Reconcile document uploads on a schedule - #35
Merged
Conversation
The upload reconcile needs to find documents in a given status that were created before a cut-off, so DocumentRepository gains a derived finder and DocumentRepositoryIT pins what it returns: both predicates, both directions, and the cut-off row itself proving the comparison is strict.
The reconcile asks whether a batch of registered keys made it into the bucket, and each answer is an independent HEAD request. Running them on a virtual thread per key decouples the number in flight from the core count, which matters on the small instance this will eventually run on: a parallel stream would size itself to the processors it can see. The checks fail the whole call rather than reporting a missing object, because this feeds a delete: 'S3 says no' and 'S3 did not answer' must not become the same answer.
A registered document sits in AWAITING_UPLOAD until its file appears in the bucket, and nothing moved it on. The reconciler takes the registrations that are past the presign's life plus a short grace, asks storage which keys actually arrived, promotes those to UNAPPROVED and deletes the rest — the abandoned rows are referenced by nothing, so they go rather than linger as soft-deleted noise. The cut-off comes from an injected Clock rather than now(), so the boundary is an input the unit tests state outright instead of one they have to build rows relative to.
Inside the reconcile's transaction the documents are managed, so setting the status is the write and an update call adds a line that issues no statement. The unit tests assert the status on the instances the repository handed back, which is the same state the flush will carry.
The transaction boundary stays around the whole method. The job is single-threaded, so the cost is one pooled connection held for the S3 round trips, and promoting and expiring in one transaction is worth more than releasing it early.
The SDK sets no call timeout by default, so an unresponsive S3 burns the 30s socket timeout on each of three attempts while the reconcile holds a pooled connection for all of it. Bound the attempt rather than the whole call: a document download's duration scales with the file, a single attempt's does not. The listener merges into the builder's existing override configuration so Micronaut's user-agent suffix survives. The timeout is configurable so the test can drive it low. The test asserts the call is abandoned against a socket that never responds, rather than asserting the configured value back - which would only restate the configuration.
Interval, initial delay and an on/off condition are all configurable. The initial delay matters: Micronaut passes an absent one to the scheduler as null, which becomes zero, firing a reconcile while the context is still starting. The condition gates the scheduler's own firing rather than the method, so both test environments switch the schedule off while leaving the bean injectable for a test to drive directly.
The completion line is emitted on the empty run too - a run that finds nothing still needs to record that it ran, since a quiet reconcile is the normal case and is otherwise indistinguishable from one that never happened.
Driven through the scheduler, the entry point the application actually uses. These cover what no unit test can see: the promotion is written by the transaction's dirty check with no explicit save call, so mocking the repository leaves it unobservable.
runScheduledUploadReconcile said 'scheduled' twice - the class already carries it - and said it at the one call site where it is not true. The IT moves in beside the other end-to-end tests; it drives the application through its real entry point, which is what that package holds.
Micronaut reads the scheduling condition as booleanValue(...).orElse(true), so a typo in that property name does not disable the job - it enables it, in the tier whose fixtures a background reconcile would race. The second test deliberately overrides only the timings and leaves the switch to the environment's own configuration, so it fails if that value is ever mis-set rather than the integration tier quietly acquiring a competing job.
A run takes at most a configured batch, ordered oldest first with an id tiebreak so the order is total and nothing at the back can starve. The remainder waits for the next run, which is safe because every row a batch takes is either promoted out of AWAITING_UPLOAD or deleted - no row can survive a run it was included in. The reconciler returns a summary of what it did and what it could not reach, and the scheduler logs it, so the result is consumed rather than discarded. Remaining is derived from the page total rather than counted again, and is what is left after the run rather than what was waiting before it, which is the number that says whether the batch is undersized.
An empty page no longer assumes nothing is waiting - it reports the page total, so a batch size of zero cannot present a real backlog as an empty queue to whatever ends up alerting on that number. The sort is named, remaining is computed where it is used, and each end-to-end case now acts on two documents rather than one, so a run that handled only the first row it found would fail rather than pass.
The three numbers governing this job all have names now; this one was a bare plusMinutes(5) inline. It is a product decision - how long an upload that started just before the URL expired is allowed to still be arriving - so it sits with the other upload rules. Drops that file's header comment, which restated the class name and asserted a count of constants that adding this one made false.
Sized against the 25Mb ceiling on a poor connection, and bounded by the presigned URL being a bearer credential.
The grace was five minutes on top of a ten minute URL expiry, but that expiry is sized for a 25Mb upload on a 0.5Mbps line - about seven minutes of transfer. S3 checks the signature when it accepts the request, not when the body finishes, so a PUT starting at 9:59 could still be streaming at 17:00 while the reconcile deleted its row at 15:00. The upload then completed into a bucket with no document row pointing at it. Ten minutes covers the transfer the expiry already admits. The schedule moves to twenty to stay roughly in step with the window it is sweeping. The end-to-end fixtures move to thirty minutes: at twenty they sat exactly on the new boundary, where created_at < cutOff held only by the milliseconds between the insert and the run.
The batch size reads as a free drain rate, but the S3 checks fan out a thread per key against a connection pool that holds fifty, so a run is ceil(size / 50) serial waves with the transaction open across all of them. The divisor is an SDK default sitting in another layer, so nothing at the knob hinted it existed.
Records what actually shipped rather than what was planned: the reconcile is a domain service with no HTTP route, driven by an in-app @scheduled stopgap, and the multi-instance limitation is written down as a scaling constraint that comes due with 0.4 rather than something to solve now (D-049, D-051). Base config is universal-only with no prod profile, so nothing runs as prod by accident (D-050). Adds the standing note that the AWS SDK ships no default call timeout, which is why the S3 calls inside the job are explicitly bounded. Also restores the runner line in the Done list, which had reverted to naming QuestionQueryManager and LinkedQuestion. Both are gone, and both are contradicted by the projections note in section 5.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Completes the document upload lifecycle: a document registered for upload is either promoted once its file lands in S3, or removed when the file never arrives.
What's here
The reconcile itself.
DocumentUploadReconcilerfindsAWAITING_UPLOADdocuments older than the presign expiry plus a five-minute grace period, checks S3 for each key concurrently, promotes the ones that arrived toUNAPPROVED, and hard-deletes the ones that did not. Promotion is written by the transaction's dirty check rather than an explicit save.The transaction spans the S3 calls, deliberately. That holds a pooled connection across the round trips. The job is single-threaded, so the cost is one connection, and it buys atomicity: a run either commits both the promotions and the expiries or neither. That in turn makes a run's success binary, which the 0.4 monitoring work depends on.
An S3 attempt timeout. The SDK sets none by default, so an unresponsive S3 would burn the 30s socket timeout on each of three attempts while the reconcile held that connection.
S3ClientConfigurerbounds a single attempt at 10s and deliberately leaves the whole-call budget open, since a document download's duration scales with the file and an attempt's does not. It merges into the builder's existing override configuration so Micronaut's user-agent suffix survives.Work is taken in batches, oldest first. A run takes at most
practiq.document-upload-reconcile.batch-size(100) documents, ordered bycreated_atwith anidtiebreak so the order is total and nothing at the back of the queue can starve. The remainder waits for the next run, which is safe because the two sets the run produces — promoted and expired — partition the batch: no row can survive a run it was included in, so a successful run always reduces the backlog by exactly what it examined.The reconciler returns a summary of what it examined, promoted and expired, plus what it could not reach.
remainingis derived from the page total rather than counted again, and describes the state after the run rather than before it — that is the number that says whether the batch size is undersized. The scheduler logs it, so the result is consumed rather than discarded.A scheduler. Interval, initial delay and an on/off switch are all configurable. The initial delay matters: Micronaut passes an absent one to the scheduler as null, which becomes zero, firing a reconcile while the context is still starting.
Divergence from the plan
Slice 3 was sequenced as a run-resource on an internal HTTP surface, triggered by EventBridge. This ships an in-process
@Scheduledinstead — no deployed environment or Terraform exists yet to trigger it. Known cost:@Scheduledfires on every instance, so two instances would run two concurrent reconciles and collide ondeleteAllagainst@Version-ed rows. Latent while there is one instance; it needs revisiting before autoscaling.CLAUDE.mdstill documents the endpoint form and wants updating.Testing