Skip to content

Reconcile document uploads on a schedule - #35

Merged
jamiewmeldrum merged 19 commits into
mainfrom
feature/document_upload_cleanup
Aug 28, 2026
Merged

Reconcile document uploads on a schedule#35
jamiewmeldrum merged 19 commits into
mainfrom
feature/document_upload_cleanup

Conversation

@jamiewmeldrum

@jamiewmeldrum jamiewmeldrum commented Aug 27, 2026

Copy link
Copy Markdown
Owner

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. DocumentUploadReconciler finds AWAITING_UPLOAD documents older than the presign expiry plus a five-minute grace period, checks S3 for each key concurrently, promotes the ones that arrived to UNAPPROVED, 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. S3ClientConfigurer bounds 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 by created_at with an id tiebreak 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. remaining is 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 @Scheduled instead — no deployed environment or Terraform exists yet to trigger it. Known cost: @Scheduled fires on every instance, so two instances would run two concurrent reconciles and collide on deleteAll against @Version-ed rows. Latent while there is one instance; it needs revisiting before autoscaling. CLAUDE.md still documents the endpoint form and wants updating.

Testing

  • Four ITs drive the real entry point and prove the writes reach the database — the promotion is invisible to any unit test, since mocking the repository leaves the dirty-check flush unobservable.
  • One CT proves the S3 timeout behaviourally, against a socket that accepts and never responds, rather than asserting the configured value back.
  • One CT proves the schedule fires, and that the test environment's switch actually suppresses it.
  • A backlog IT proves convergence: six documents against a batch of five, the five oldest taken on the first run and the remainder cleared by the second. Rows are inserted newest-first so an unordered query cannot pass by matching Postgres's physical scan order — an earlier version of this test did exactly that.
  • Each of these was checked by mutation: the production change it targets was reverted and the expected test confirmed to fail.

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.
@jamiewmeldrum
jamiewmeldrum merged commit 2d6b7d6 into main Aug 28, 2026
3 checks passed
@jamiewmeldrum
jamiewmeldrum deleted the feature/document_upload_cleanup branch August 28, 2026 19:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant