Skip to content

Add the discovery v2 ingestor and tick workers - #2129

Open
lubomirw wants to merge 35 commits into
mainfrom
feat/discovery-v2-ingestor-workers
Open

Add the discovery v2 ingestor and tick workers#2129
lubomirw wants to merge 35 commits into
mainfrom
feat/discovery-v2-ingestor-workers

Conversation

@lubomirw

@lubomirw lubomirw commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

TLDR

Implements the discovery v2 engine in Core: a sequence-gated ingestor and the three tick workers (STATUS, DRAIN, PROCESS) that drive a run off the discovery_work agenda. Every tick is short, stateless and idempotent, so a pod dying mid-run is recoverable by any other pod — closing v1's hole where a crash stranded a run in PROCESSING with no way back.

Closes #1963, under the discovery v2 epic OmniTrustILM/ilm#267.

What lands

Ingestor — one funnel for connector-reported data. A drain page stages its items under the run row's lock and advances last_applied_sequence by the highest item sequence the page actually carried, never by the response's run-wide highestSequence, which counts items the connector has produced but not handed over. Advancing by the latter silently skips everything in between; it has its own regression test. Certificates keep the v1 staging table (a REQUIRED variant of that write, so staged rows and the cursor accounting for them commit together); every other resource lands in discovery_item, deduped on the connector's uniqueRef.

STATUS worker — the only place a connector-reported state becomes Core state. The connector-state mapping is applied here, with the tail drain kept behind IN_PROGRESS so a run cannot enter PROCESSING while items are still at the connector. The attempt budget counts consecutive unanswered ticks, so a week-long scan and a days-long pause both keep their budget while the connector keeps answering.

DRAIN worker — one bounded page, then a decision. Continuations are direct-published with the agenda row parked as a backstop. The handover to PROCESSING requires a full acknowledgement, not just an empty page, and sends the contract's ack drain before releasing the connector handle.

PROCESS worker — processing becomes a database cursor instead of one long message handler. Bounded batches through the unchanged import pipeline; a batch that dies leaves its rows reclaimable. Terminal WARNING or COMPLETED is read from the evidence the rows carry.

Also: discovery.run_meta is retyped to List<MetadataAttribute>. It holds the connector's run handle, which the wire carries as an attribute list, but the field was Map<String, Object> — unable to represent a JSON array at all — so the first code to replay the handle could not read one back.

Review round already applied

A multi-reviewer pass (guided + superpowers + hygiene; Copilot stalled and is recorded as a failed source) found 28 findings, and every actionable one is fixed in this branch. The three worst were all in the seams between ticks, which is why the suites were green:

  • A late DRAIN tick on a PROCESSING run deleted the whole agenda, taking the PROCESS row driving the import — after which the reaper read a live run as work-lost and failed it.
  • STATUS had no PROCESSING guard, so a tick from before the handover called a handle-less connector and read the 404 as "this run vanished", ending a healthy run mid-import.
  • PROCESS measured its backlog on processed, which the pipeline deliberately leaves false on rows it never reached — so those were re-claimed every tick, the row was committed due-now and published (making the sweep a competing publisher, hence double imports), and no budget ever stopped it.

Plus: the not-tracked predicate missed the tunneled 404 its own Javadoc claimed to cover; unchecked connector failures bypassed the budget entirely; the cursor-lag re-drain was a backoff-free retry storm; and a page omitting the required more/highestSequence was read as "drain finished".

Testing

71 discovery tests (12 ingestor ITests + 5 unit, 14 STATUS, 9 DRAIN, 12 PROCESS, 4 listener, plus the existing sweep and repository suites), each new race test failing against the pre-fix code. DiscoveryServiceITest (21) and the certificate-handler suites unchanged, so v1 behaviour stays frozen. TransactionalBoundaryArchTest and ConnectorApiClientArchTest green.

Deliberately not here

  • Docs updates live in other repos — the status table missing Stopped/Cancelled and the absent discovery-provider-v2.md belong to Discovery v2 provider documentation and quick start documentation#355, the v2 API-reference group to Discovery v2 interface documentation and OpenAPI sync interface-documentation#219.
  • Run-message storage is being replaced wholesale by Store discovery run messages as aggregated rows #2127, so the current JSONB cap was left alone rather than tuned.
  • PROCESS batches are not exclusively claimed. The agenda row is parked as a backstop rather than due-now, which stops the sweep publishing a competing tick — but that narrows the window rather than closing it. A batch running longer than the backstop, or a duplicate JMS delivery, can still have two ticks select the same rows. The certificate insert is protected by the inventory constraint; the metadata updates, event histories and action triggers around it are not. Tracked as Serialise discovery agenda claims and processing batches #2130.
  • Five judgment calls from the earlier review are recorded but unfixed: the terminator not cancelling at the connector, the ingestor's lock window across a whole page, the key-fingerprint correlation assuming the connector's digest matches Core's, the payload-cast severity, and the two docs pages.
  • The full-ack timing is a known semantic gap: the contract frames that acknowledgement as following the run's terminal state, but Core releases the handle at the PROCESSING swap, so it is sent at the last moment the handle exists. Reconciling the two belongs to Discovery v2 adapter lifecycle and core API #1964.

One funnel for connector-reported discovery data. A drain page stages its
items under the run row's lock and advances last_applied_sequence by the
highest item sequence the page actually carried -- never by the response's
run-wide highestSequence, which counts items the connector has produced but
not handed over, and would skip everything in between.

Advisory events stay advisory: progress is a cosmetic snapshot, an error
joins the run's message log, and the two events meaning "there is something
to fetch" schedule the tick that fetches it authoritatively. None of them
commits run state or moves the cursor, and none may put work back on a run
that has already finished.

Certificates keep the v1 staging table, whose write is split into a
REQUIRED variant so staged rows and the cursor accounting for them commit
together; every other resource lands in discovery_item, deduped on the
connector's uniqueRef.
run_meta holds the connector's opaque run handle -- a serialized
List<MetadataAttribute>, the authority-v3 handle type -- which the lifecycle
calls replay verbatim. The field was typed Map<String, Object>, which cannot
represent a JSON array at all, so the first caller to actually replay the
handle could not read one back.

The mapper proof moves to discovery_item.payload, which is a genuine
Map-typed jsonb column rather than one borrowed for the shape.
The STATUS tick is the only place a connector-reported state becomes Core
state: pushed events merely ask for it, and what the connector answers here
is what commits. The mapping follows the design's table, with the tail drain
kept behind IN_PROGRESS so a run cannot enter PROCESSING while items are
still at the connector.

The attempt budget counts consecutive unanswered ticks rather than elapsed
time. Any clear answer pulls the counter back to the ladder's ceiling, so a
week-long scan and a days-long pause both keep their budget while the
connector keeps answering; it runs out only when the connector goes silent.
A run the connector no longer tracks ends immediately -- the contract makes
that answer definitive.

Two collaborators land with it: the v2 client, which rebuilds the identity
and configuration every stateless-connector call replays, and the run
terminator, which lands the status, the handle release and the agenda
deletion together so a run can never end while still scheduled.
A drain tick pulls one bounded page, ingests it, and decides what comes
next. When the connector says more is waiting, the worker commits the
cursor advance and the agenda row's new due time and then publishes the
follow-up tick itself, so the sweep's cadence is the recovery latency after
a lost message rather than the drain's throughput ceiling.

The handover to processing needs a full acknowledgement, not just an empty
page: more:false on a completed run still allows the cursor to sit below
the run-wide highestSequence, and swapping there would strand the items in
between at a connector nobody will ask again. Anything short of a caught-up
cursor drains again immediately; only a full ack swaps the agenda over,
releases the connector handle and moves the run to PROCESSING.

The attempt budget keeps counting consecutive failures only, so a page that
answers -- items or a valid empty answer -- refreshes it. An empty answer
refreshes without speeding the cadence up, since there is nothing to hurry
toward.
Processing becomes a database cursor instead of one long message handler.
Each tick claims a bounded batch of the run's unprocessed rows, runs the
unchanged import pipeline over it, and either publishes the next batch's
tick or ends the run. A pod that dies mid-batch simply leaves those rows
unstamped, so any pod reclaims exactly them on the next tick -- closing
v1's hole where a crash stranded a run in PROCESSING with no way back.

The pipeline is shared, not reimplemented: the run-level decision is lifted
out of importAndReport into importGroups, which both flows call. v1 keeps
finishing a run in one pass through the finished event; a v2 run is ended
by this worker, the only party that knows the backlog is empty. The final
status is read from the evidence the rows carry, so a run with any recorded
per-certificate reason ends WARNING rather than reporting a clean pass.

Scope is certificates: keys are staged but have no import pipeline yet, so
this worker neither claims them nor waits on them.
A PROCESS batch that falls short now adds its summary to the run's message
log instead of leaving the run silent until it ends. An operator watching a
long run can see what is going wrong while it is still going wrong, and the
per-certificate reasons stay where they were, on the rows.

The sentences move onto DiscoveryRunCounts so both flows say the same thing
about the same counts: the v1 pass still turns them into its final status
message, and a v2 batch files the identical wording per batch.
The reaper carried its own copy of the terminal mutation, and it had
already drifted: unlike the terminator it never recorded the ending in the
run's message log, so a run the reaper finished lost the one sentence
explaining why.

The mutation now lives once, on the terminator, and the reaper applies it
to the run it already holds locked. Its own re-assert and row lock stay
where they are -- those are the reaper's conditions, not the terminator's,
and routing it through the transactional entry point would deadlock
against the lock it is already holding.
The published schema for these fields promises curated text and no raw
exception messages, and the producers added with the tick workers were
concatenating getMessage() straight in -- putting transport internals,
provider details and parser text where API clients read them.

Certificate staging failures now go through the classifier that already
exists for exactly this leak on processedError. Connector failures forward
only an RFC 9457 detail, which the contract already obliges the connector
to curate, and are classified otherwise. The full exception still reaches
the log, which is where it was always the useful thing.
Four ways a v2 run could be killed, stranded, or double-imported. All were
in the seams between the ticks rather than inside any one of them, which is
why the suites were green.

A run that has handed over to processing is alive but no longer holds a
connector handle. Both connector-driven ticks tested only for a terminal
run, so one still in flight from before the swap called the connector with
no handle and read the resulting 404 as "this run no longer exists",
ending a healthy run mid-import; a pushed event could re-arm that work for
the same reason. They now ask whether the run has left the connector. The
drain's drop was worse: it deleted the entire agenda, taking the PROCESS
row that drives the remaining import, after which the reaper read a live
run with no work as lost and failed it. A tick whose own work is obsolete
now deletes only its own row.

Processing looped instead of ending. The backlog was measured on
`processed`, which the import pipeline deliberately leaves false on a row
it never reached, so those rows were re-claimed every tick and the backlog
never drained; the row was committed due-now and published, and a due-now
row is one the sweep claims and publishes too, so two ticks ran the same
batch and imported it twice; and nothing enforced the PROCESS budget, so
none of it stopped. The claim now excludes rows already carrying a reason,
the row is parked as a backstop rather than a competing publisher, and a
tick that accounts for nothing climbs its ladder and ends the run.

The not-tracked predicate missed the case its own javadoc claimed: over the
proxy the problem body is discarded and the client raises a plain
ConnectorEntityNotFoundException, so a forgotten run burned its whole
budget. It also OR-ed code with status, letting an unrelated 404 read as
not-tracked, and reached the status through HttpStatus.valueOf, which
throws on a valid but unmapped code. It now mirrors the library's own
predicate.

The drain could also run forever: a connector counting more items than it
handed over drove a due-now retry with the counter reset, and a page
omitting the required `more` or `highestSequence` was read as "finished",
handing a half-drained run to processing and releasing the handle. Both are
now bounded by the budget. The handover sends the contract's full
acknowledgement before releasing the handle, so a connector may discard the
run's state rather than holding it for its full retention window.

Ingestion is hardened alongside: a page for a run that ended while it was
in flight is dropped, advisory events no longer write to a finished run,
certificates are deduped within a page since their table has no uniqueRef
constraint to lean on, a payload that does not match its declared resource
costs itself rather than the page, and items arriving without the required
sequence are recorded on the run instead of vanishing.
The two worst defects this round found lived in states no test set up: a
STATUS or DRAIN tick arriving after the drain handed the run to processing,
and a PROCESS batch that records reasons without stamping rows. Each new
test fails against the code as it stood before the fixes.

The drain's due-now assertions are rewritten as backstop assertions, since
a row parked due-now is one the sweep claims and publishes itself.
The tick workers' class javadocs restated what their own methods already
documented -- the backstop rule, the attempt budget, the continuation
mechanism -- so the same fact lived in two places and only one of them
would get corrected when the code moved. That already happened once on
this branch: the drain's class doc still claimed the lagging-cursor branch
retried immediately after the fix made it back off.

Class docs now carry what spans the whole component; a method's mechanism
stays on the method, where a reader stands when they are about to change
it. The terminator's doc also loses a clause narrating a defect that no
longer exists.
Copilot AI balanced review requested due to automatic review settings August 23, 2026 06:38
@lubomirw lubomirw added the enhancement Improvement to existing functionality label Aug 23, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements the recoverable Discovery v2 ingestion and tick-driven lifecycle.

Changes:

  • Adds sequence-gated ingestion and resource staging.
  • Adds STATUS, DRAIN, and PROCESS workers with agenda-backed retries.
  • Extends lifecycle bookkeeping, connector handling, and integration coverage.

Reviewed changes

Copilot reviewed 32 out of 32 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
src/test/java/com/otilm/core/util/DiscoveryRunMetaFixture.java Adds run-handle fixtures.
src/test/java/com/otilm/core/service/handler/discovery/DiscoveryEventIngestorTest.java Tests ingestion edge cases.
src/test/java/com/otilm/core/messaging/jms/listeners/discovery/DiscoveryWorkListenerTest.java Tests tick dispatch.
src/test/java/com/otilm/core/messaging/jms/listeners/discovery/DiscoveryRunReaperUnitTest.java Updates reaper lifecycle tests.
src/test/java/com/otilm/core/integration/repository/DiscoveryRepositoryITest.java Tests typed run metadata persistence.
src/test/java/com/otilm/core/integration/discovery/DiscoveryWorkSweepITest.java Updates sweep/reaping coverage.
src/test/java/com/otilm/core/integration/discovery/DiscoveryStatusTickWorkerITest.java Tests STATUS transitions and retries.
src/test/java/com/otilm/core/integration/discovery/DiscoveryProcessTickWorkerITest.java Tests processing batches and recovery.
src/test/java/com/otilm/core/integration/discovery/DiscoveryEventIngestorITest.java Tests cursor and advisory ingestion.
src/test/java/com/otilm/core/integration/discovery/DiscoveryDrainTickWorkerITest.java Tests draining and handover.
src/test/java/com/otilm/core/integration/config/JsonColumnFormatMapperITest.java Moves JSON mapper verification to staged items.
src/main/resources/application.yml Adds worker tuning properties.
src/main/java/com/otilm/core/service/writer/DiscoveryWriter.java Adds run-message appending.
src/main/java/com/otilm/core/service/writer/discovery/DiscoveryWorkWriter.java Adds work-type-specific deletion.
src/main/java/com/otilm/core/service/writer/discovery/DiscoveryItemWriter.java Adds transactional item staging.
src/main/java/com/otilm/core/service/handler/discovery/DiscoveryV2Client.java Implements connector lifecycle calls.
src/main/java/com/otilm/core/service/handler/discovery/DiscoveryStatusTickWorker.java Implements STATUS ticks.
src/main/java/com/otilm/core/service/handler/discovery/DiscoveryRunTerminator.java Centralizes terminal transitions.
src/main/java/com/otilm/core/service/handler/discovery/DiscoveryProcessTickWorker.java Implements bounded processing ticks.
src/main/java/com/otilm/core/service/handler/discovery/DiscoveryEventIngestor.java Implements sequence-gated ingestion.
src/main/java/com/otilm/core/service/handler/discovery/DiscoveryDrainTickWorker.java Implements draining and processing handover.
src/main/java/com/otilm/core/service/handler/discovery/DiscoveryConnectorErrors.java Classifies connector failures.
src/main/java/com/otilm/core/service/handler/CertificateHandler.java Adds transaction-joining certificate staging.
src/main/java/com/otilm/core/model/discovery/DiscoveryRunLifecycle.java Adds shared lifecycle helpers.
src/main/java/com/otilm/core/messaging/jms/listeners/discovery/DiscoveryWorkListener.java Dispatches agenda messages to workers.
src/main/java/com/otilm/core/messaging/jms/listeners/discovery/DiscoveryRunReaper.java Reuses centralized termination logic.
src/main/java/com/otilm/core/events/handlers/discovery/DiscoveryRunCounts.java Adds reusable warning descriptions.
src/main/java/com/otilm/core/events/handlers/CertificateDiscoveredEventHandler.java Exposes bounded batch processing.
src/main/java/com/otilm/core/dao/repository/DiscoveryWorkRepository.java Adds selective work deletion.
src/main/java/com/otilm/core/dao/repository/DiscoveryItemRepository.java Adds idempotent native staging.
src/main/java/com/otilm/core/dao/repository/DiscoveryCertificateRepository.java Adds processing cursor queries.
src/main/java/com/otilm/core/dao/entity/Discovery.java Retypes connector run metadata.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/main/resources/application.yml Outdated
Comment thread src/main/resources/application.yml
Six findings from the PR review, four of them residue from the previous
round: a fix that covered a path but not its sibling.

The full acknowledgement was sent before the cursor check, so a run whose
cursor lagged told the connector it could discard the very items Core was
still waiting for. It now goes out only once ingestion has caught up, and
never on the lag path.

Terminal status answers bypassed the under-lock ownership recheck, so a run
the drain had safely handed over could still be failed or cancelled by a
status answer in flight -- the same defect the last round fixed for live
answers only. They now go through the same locked block, which also records
the connector's own view of the ending rather than losing it.

The handover re-asserted only that the run had not ended, so a concurrent
pause or a second drain could still hand over and publish duplicate
processing work. It now re-asserts the whole precondition.

A status answer omitting the required run state reached getCode() inside
the transaction and escaped the connector-call catch, retrying past its
budget; it is now bounded like the drain's non-conformant page.

A batch that failed wholesale was rethrown to the listener, which logs and
acknowledges, so the budget was never spent and a persistent failure
stranded the run in PROCESSING -- the failure this worker exists to close.
It now takes the stall path.

The terminal decision read only per-row errors, so a run whose bookkeeping
write failed, or whose validation was never requested, reported a clean
success. Run-level evidence counts too.

The context-signature baseline rises to 61: three workers, three
collaborator mock sets, three contexts.
The v1 entry point reaches the shared staging method by self-invocation,
which Spring's proxy does not intercept, so the @transactional on it was
inert -- and worse, it told a reader the method starts its own REQUIRED
boundary when in practice it always runs in the caller's. Both callers
already supply one: v1 through its own REQUIRES_NEW, v2 through the
ingestor's.

The native staging insert keeps its parameter-per-column signature, which
is what binding a native query costs; suppressed with the reason rather
than wrapped in an object that would be unpacked again at the only call
site.
The v1 wrapper existed only to impose REQUIRES_NEW, and it reached the
staging method by self-invocation -- which Spring's proxy does not
intercept, so the boundary it was imposing never applied. Dropping the
annotation from the inner method traded that for something worse: an
un-annotated caller invoking a @transactional one, which cannot satisfy it
at all.

Both callers now reach the one annotated method through the proxy, and
REQUIRED gives each what it needs. v1 calls from a virtual thread holding
no transaction, so every batch still opens and commits its own and a later
batch's failure cannot undo it. v2 calls from inside the ingestor's
transaction and joins it, so staged rows and the cursor accounting for them
still commit together.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 34 changed files in this pull request and generated 6 comments.

Suppressed comments (4)

Previously missed (2) — in code that hasn't changed since the last review.

src/main/java/com/otilm/core/service/handler/discovery/DiscoveryStatusTickWorker.java:198

  • schedule is an upsert that resets the DRAIN attempt to 0 and makes it due immediately. Because STATUS remains scheduled after a COMPLETED answer, every later completed poll wipes out the counter that awaitTheRest is spending; at the configured 5-minute STATUS cadence and 30-second DRAIN ceiling, the 100-attempt lag budget cannot be exhausted. Arm DRAIN only on the transition into completed state, schedule it only if absent, or retire STATUS when tail draining begins.
            case COMPLETED -> {
                run.setStatus(DiscoveryStatus.IN_PROGRESS);
                clearResumeWindow(run);
                workWriter.schedule(run.getUuid(), DiscoveryWorkType.DRAIN, OffsetDateTime.now(ZoneOffset.UTC));

src/main/java/com/otilm/core/dao/repository/DiscoveryCertificateRepository.java:43

  • This ordered backlog query and its matching count execute for every 200-row batch, but the migrations define no discovery_certificate index covering discovery_uuid, the outcome predicates, and creation order. Each tick therefore scans/sorts the run repeatedly (the count runs twice), making large-run processing quadratic. Add a suitable partial index and avoid full remaining-backlog counts where batch outcome accounting or exists is sufficient.
    @EntityGraph(attributePaths = {"certificateContent"})
    List<DiscoveryCertificate> findByDiscoveryUuidAndNewlyDiscoveredTrueAndProcessedFalseAndProcessedErrorIsNullOrderByCreatedAsc(
            UUID discoveryUuid, Pageable pageable);

    long countByDiscoveryUuidAndNewlyDiscoveredTrueAndProcessedFalseAndProcessedErrorIsNull(UUID discoveryUuid);

src/main/java/com/otilm/core/service/handler/discovery/DiscoveryDrainTickWorker.java:156

  • hasCaughtUp checks only the cursor. A concurrent STATUS tick can move the run to STOPPED after that check; this acknowledgement then lets the connector discard its state, swapToProcessing rejects the stopped run, and Core retains a resumable run whose handle no longer exists. Establish the handover while retaining the handle before acknowledging, or otherwise reserve/recheck connector ownership without holding a database lock across HTTP.
        sendFullAck(run, page.getHighestSequence());
        if (swapToProcessing(discoveryUuid, page.getHighestSequence())) {

src/main/java/com/otilm/core/service/handler/discovery/DiscoveryProcessTickWorker.java:196

  • anyFailed can be true solely because of a run-level message such as validation not being queued or bookkeeping failing, with no row carrying processedError. In that case the advertised certificate-list detail does not exist. Mention the run messages as the source for run-level warnings.
            terminator
                    .end(discoveryUuid, DiscoveryStatus.WARNING,
                            "Discovery completed with warnings. See the discovery certificate list for "
                                    + "per-certificate detail.");

Six findings from the second review round, four of them the same shape as
the last: a request that went out before the handover, answering after it.

A status or drain call started while the run was still connector-owned can
return 404 once the handover has released the handle. Every failure path
took that as "the run no longer exists" and ended a healthy import,
deleting the agenda row driving it. The terminator gains a
connector-owned entry point that refuses a run which has left the
connector, and every connector-driven ending now goes through it;
processing keeps the plain one, since ending a PROCESSING run is exactly
its job.

The handover's own PROCESS row was scheduled due-now and then published
directly -- the double-publisher bug fixed for continuations and missed at
the handover itself. It is parked at the backstop like the rest.

Ingestion sat outside the connector-call catch, so a page that could not be
staged escaped to the listener and was redelivered forever without the
budget advancing. It now spends budget like any other unanswered tick.

Connector-supplied prose no longer reaches the run's message. The contract
obliges a connector to curate its RFC 9457 detail, but an obligation is not
a guarantee and the run message is user-visible; only the error code
survives, mapped to text Core wrote. The connector's wording still reaches
the log.

The stall-limit message pointed operators at a certificate list that has
nothing to show for those rows, since they carry no per-row reason; it now
points at the run's messages, where the batch failure is recorded.

Tests cover the classifier's arms, what the v2 client puts on the wire
including the maxBytes clamp and the acknowledgement cursor, and the drain
paths these fixes added.
Four defects a third review round found, three of them ways a run either
loses itself or never ends.

The full acknowledgement went out before the handover committed. It lets
the connector discard the run's whole state, so a swap that then rolled
back or lost a race left the connector free to throw away a run Core never
finished taking over -- and the next drain would meet a 404 and fail a run
whose items were all safely staged. It now goes out after the swap
commits, replayed against the handle that swap released.

Every repeated `completed` status answer re-armed the drain row, and
arming resets the attempt counter, so a permanently failing drain had its
budget refreshed faster than it could spend it and the run never ended.
The row is armed on the transition only.

Processing claimed rows by page while the import pipeline acts once per
certificate content, so a certificate found on more hosts than the batch
size had its group split across ticks and its triggers, histories and
validation run once per page. The claim pages by content instead, and
takes every pending row of the contents it claims.

A `more: true` page carrying nothing new was answered by publishing
another tick at once, which spins as fast as the connector repeats itself.
Ingestion now reports whether the cursor moved, and a page that did not
move it takes the bounded path.

Also drops a boilerplate javadoc sentence, shortens the groundwork note on
the advisory path, and rewrites the acknowledgement's own doc to match its
new ordering.
@lubomirw lubomirw changed the title Discovery ingestor and tick workers Add the discovery v2 ingestor and tick workers Aug 24, 2026
discovery_certificate has had no index since it was created -- the foreign
key to discovery is a constraint, and PostgreSQL does not index the
referencing side -- so the claim, its two backlog counts and the
end-of-run warning check have all been sequential scans, once per tick.

Both indexes are partial. A row leaves the pending set permanently once it
carries an outcome, so that index shrinks as a run drains rather than
growing with it, and the failed-row index holds nothing at all on a
healthy run.

The claim also breaks ties on the content id now. i_cre comes from the JVM
clock and the staging loop writes rows faster than it advances, so two
groups stamped in the same tick could otherwise swap places between pages
-- which made the javadoc's promise of a stable order untrue.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 37 out of 37 changed files in this pull request and generated 2 comments.

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

src/main/java/com/otilm/core/service/handler/discovery/DiscoveryDrainTickWorker.java:137

  • This terminal message is inaccurate when more is present but highestSequence is missing: the connector did say whether items remain. Name both required pagination fields so operators can diagnose either malformed response.
                            "The connector's results did not say whether more items remain");

src/main/java/com/otilm/core/service/handler/discovery/DiscoveryProcessTickWorker.java:74

  • Validate this tunable before storing it. With discovery.processing.batch-size=0 (or a negative value), PageRequest.of(0, batchSize) throws before the worker reaches its bounded stall path; the listener acknowledges each failure and the agenda retries forever, leaving the run in PROCESSING. Failing application startup makes the misconfiguration actionable.
        this.batchSize = batchSize;

Arming an agenda row is a fresh start and resets its attempt counter, and
every caller meaning only "look now" inherited that. A pushed event is not
an answer from the connector, so a connector whose endpoint was broken but
whose event stream worked could keep refreshing the failure budget and
hold a dead run open indefinitely.

The agenda gains an expedite operation: same upsert, without the counter
reset in its conflict branch. New rows still start at zero; existing ones
keep what they have spent. Arming stays for the genuine fresh starts, the
drain handover and the first completed status answer.

This is the second instance of the same cause. A previous round fixed the
completed-status path re-arming the drain row on every poll, but fixed the
instance rather than the reset itself.

Certificate metadata definitions are also registered once per drained page
before its rows can reach the pipeline. The pipeline imports content
groups in parallel and each applies its rows' metadata, so two groups
carrying the same new definition raced to insert it and the loser's whole
group rolled back as failed -- a certificate reported unimportable for no
reason but timing. The v1 download path has always done this before
submitting a batch; v2 never did.
An independent review of the whole branch found the same accounting
duplicated in the status and drain workers, down to a verbatim comment.
Both copies now delegate to DiscoveryTickBudget, so a fix to how an
unanswered tick is charged can no longer land in one worker and not the
other.

Draining a page Core cannot store is charged to the same budget but ends
the run with Core named as the party that failed. The old path routed it
through the connector's message, which told an operator the connector
had stopped handing over items when it had answered correctly.

Also drops two pieces of sediment the review turned up. The drain's
hasCaughtUp pre-check re-read the cursor in its own transaction to
decide what swapToProcessing re-asserts under the run row's lock, and
the caller answered both the same way. The status worker set
connector_status twice per live answer.

The remaining comment corrections are all cases where the code moved and
the prose did not: applyTerminalState has two lock-holding callers
rather than the one its javadoc named, applyDrainPage's idempotency
paragraphs sat inside its @return tag, the ingestor's terminal-only
guard never said why PROCESSING is deliberately still open to staging,
and claimContents claims nothing until core#2130 lands.
The full ack is the one call that licences a connector to discard a
run's state, and no test asserted it was sent, that it carried the run's
highest sequence, or that it was withheld while the cursor still lagged.
It now has all three, plus the case that matters most: an ack that fails
leaves the handover standing, because the connector retains the run for
24 hours regardless and rolling back would cost the import instead.

Three budget-exhaustion endings were also unpinned -- processing that
never accounts for its backlog, a connector that keeps omitting the run
state, and a page Core keeps failing to store. Each is what stops a run
sitting in its phase forever, so each is worth a test.

Renames ingestionFailure_spendsBudgetRatherThanEscapingToTheListener,
which asserted the attempt counter was zero. The budget is spent by the
sweep's claimer, which this test bypasses; what it actually pins is that
the failure is contained, the cursor is untouched and the agenda row
survives. It now says so and asserts all three.
@lubomirw
lubomirw requested a balanced review from Copilot August 24, 2026 05:35

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 39 out of 39 changed files in this pull request and generated 3 comments.

The process worker read its backlog outside any lock, so a drain page
still in flight across the handover could stage rows after the count and
before the ending committed -- staged, counted by nobody, never
imported. The zero-backlog check now runs inside the terminal
transaction, where it serialises against staging: both take the run
row's lock, so a late page either commits in time to be imported or
finds the run already terminal and is refused. When the check finds work
after all, the run carries on processing instead of ending.

This is what makes the ingestor's terminal-only guard defensible. Its
comment previously claimed refusing a late page was the only way those
items could vanish; they could vanish through this race just as
silently, so the comment now describes the lock ordering that actually
holds.

Also splits the two kinds of terminal evidence. A run warned on
run-level evidence alone has no row carrying a reason, so pointing the
operator at the discovery certificate list sent them to a list of clean
rows.

Found by the Copilot review on #2129.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 39 out of 39 changed files in this pull request and generated 4 comments.

A hygiene pass over the branch. The substantive one: both tick workers
carried a verbatim comment claiming DiscoveryV2Client's javadoc warns
its declared throws is incomplete. It carries no such warning, and the
concrete cases the comment lists -- a 422 arriving as an unchecked
ValidationException, a bodiless 2xx as IllegalStateException -- make the
point without the citation.

The rest is length. Four javadocs restated their own summaries or the
repository javadoc next door, and the tick budget's class comment
narrated how the duplication came about rather than why one copy
matters.
Moving the backlog check into the terminal transaction last round fixed
one of three inputs and left the other two outside it. A late drain page
takes the same lock and can both stage rows and append to the run's
message log, so a page carrying only malformed items -- or only keys, or
certificates that all failed to stage -- appends a complaint, leaves the
certificate backlog at zero, and the ending commits the stale decision:
a run reported as completed successfully while carrying a warning.

endWhile is replaced by endWith, which hands the locked entity to a
callback and applies whatever ending it returns, or none. The process
worker now reads the backlog, the per-row failures and the message log
there, and reads the log off the locked entity rather than issuing its
own query, so it cannot be stale by construction.

Two smaller ones alongside. The results page's items field is required
on the wire -- a page with nothing to hand over sends an empty array --
so a missing items now joins more and highestSequence in the
non-conformant guard, rather than being normalised to an empty page that
reads as fully drained and releases the connector handle. And the tick
workers reject non-positive bounds at construction: a batch size of zero
throws inside PageRequest.of before the worker reaches any bounded path,
where the listener acknowledges it and nothing ever ends the run.

Found by the Copilot review on #2129.
The wire contract defines uniqueRef as the key Core dedupes an item by
"across drains and retries". discovery_item has honoured that since the
v2 schema, through uq_discovery_item_ref. discovery_certificate predates
v2 and had nowhere to put the reference, so v2 certificate dedupe was
per-page and in memory: a connector re-sending an item under a newer
sequence -- which the contract permits, and which the cursor filter lets
through by design -- staged it a second time and the run imported the
same certificate twice.

The column is nullable and the unique index partial, because v1 has no
equivalent key. A v1 provider's certificate uuid names the certificate
rather than the occurrence, and nothing in the v1 contract promises it
appears once per run, so writing it here would impose a guarantee no
provider has been asked to meet. Both staging callers now say which they
are rather than the shared path guessing.

The ingestor looks up only the references its own page carries, not the
run's whole set, which on a large run is every certificate it has found.
Reading and filtering is enough because every drain for a run serialises
on the row lock the ingestor already holds; the index is the backstop.

The index migration this branch added is extended rather than followed by
a second one -- it is unreleased, so no deployed database has run it.
Anyone who has already applied it on this branch needs a flyway repair or
a fresh database.

Found by the Copilot review on #2129.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 41 out of 41 changed files in this pull request and generated 2 comments.

Comment thread src/main/resources/application.yml Outdated
importGroups reports a percentage of the groups it was handed, and its
last group always makes that 100%. For v1 that is right -- one pass
imports the whole run. A v2 processing tick is handed one bounded batch,
so every batch was writing "Processed 100 % of newly discovered
certificates" while the run still had a backlog.

totalGroups becomes nullable and means what its name says: the run's
total, or absent when the caller sees only part of the run and reports
its own progress. The PROCESS worker then writes the message itself, as
a remaining count rather than a fraction -- it is the only party that
knows what is left, and the backlog is what a tick already measures, so
this costs no query. Deriving a run-wide denominator instead would scan
discovery_certificate on every tick, since processed rows fall outside
both of the table's partial indexes.

Also validates continuation-backstop in both tick worker constructors,
which the previous round missed while adding the other three. Zero or
negative parks the backstop row due-now while the worker publishes the
continuation itself, so the sweep claims it and publishes a competing
tick -- the race the backstop exists to prevent, and it would read as
duplicate processing rather than as a misconfiguration.

Found by the Copilot review on #2129.
if (apply(discoveryUuid, status)) {
// A clear answer refreshes the budget without restarting the backoff ramp: the counter drops to
// the rung where the ladder already reached its slowest delay.
workWriter

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

! Major | Correctness

DiscoveryStatusTickWorker.java:101 resets the persisted attempt while DiscoveryWorkClaimer can concurrently read the old value and later overwrite the reset. DiscoveryProcessTickWorker.java:212 and DiscoveryDrainTickWorker.java:234 also trust attempts carried by delayed messages, so an old high attempt can terminate after a newer success, while an old low attempt can reduce the persisted counter. This violates this PR's consecutive-unanswered-ticks budget and can prematurely fail a healthy run or retry indefinitely.

Suggested: Serialize agenda claim, reset, and spend operations with a row lock or generation-based compare-and-set, ignoring stale tick attempts before terminating or rescheduling.

flagged by Transaction & concurrency

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, and folded into #2130 rather than answered here. Both halves hold: resetAttempt does not take the sweep's advisory lock, so a claimer that has already read the old value overwrites the reset; and the workers trust the attempt their message carries, so a stale redelivery either ends a recovered run or rewinds the budget.

It went to #2130 rather than a new issue because it is the same root shape as the batch claim already tracked there -- agenda state mutated from two places with no shared lock -- and it needs the same concurrent-ticks harness that issue's DoD already calls for. #2130 is now titled "Serialise discovery agenda claims and processing batches" and carries two DoD items for this.

if (batch.isEmpty()) {
return new DiscoveryRunAccumulator().counts();
}
EventMessage eventMessage = constructEventMessage(discovery.getUuid(), null, null);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

! Major | Correctness

CertificateDiscoveredEventHandler.java:286 creates the PROCESS event with a null user, while DiscoveryWorkListener.java:42 invokes this path without installing an Authentication. AuthorizationEnforcerImpl.java:44-48 therefore denies the check at CertificateDiscoveredEventHandler.java:295, causing every non-empty batch to stall and eventually end as WARNING without importing certificates.

Suggested: Carry and authenticate the initiating user UUID for PROCESS ticks, or install a defined system principal authorized for CERTIFICATE:CREATE before invoking processBatch.

flagged by Security & info-leak

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and this was a blocker. I traced it: EventListener.processMessage calls authenticateAsActingUser when the event carries a userUuid, which is how v1 gets an identity on a JMS thread. DiscoveryWorkListener installed nothing, so enforce read a null Authentication and refused -- and because importBatch swallows to reach its bounded stall path, the refusal would have surfaced as a run that quietly imported nothing and ended WARNING.

Took your first suggestion. The run now records who started it (started_by_user_uuid, captured at creation while a caller is still on the thread) and the worker authenticates as them before each batch.

The proof was uncomfortable: setting the fixture's user turned 9 of 20 process tests red immediately. They passed before only because the ITests mock the import pipeline, so enforcement was never exercised. There is now a test asserting authenticateAsUser is called with the run's user. a5b5d8e

* Deliberately bare for the schema groundwork: the staging writes, the union listing and the processing claims each
* bring their own queries with the tasks that own them.
* Staging store for every discovered resource except certificates, which keep their own v1 table until the
* evidence-gated unification (core#2027).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

. Nitpick | Documentation

This PR adds core#... breadcrumbs at DiscoveryItemRepository.java:14, DiscoveryEventIngestor.java:260, DiscoveryProcessTickWorker.java:42,134, and DiscoveryProcessTickWorkerITest.java:157. The surrounding prose already explains the relevant invariants, while the PR description is the durable home for these tracking links.

Suggested: Remove the five core#... references while retaining the adjacent behavioral rationale.

flagged by Noise & hygiene

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, all five removed with the behavioural statements kept. 06abf7e

|| key.getFingerprint().isBlank()) {
return true;
}
return keyItemRepository.findByFingerprint(key.getFingerprint()).isEmpty();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

**~ Minor** | Performance

DiscoveryEventIngestor.java:241-244 loops over every drained item, while DiscoveryEventIngestor.java:324 calls findByFingerprint for each key. A 500-key page therefore adds 500 SELECTs while applyDrainPage holds the run's pessimistic lock.

Suggested: Collect key fingerprints before the loop, fetch existing fingerprints once with an IN projection, and compute newlyDiscovered from that set.

flagged by Persistence & schema

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid. Fixed: the page's key fingerprints are collected once and resolved with a single IN query, and novelty is computed from that set. It mattered more than the round-trip count alone suggests, since this runs inside the transaction holding the run's row lock -- everything else about the run queues behind it. a5b5d8e


/** Every pending row of the given contents, so a claimed group is always whole. */
@EntityGraph(attributePaths = {"certificateContent"})
List<DiscoveryCertificate> findByDiscoveryUuidAndCertificateContentIdInAndNewlyDiscoveredTrueAndProcessedFalseAndProcessedErrorIsNull(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

**~ Minor** | Performance

DiscoveryCertificateRepository.java:57 returns every pending row for the selected content IDs without pagination. DiscoveryProcessTickWorker.java:148-155 deliberately accepts the first group even when its weight exceeds batchSize, so one certificate reported at arbitrarily many locations bypasses the configured batch limit and materializes the entire group in memory.

Suggested: Introduce an explicit maximum group size with bulk handling for oversized groups, or redesign the claim so rows can be paged without repeating group-level side effects.

flagged by Persistence & schema

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and deliberate today -- the group wins because splitting it would run its triggers, histories and validation once per page. But you are right that it is unbounded, and "documented trade-off" is not the same as "safe".

Tracked on #2130 rather than fixed here: that issue now covers the claim redesign this needs, and an explicit maximum group size only makes sense alongside the bulk handling for oversized groups you describe.

* Consumes {@code provider.discovery-work} ticks and hands each to the worker for its type.
*
* <p>
* <b>No {@code @Transactional} here:</b> a tick calls the connector, and a connector call must never run inside a

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

**~ Minor** | Documentation — comments state what the code is not

Comments should say what a thing is, not what it isn't. 64 added comment lines are framed as a negation or a contrast — a reader has to hold the rejected alternative in mind to extract the actual statement, and the rejected alternative is not in the code.

This line is the pattern at its clearest: <b>No {@code @Transactional} here:</b> documents an annotation that isn't present. Others:

  • DiscoveryEventIngestor.java:292 — "Names the page by what it carried, not by where the cursor stood before it."
  • DiscoveryEventIngestor.java:277 — "the table's partial unique index is the backstop, not the mechanism."
  • DiscoveryDrainTickWorker.java:280 — "Parked, not due-now"
  • DiscoveryEventIngestor.java:336 — "Expedited, not armed"
  • DiscoveryItemWriter.java:36 — "The enum's name, not its wire code"
  • DiscoveryV2Client.java:104 — "Clamped rather than trusted"
  • DiscoveryWorkWriter.java:50 — "{@code REQUIRED} (not {@code REQUIRES_NEW})"
  • DiscoveryProcessTickWorker.java:81 — "A backstop that is not in the future is not a backstop."
  • CertificateDiscoveredEventHandler.java:269 — "Deliberately says nothing about the run as a whole: it emits no DISCOVERY_FINISHED, decides no final status, and does not care whether more batches follow."
  • DiscoveryConnectorErrors.java:24 — "Nothing the connector wrote is forwarded — not {@code getMessage()}, and not the RFC 9457 {@code detail} either."
  • Three ITest class docs open with "Not {@code @transactional}: …"

Suggested: Restate each positively. No @Transactional here: a tick calls the connector…Each worker opens its own short transactions; a connector call runs outside any transaction. Parked, not due-nowParked one backstop interval out. Clamped rather than trustedClamped to the contract's cap.

flagged by pr-hygiene · obvious-comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right as a rule, and applied -- b0be9c5 restated the sites you listed positively, including the three ITest class docs.

I kept two as negations, and want to flag them rather than have it look like an oversight. Parked, not due-now and REQUIRED (not REQUIRES_NEW) exist because the negated alternative is the tempting wrong edit, and both were actual bugs on this branch -- a due-now row lets the sweep publish a competing tick, and REQUIRES_NEW stops reschedule joining the claimer's transaction. Restating them as facts keeps the fact and loses the warning. They are reworded but still name what must not happen.

if (batchSize <= 0) {
throw new IllegalArgumentException("discovery.processing.batch-size must be positive");
}
// A backstop that is not in the future is not a backstop. The row would be parked due-now while this

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

**~ Minor** | Documentation — the same rationale is restated at many sites

Several explanations are repeated near-verbatim across the diff, so each one now has many copies to keep true.

  • The backstop / due-now race is explained at six sites: here, DiscoveryDrainTickWorker.java:79-81, :280-281, :295-297, DiscoveryProcessTickWorker.java:188-190, and DiscoveryTickWorkerConfigTest.java:39-41.
  • "strands the run in PROCESSING forever" appears at 8 sites.
  • The listener's log-and-acknowledge consequence is spelled out at 7 sites, twice word-for-word: "RuntimeException too: over MQ a 422 arrives as an unchecked ValidationException and a bodiless 2xx as IllegalStateException…" is identical in DiscoveryDrainTickWorker.java:109-111 and DiscoveryStatusTickWorker.java:76-78, as is "Outside any transaction, by the platform's connector-call rule." at :106 and :73.
  • The tunneled-404 and REGISTRATION_NOT_FOUND-on-422 reasoning is given in full in both DiscoveryConnectorErrors.java:51-63 and its test at DiscoveryConnectorErrorsTest.java:31-40.

Suggested: Keep one authoritative copy — on DiscoveryTickBudget for the budget rules, on DiscoveryWorkWriter.schedule/expedite for the backstop rules, on DiscoveryConnectorErrors for the 404 rules — and let the other sites carry a bare {@link} or nothing at all.

flagged by pr-hygiene · doc-duplication

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and done exactly as you suggest -- one authoritative copy each, the rest pointing at it. The backstop rules live on DiscoveryWorkWriter, the stranded-in-PROCESSING failure on DiscoveryProcessTickWorker's class doc, the log-and-acknowledge consequence on DiscoveryWorkListener, and the tunneled-404 reasoning on DiscoveryConnectorErrors with its test shortened to a pointer. b0be9c5

}
Discovery run = located.get();
if (DiscoveryRunLifecycle.isTerminal(run.getStatus())) {
// Terminal only, where every other guard in the engine uses hasLeftTheConnector. PROCESSING is

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

**~ Minor** | Documentation — inline comment volume

881 of this PR's 4478 added lines are comments — around 20%, and the inline blocks are paragraphs rather than notes. This one runs five lines; DiscoveryDrainTickWorker.java:122-125 and :134-137 run four each, as do DiscoveryProcessTickWorker.java:173-177 and :257-260, and DiscoveryStatusTickWorker.java:91-93, :145-147, :151-153, :186-187, :191-193.

Inline comments should be sparing — a line or two where the code genuinely cannot say it. At this density the comments become the primary text and the code the annotation, and every one of them is a separate thing that can drift out of date.

Suggested: Cut each inline block to the single non-obvious sentence, and move the argued-for-the-reader parts (why the alternative was rejected, what the v1 flow used to do, what would go wrong in a design that isn't in the tree) to the PR description. For this block, the load-bearing sentence is the last one — that the run row lock is what makes staging into a PROCESSING run safe.

flagged by pr-hygiene · obvious-comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair, and the number was the useful part -- it is not something I would have noticed from any single block. Two passes took the diff down by roughly 290 lines of prose: b0be9c5 for the duplication and the negation framing, 06abf7e for the entities, repositories, migration, and the ingestor, process worker and terminator.

For this block specifically you identified the load-bearing sentence correctly, and that is what survives: the run row lock is what makes staging into a PROCESSING run safe.

@Autowired
private DiscoveryWorkWriter workWriter;

// ------------------------------------------------------------------ continuation

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

. Nitpick | Documentation — ASCII section banners in tests

20 banner comments of the form // ------------------------------------------------------------------ continuation divide the added ITests. Four of them read fixtures, three ticks with nothing to do, two unanswered ticks.

They restate grouping that the test method names already carry, and they are the kind of decoration that silently stops matching once a test is added under the wrong banner. JUnit has @Nested for this, which the compiler keeps honest.

Suggested: Replace the banners with @Nested classes, or drop them — the method names are the labels.

flagged by pr-hygiene · obvious-comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and removed from all five test files. b0be9c5

I did not convert them to @Nested: that changes test structure rather than comments, and this was a documentation pass. Worth doing separately if you want the compiler enforcing the grouping -- the method names carry it for now.

workRepository.schedule(UUID.randomUUID(), discoveryUuid, workType.name(), nextDueAt);
}

/**

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

. Nitpick | Documentation — writer Javadoc restates the repository's

DiscoveryWorkWriter.expedite documents what DiscoveryWorkRepository.expedite already documents at length (DiscoveryWorkRepository.java:46-56, 11 lines), and the same pair repeats for deleteForRunAndType: the writer's 9-line block at DiscoveryWorkWriter.java:75-83 and the repository's at DiscoveryWorkRepository.java:89-92 explain the same distinction. The writer is a one-line delegation in both cases.

When a fact lives in two doc comments, one of them rots. Also note DiscoveryRunTerminator.java:56, :63 and :77 carry the identical bare tag @return whether this call was the one that ended the run three times.

Suggested: Let the repository own the rules and give the writer a {@link DiscoveryWorkRepository#expedite} reference, or drop the writer's doc entirely — the delegation is self-evident.

flagged by pr-hygiene · doc-duplication

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. The repository owns the agenda rules now and the writer's javadoc is one line per method; the schedule/expedite distinction and the deleteForRunAndType reasoning are stated once. The three identical @return tags on DiscoveryRunTerminator went with the rest of that class's javadoc in 06abf7e.

@ivosh

ivosh commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

In general, this PR's comments argue the design — what the v1 flow did wrong, which alternative was rejected, what would break in a version that isn't in the tree. That's PR-description material, not a part of the source code.

A PROCESS tick arrives on a JMS thread with no principal, and the import
pipeline enforces CERTIFICATE:CREATE against whatever is on the thread.
Nothing installed one, so authorization refused every batch -- and
because importBatch swallows to reach its bounded stall path, the
refusal surfaced as a run that quietly imported nothing and ended
WARNING. The ITests missed it because they mock the pipeline, so the
enforcement was never exercised.

The v1 flow solves this by carrying the user on its CERTIFICATE_DISCOVERED
event, which its listener authenticates from. A tick has no such
envelope, so the run holds the user instead: captured at creation while
a caller is still on the thread, and installed before each batch.

Three more from the same review:

The swap to PROCESSING returned a nullable handle, using null for both
"no handover" and "a handover whose connector handle was absent" -- and
meta is optional in the contract, so a legitimately handle-less run was
read as no swap and skipped both the PROCESS publication and the full
acknowledgement. It now returns a Handover.

discovery_certificate's unique constraint lived only in the migration.
Tests build their schema from the entities, so the dedupe invariant
added for uniqueRef was one the tests could not exercise; it is now
mapped as well as migrated.

Key novelty asked inventory one fingerprint at a time, inside the
transaction holding the run's row lock -- 500 round trips for a full
page, with every other write about the run queued behind them. One
query per page now.
Roughly a fifth of this PR's added lines were comments, and the prose
had started to argue rather than explain: rationale repeated at up to
eight sites, paragraphs describing what the code deliberately is not
doing, and forward references to issues that will change it later.

Four rationales now have one authoritative home each -- the backstop
race on DiscoveryWorkWriter, the stranded-in-PROCESSING failure on
DiscoveryProcessTickWorker, the listener's log-and-acknowledge on
DiscoveryWorkListener, the tunneled 404 on DiscoveryConnectorErrors --
and their other sites point at it instead of restating it. Negation
framing is restated positively, five core#NNNN breadcrumbs are gone with
their behavioural statements kept, the longest javadocs are cut to a
summary plus tags, and the ASCII section banners are gone from the
ITests, where the method names were already the labels.

Two guard comments were left as warnings rather than facts, because
each describes an edit that was an actual bug on this branch: the
backstop row must be parked ahead of due-now, and reschedule must be
REQUIRED so it joins the claimer's transaction.

Clears both open Sonar issues, which had the same cause -- a member
inserted between a doc or annotation and the declaration it belonged
to. progress and runMeta each state their own S1948 justification now,
with the condition that ends it, and the Handover record no longer sits
between handleUnanswered's javadoc and the method.
A second pass over the surfaces the first one spared. The migration was
the worst of them: forty-two lines of essay above five DDL statements,
arguing the design and comparing it to v1. It is now nineteen -- each
statement saying what it does plus the one thing the DDL cannot show,
why a column is nullable or an index partial.

Entity field comments say what the column holds and stop. Repository
query javadoc is one sentence, with a second only where a predicate
genuinely traps a reader: the claim query's "accounted for, not
processed" rule survives, the paging-by-content reasoning and the
schedule/expedite concurrency essay do not -- DiscoveryWorkWriter owns
the agenda rules now.

The ingestor, the process worker and the terminator lose their
class-level narration and their method javadoc drops to a sentence
each. Four explanations were kept because nothing else carries them:
the cursor advances by what a page held rather than the run-wide count,
the run row lock is what makes staging into a PROCESSING run safe,
importBatch swallows so a failure reaches the bounded stall path, and
applyTerminalState exists for callers already holding the lock.

79 insertions against 285 deletions.
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement Improvement to existing functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Discovery ingestor and tick workers

3 participants