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_auth has 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.id values 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.
Command icon 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

mably created an issue. See original summary.

mably’s picture

Status: Active » Needs review

  • mably committed 6d037f57 on 1.x
    fix: #3591949 Cron auto-coverage fails when bucket envelope slice...
mably’s picture

Status: Needs review » Fixed

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.