Task · SFL-0043

eventlog_objects: big-object drain re-emits a >500-row timestamp tie group every poll cycle (capped dedup window has no at-watermark exemption)

Status
To Do
Labels
followup, phase-2
Milestone
Correctness & data-integrity hardening
Updated
2026-08-14

Description

What

The big_object: true DESC drain has no server-side secondary cursor, so the checkpoint id window is its ONLY cross-cycle dedup — and that window is capped unconditionally at 500 ids. A timestamp tie group larger than the cap is therefore partially evicted every cycle and its overflow is re-emitted indefinitely.

Mechanics:

When N records share one timestamp T and N > 500, cycle 1 emits all N and commits a window holding only the last 500 ids. Cycle 2’s >= T re-fetch returns all N again, N-500 survive the seen filter, get re-emitted, and their ids evict an equal number of older boundary ids from the window. The window rotates through the tie group forever; the watermark cannot advance (every row is at T), so the loop persists until a strictly newer record arrives.

The sibling EventLogFile source already solved exactly this for its own CreatedDate >= watermark re-list: src/sf2loki/sources/eventlogfile_source.py:91-96 documents it (“Pairs AT the watermark are always all kept … capping them would re-download uncovered files forever when >cap files share one CreatedDate”), implemented in _append_carried_id at :180-193, with a _CARRIED_IDS_WARN_THRESHOLD = 5000 anomaly warning at :98-100. eventlog_objects has no equivalent exemption.

Unaffected paths, for scope: the ASC path is safe because its Id tiebreak excludes already-emitted boundary rows server-side regardless of window size; apexlog_source.py:59 has the same 500 cap but also the tiebreak (:177), so it is safe too.

Reproduced against the real source (FileCheckpointStore, respx SOQL fake honouring Id NOT IN and the upper bounds, 800 rows all stamped 2026-06-30T10:00:00.000+0000, committing the last entry’s checkpoint per cycle as the pipeline does):

cycle 1: soql_calls=6 emitted=800 window=500 last_ts=2026-06-30T10:00:00.000+0000
cycle 2: soql_calls=6 emitted=300 window=500 last_ts=2026-06-30T10:00:00.000+0000
cycle 3: soql_calls=6 emitted=300 window=500 last_ts=2026-06-30T10:00:00.000+0000
... unchanged through cycle 7

Why it matters

A quiet custom big object (or a *EventStore object with a second-granularity or bulk-loaded timestamp field — the trigger class the source’s own docstring names at :35-37) that receives a >500-row load stamped with one timestamp and then goes idle re-pushes the overflow slice on every poll interval, indefinitely:

No data is lost, and the condition self-clears once a strictly newer record advances the watermark — hence low severity, not a stall (#38 covered the stall; its body explicitly left the window out of scope: “_MAX_CARRIED_IDS governs eviction, not progress”).

Proposed approach

Mirror the ELF rule — never cap ids that sit AT the current watermark, since those are exactly what the >= re-fetch returns. Implement it with an additive checkpoint field rather than changing the element type of ids, so every existing reader and stored checkpoint keeps working:

  1. Extend the checkpoint JSON to {"last_ts": ..., "ids": [...], "boundary_ids": [...]}. boundary_ids holds every id whose timestamp equals last_ts (uncapped); ids keeps its existing meaning — a tail of at most _MAX_CARRIED_IDS ids at strictly OLDER timestamps. _parse_checkpoint (:119-136) returns [] for a missing boundary_ids, so today’s checkpoints and the legacy bare-timestamp form load unchanged (their ids tail already covers the boundary they were written at).
  2. In _emit_record (:464-523), replace the unconditional trim at :493 with a watermark-transition fold:
    • when the record’s valid timestamp differs from the current watermark, the old boundary set has become historical: ids = [*ids, *boundary_ids][-_MAX_CARRIED_IDS:], boundary_ids = [], then advance the watermark;
    • append record_id to boundary_ids (records with a null/unparseable timestamp keep the previous watermark and so belong to the current boundary set — the safe side, since a >= re-fetch can return them). Records arrive ASC on both paths, so the watermark only ever moves forward and the fold happens at most once per distinct timestamp.
  3. Dedup against the union: seen = set(window) | set(boundary) at :352 and at :433 (the ASC path — harmless there, and it keeps the two paths identical).
  4. ASC-path cursor: last_id must remain the id of the newest-emitted record — boundary_ids[-1] if boundary_ids else (ids[-1] if ids else "") (:378). A legacy/current checkpoint’s ids[-1] is by construction its boundary id, so behaviour is unchanged on the first cycle after upgrade.
  5. Mirror ELF’s anomaly warning: log once at WARNING when boundary_ids crosses a threshold (reuse 5000, per eventlogfile_source.py:98-100) — thousands of rows at one timestamp means the checkpoint document is growing abnormally.
  6. Thread boundary_ids through _checkpoint_value (:525-538, extend the cache key tuple), _checkpoint_only_entry (:540-556), and the (watermark, window)-changed comparisons at :363 and :461.
  7. Update the module docstring’s checkpoint description (:9-19, :48-54) and docs/sources/eventlog-objects.md:38.
  8. Guard against the secondary risk this exposes: the intra-cycle tie escape interpolates the whole tie group into Id NOT IN (...) (:634), so an 800-id list with real 18-character Salesforce ids approaches the SOQL/GET URI length limit. Either bound the escape’s id list and rely on the (now complete) boundary window across cycles, or assert the query length and fail loudly instead of emitting a malformed query.

Imported from GitHub issue #127 on 2026-08-14, when this repo migrated from GitHub Issues to Backlog.md. The original issue has been deleted; its verbatim body, labels and comments are preserved in archive/issues-dump.json (jq '.[] | select(.number == 127)' archive/issues-dump.json).

Filed from the 2026-07-30 full-repo audit (11 finder lanes + adversarial verification per finding).

Acceptance Criteria

Definition of Done

References

View the source file on GitHub