Description
What
EventLogFileSource._process_event_type decodes its stored checkpoint with no error handling and no validation:
# src/sf2loki/sources/eventlogfile_source.py:511-518
raw = await state.load(key)
if raw is None:
since = default_since
ids: list[_CarriedId] = []
else:
parsed: dict[str, object] = json.loads(raw) # :516 - no try/except, no isinstance
since = str(parsed.get("last_created") or default_since) # :517 - no watermark validation
ids = _parse_carried_ids(parsed.get("ids", []))
Three distinct failure shapes follow from a stored value the source did not write itself:
- Non-JSON value (e.g. a bare
2026-07-01T00:00:00Z) -json.loadsraisesJSONDecodeError. - Non-dict JSON (a quoted scalar
"2026-07-01T00:00:00Z", or a list) -json.loadsreturnsstr/list, andparsed.getraisesAttributeError. - Well-formed dict with a garbage
last_created- no exception; the garbage flows intosinceunvalidated.
Shapes 1 and 2 are fatal to the whole process. eventlogfile_source.py:388-395 deliberately re-raises any non-contained worker exception (raise result), which escapes _process_cycle and events() (eventlogfile_source.py:300). Pipeline._produce (src/sf2loki/app.py:301-321) wraps the async for in a try/finally with no except. OrgSource.events (src/sf2loki/sources/org_adapter.py:106-120) contains only AuthError. _on_pipeline_done (src/sf2loki/app.py:1168-1177) records the exception and src/sf2loki/app.py:1224-1225 re-raises it, so the process exits nonzero. The stored value is unchanged on disk/in the object store, so the supervisor restart hits the identical exception - a permanent crash loop that takes down every source and every org, not just the affected EventType.
Shape 3 is a permanent silent stall. to_soql_datetime_literal (src/sf2loki/salesforce/soql_client.py:44-53) returns unparseable input unchanged by design, and EventLogFileClient.list_files interpolates it unquoted (src/sf2loki/salesforce/eventlogfile_client.py:172-178):
AND CreatedDate >= {since_literal}
so the query is MALFORMED_QUERY. That is wrapped into the EventLogFileError family and contained as a per-cycle listing skip (eventlogfile_source.py:534-543), which leaves the poison checkpoint in place and retries the same malformed query every poll interval forever. There is no fallback to now - lookback.
Both sibling sources already guard all three shapes, so this is an asymmetry rather than a design decision:
src/sf2loki/sources/eventlog_objects_source.py:119-136-_parse_checkpointcatchesValueErrorand returns the raw string as the watermark (legacy bare-timestamp shape), and returnsraw, []for a non-dict JSON scalar.src/sf2loki/sources/eventlog_objects_source.py:297-312-_is_valid_watermarkcheck, WARNING log, fallback tonow - lookback.src/sf2loki/sources/apexlog_source.py:80-90and:165-172- identical pair.src/sf2loki/app.py:565-585shows the codebase already treats this value as untrusted on the observability path:_parse_eventlogfile_watermarkcatchesValueError/AttributeError/TypeErroraround the samejson.loads(value).get("last_created"). The source itself does not.
src/sf2loki/backfill.py:583 repeats the same unguarded parsed: dict[str, object] = json.loads(raw) (lower blast radius: one-shot CLI, separate state file).
Why it matters
The documented recovery runbook produces failure shape 1. docs/deployment/state.md:44 lists eventlogfile:<EventType> (e.g. eventlogfile:ApiTotalUsage) in the SOQL-polling checkpoint-key table, and docs/deployment/state.md:70-72 then says state set KEY VALUE “moves the checkpoint to an exact, known-good position (an ISO-8601 timestamp for SOQL-polled sources, a base64 replay_id for Pub/Sub)”. run_state_set (src/sf2loki/statecmd.py:173-189) writes the string verbatim with no shape validation, and docs/reference/cli.md:129-135 only shows a Pub/Sub example, so nothing tells the operator that an EventLogFile key needs the JSON {"last_created": ..., "ids": [...]} envelope.
So an operator following the shipped runbook to unstick one EventType instead takes the entire daemon into a restart loop, with a JSONDecodeError traceback that names json.loads, not the checkpoint they just wrote. Recovery requires knowing to run state delete on that key. The same crash reaches production via any other route that puts a non-envelope value in the store (hand-edited file store, a partially written object, a value copied from the eventlog_objects/apexlog legacy bare-timestamp shape).
Reproduced against current main using the existing fakes in tests/sources/test_eventlogfile_source.py plus a real FileCheckpointStore:
stored value for eventlogfile:Login |
observed |
|---|---|
2026-07-01T00:00:00Z |
json.decoder.JSONDecodeError: Extra data: line 1 column 5 raised out of events() |
{"last_created": "junk"} |
no raise; client received list_files("Login", "Hourly", since="junk", 1000) |
Proposed approach
Mirror the sibling sources exactly, so all four polling sources share one contract.
- Add module-level helpers to
src/sf2loki/sources/eventlogfile_source.pynext to_parse_carried_ids:_watermark_datetime(value: str) -> datetime | Noneand_is_valid_watermark(value: str) -> bool, copied fromeventlog_objects_source.py:103-117(datetime.fromisoformat(value.replace("Z", "+00:00")),ValueError->None, naive -> assume UTC)._parse_checkpoint(raw: str) -> tuple[str, list[_CarriedId]]:json.loadsinsidetry/except ValueError; on failure return(raw, [])(legacy bare-timestamp shape). If the decoded object is adict, return(str(parsed.get("last_created") or ""), _parse_carried_ids(parsed.get("ids", []))). For any other JSON type (scalar, list) return(raw, [])rather than calling.get.
- In
_process_event_typereplaceeventlogfile_source.py:511-518with a call to_parse_checkpoint, then validate: ifnot _is_valid_watermark(since), log at WARNING naming the event type and the rejected value (match the wording ateventlog_objects_source.py:301-309), increment the existing checkpoint-error counter if one is wired for this source (otherwise reuseself._metrics.soql_poll_errors.labels(source="eventlogfile", object=event_type)), and fall back todefault_sincewith an empty carried-id window. Falling back todefault_sincere-lists the lookback window, which is a bounded duplicate window, not data loss (ingestion is at-least-once and Loki dedupes exact duplicates). - Apply the same
_parse_checkpointtreatment tosrc/sf2loki/backfill.py:583. - Fix the runbook so it stops prescribing the crashing value: in
docs/deployment/state.mdstate the exact per-source value shapes -eventlog_objects:<Object>andapexlogaccept a bare ISO-8601 timestamp or the{"last_ts", "ids"}envelope,eventlogfile:<EventType>takes{"last_created": "<ISO-8601>", "ids": []},pubsub:<topic>takes the replay envelope - and add aneventlogfileexample todocs/reference/cli.md’sstate setsection. Recommendstate showfirst to copy the live shape.
Optionally (separate, do not couple): have run_state_set (src/sf2loki/statecmd.py:173-189) warn when a value written to an eventlogfile: key does not decode to a JSON object carrying last_created.
Imported from GitHub issue #103 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 == 103)' archive/issues-dump.json).
Filed from the 2026-07-30 full-repo audit (11 finder lanes + adversarial verification per finding).
Acceptance Criteria
- #1
_parse_checkpointand_is_valid_watermarkexist insrc/sf2loki/sources/eventlogfile_source.pyand are the only path by which a stored ELF checkpoint reachessince/ids. - #2 A bare-timestamp checkpoint (
2026-07-01T00:00:00Z) is accepted aslast_createdwith an empty id window; no exception escapesevents(). - #3 A checkpoint that is not valid JSON at all (
{not json), a JSON scalar ("2026-07-01T00:00:00Z"), and a JSON list ([]) each fall back tonow - lookbackwith a WARNING and no exception. - #4 A JSON object with an unparseable
last_created({"last_created": "junk"}) falls back tonow - lookbackwith a WARNING;list_filesis never called with a non-datetimesince. - #5 The fallback resets the carried-id window to empty (a garbage envelope’s
idsare not trusted against the fallback watermark). - #6
src/sf2loki/backfill.py:583uses the same guarded decode; a poison backfill checkpoint does not raise out of the backfill run. - #7 Test:
tests/sources/test_eventlogfile_source.py::test_poison_checkpoint_falls_back_to_lookback_without_raising- table-driven over the values above, asserting no exception escapesevents(), that thesinceseen by the fake client parses as a datetime within the configured lookback, and that a WARNING was logged (caplog). - #8 Test:
tests/sources/test_eventlogfile_source.py::test_legacy_bare_timestamp_checkpoint_is_used_as_watermark- a bare ISO timestamp is used assinceverbatim (afterto_soql_datetime_literalnormalisation), not discarded in favour of the lookback. - #9 Test:
tests/test_backfill.pycase covering a non-JSON stored backfill checkpoint. - #10 Test: a pipeline-level test asserting that a poisoned
eventlogfile:*checkpoint does not surface as a pipeline crash (guards theeventlogfile_source.py:395->app.py:301-321->app.py:1224path that currently exits the process). - #11
docs/deployment/state.mddocuments the exact accepted value shape per checkpoint-key family, anddocs/reference/cli.md’sstate setsection carries aneventlogfile:example. - #12
just gategreen.
Definition of Done
- #1 just gate is green (ruff check + ruff format –check + mypy src + pytest) — run it, don’t assert it
- #2 just gen-config run and its output committed, if config.py changed (CI drift gate fails otherwise)
- #3 committed straight to main with a conventional-commit message, and pushed