Problem
The cron coverage pass (CronArchiveHook::coveragePass()) groups orphan rows past the configured cutoff into closed time buckets and passes each bucket's [from_id, to_id] envelope to ChainArchiver::ensureSegmentCoverage(). When the envelope straddles an existing segment, uncoveredRanges() slices it around the obstacle. Each gap is then materialised via createBareSegment(), which calls readRowHash(chain, to_id) to compute the segment's anchor_after.
The slice boundary min(obstacle.from_id - 1, envelope.to_id) is computed in absolute audit_trail.id space. When that boundary id corresponds to a row on a different chain (audit_trail's PRIMARY KEY is shared across chains, so neighbouring ids commonly belong to different chains), readRowHash() finds no row on the target chain at that id and throws:
Cannot resolve anchor_after: chain "[chain]" has no row id=[N].coveragePass catches the throw, logs a watchdog warning, and continues. The bucket is never minted. Subsequent cron ticks repeat the same failure forever; the orphan rows accumulate on the chain indefinitely.
Reproduction (observed in a real install)
- Chain
audit_trail_user_authhas segments at[1803, 1803](segment 148) and[1848, 1848](segment 153), typical single-row attestation segments produced by coverage minting on neighbouring time buckets. - Lifecycle attestations (segment_archived, segment_live_purged, segment_file_purged, segment_restored) accumulated on the user_auth chain at
audit_trail.idvalues 1830, 1831, 1832, 1833 (between segments 148 and 153) and 1863, 1864, 1865, 1866 (between segments 153 and 172). - Hourly granularity,
transient_purge_after = PT1H.
coveragePass groups orphan rows 1830-1833 + 1863-1866 in the same hour bucket envelope [1830, 1866]. uncoveredRanges([1830, 1866], [{1848, 1848}]) returns [[1830, 1847], [1849, 1866]]. createBareSegment(user_auth, 1830, 1847) fails because user_auth has no row at id=1847 (that id belongs to a different chain).
The watchdog log fills with recurring entries every cron tick:
Auto coverage failed on chain "audit_trail_user_auth" bucket 2026-05-24T17 (rows 1830-1866): Cannot resolve anchor_after: chain "audit_trail_user_auth" has no row id=1847.Root cause
uncoveredRanges() at src/Archive/ChainArchiver.php:669 works in absolute id space and assumes any id in the resulting gap range corresponds to a row on the target chain. createBareSegment() at src/Archive/ChainArchiver.php:1070 then resolves anchor_after via readRowHash(chain, to_id) which requires an exact-match row on the chain.
The gap slice's to_id (and similarly its from_id) can legitimately fall on an id that exists on another chain but not on the target chain.
Suggested fix
Snap each gap's from_id and to_id to the nearest actual chain row id within the gap range, inside ensureSegmentCoverage(), before calling createBareSegment(). One SELECT per gap retrieves the bounds:
SELECT MIN(id), MAX(id) FROM audit_trail WHERE chain = :chain AND id BETWEEN :gap_from AND :gap_to
If the gap has no chain rows, skip it (no bare segment needed -- the gap was a phantom produced by absolute-id slicing). Otherwise mint createBareSegment(chain, min_id_in_gap, max_id_in_gap). Both anchor_before (readRowPreviousHash on min_id) and anchor_after (readRowHash on max_id) resolve cleanly because both ids are guaranteed to exist on the chain.
Alternative: push the snap into uncoveredRanges() by making it chain-aware (extra parameters for chain id and DB connection). The wrapper at the call site is the natural place since that's where the chain id is already in scope.
Impact
- Pre-existing bug, not introduced by recent MRs.
- Affects any chain that emits lifecycle attestations into id slots that fall in coverage gaps between existing segments where the gap's slice boundary on the absolute id space doesn't correspond to a row on the target chain.
- Symptom: recurring watchdog warning every cron tick; orphan rows accumulate on the chain forever; retention cron never reaches them. Breaks the "every row past retention is in a segment" invariant the architecture relies on.
- Severity: orphan rows are small chain attestations (~200 bytes each), no immediate data leak risk, but the invariant break compounds over time and prevents the chain from being fully retention-managed.
Tests to add
- Coverage pass on a chain where the bucket envelope straddles an existing segment AND the slice boundary doesn't correspond to a target-chain row. Assert the bare segment is minted with the snapped bounds, not the absolute-id slice bounds.
- Coverage pass on a phantom gap (envelope slice has no target-chain rows). Assert no bare segment is minted and no error is thrown.
Issue fork audit_trail-3591949
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #3
mably commentedComment #5
mably commented