Description
What
sf2loki backfill has no way to preview the size of a window before it starts pushing.
The subparser at src/sf2loki/cli.py:99-142 defines --since, --until, --event-types, --interval, --ingest-timestamps, --concurrency, --org — no preview/estimate flag. The dispatch block at src/sf2loki/cli.py:202-240 calls run_backfill unconditionally.
run_backfill (src/sf2loki/backfill.py:707) goes straight to execution:
- builds a live
LokiSink(src/sf2loki/backfill.py:740) and aFileCheckpointStoreon the-backfillsibling state file (src/sf2loki/backfill.py:743-744); - resolves event types (
src/sf2loki/backfill.py:759→_resolve_event_types,src/sf2loki/backfill.py:225); - per type, pages listings and immediately downloads + pushes (
_process_event_type,src/sf2loki/backfill.py:548→_process_files,:483→_download_file,:387, each call exactly one metered ELF blob GET →_process_file,:400, which pushes and then commits the checkpoint at:455).
The only volume report is _print_summary (src/sf2loki/backfill.py:697), printed after the run.
The volume signal needed for a preview is already fetched for free. The listing SOQL selects LogFileLength (src/sf2loki/salesforce/eventlogfile_client.py:169) and parses it into EventLogFileMeta.length (src/sf2loki/salesforce/eventlogfile_client.py:190, field declared at :90). Nothing in the backfill path reads .length today. So per-type file counts and total source bytes for a window are computable from listing calls alone — no blob downloads, no pushes.
Two related facts verified while scoping this, both of which the preview output and docs should state:
- Backfill is not bounded by the egress budget.
EgressGovernoris constructed only insrc/sf2loki/app.py:1031-1032for the daemon pipeline;src/sf2loki/backfill.pynever imports it and pushes throughLokiSinkdirectly (src/sf2loki/backfill.py:740,_push_with_retryat:345). Asink.loki.egressdaily byte budget does not cap a backfill run.docs/sources/cost-controls.mddoes not mention backfill at all. - Backfill does not apply per-type
sample. Sampling is applied only in the live sources (src/sf2loki/sources/eventlogfile_source.py:431,646,694,eventlog_objects_source.py:497,apexlog_source.py:256,pubsub_source.py:704-705).backfill.pyapplies transforms/row filters (src/sf2loki/backfill.py:770-775,_shape_file_rowsat:291) but neverEventLogFileTypeConfig.sample. A preview therefore must not print a “post-sampling rows” estimate — there is no sampling in this path to model.
Why it matters
An operator runs sf2loki backfill --since 2026-01-01 for a high-volume type such as ApiTotalUsage against a Grafana Cloud stack shared with production streaming. There is no supported way to answer “how much will this push?” first. The costs are discovered only mid-run:
- billed Loki ingest for the pushed bytes;
- per-tenant ingest-rate pressure on the same stack the live daemon writes to, since no egress budget bounds the run (fact 1 above);
- one metered Salesforce ELF blob GET per file (
src/sf2loki/backfill.py:391), against the org’s daily API allowance; - in the default label mode, a
backfill="true"stream per type (src/sf2loki/backfill.py:305), doubling stream count for the window.
Aborting mid-run is safe (the checkpoint is resumable) but the bytes already pushed are already billed and already resident in Loki. The information needed to size the window correctly costs only listing SOQL calls and is already on the wire.
Proposed approach
Add --dry-run to the backfill subparser in src/sf2loki/cli.py:99-142, plumbed through to run_backfill as a keyword argument.
Behaviour of run_backfill(..., dry_run=True):
- Build
TokenProviderandEventLogFileClientas normal (listing needs auth). Do not constructLokiSink(src/sf2loki/backfill.py:740) — a preview must never open a sink or push. - Open the backfill
FileCheckpointStoreread-only in effect: load existing cursors so the preview reports remaining work on a resumed window, but never callstore.commit. Simplest implementation: factor the listing loop out of_process_event_type(src/sf2loki/backfill.py:548) so both modes share it, and skip the_process_filescall in dry-run mode. The existing boundary guards must be preserved verbatim in the shared loop: theuntilearly break (:620-625), thedone_idsboundary filter (:637-644), and the “more files thanpage_sizeshare one CreatedDate” bail-out (:652-668). - Accumulate per event type:
files,sum(EventLogFileMeta.length), andapi_calls_if_run(one blob GET per file, matchingsrc/sf2loki/backfill.py:391), plus the earliest/latestCreatedDateactually seen. - Print a per-type table plus a total, then return 0 without downloading or pushing. Label the byte figure as source CSV bytes (
LogFileLength) and state plainly that pushed volume differs: rows are re-encoded as JSON lines with labels and structured metadata (_shape_file_rows,src/sf2loki/backfill.py:291;route_fields/promote_labels), and row filters/transforms can remove rows. Do not print a fabricated post-sampling estimate (see fact 2). - Add
--jsonto emit the same numbers machine-readably, mirroringdoctor --json(src/sf2loki/cli.py:83-89) so CI or a wrapper script can gate on a threshold. - Keep the existing
_warn_retentioncall (src/sf2loki/backfill.py:205, invoked at:736) in dry-run mode — the retention and Loki out-of-order-window warnings are exactly what a preview should surface.
Docs:
docs/reference/cli.md:71-88: add--dry-runand--jsonto the flag table, with a worked example showing the preview output and the recommendation to run it before any window longer than a few days.docs/sources/cost-controls.md: state that a backfill run is not counted againstsink.loki.egressbudgets (the governor is daemon-only,src/sf2loki/app.py:1031-1032), and thatbackfill --dry-runis the supported way to size a window; cross-link fromdocs/reference/cli.md.
Imported from GitHub issue #121 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 == 121)' archive/issues-dump.json).
Filed from the 2026-07-30 full-repo audit (11 finder lanes + adversarial verification per finding).
Acceptance Criteria
- #1
sf2loki backfill --dry-run --since ... [--until ...] [--event-types ...] [--interval ...] [--org ...]prints per-event-typefiles, source bytes (sum(LogFileLength)), and the blob-GET count a real run would spend, plus a total line, then exits 0. - #2
--dry-runperforms zero blob downloads and zero Loki pushes, and constructs noLokiSink. - #3
--dry-runnever writes the backfill state file: nostore.commitcall, and the state file’s contents (or absence) are byte-identical before and after. - #4
--dry-runrespects an existing checkpoint: a window already partly backfilled reports only the files that a resumed run would still process (done_idsboundary filtering preserved). - #5
--dry-run --jsonemits the same figures as parseable JSON. - #6 Preview output states that source CSV bytes are a proxy for pushed bytes, and that a backfill run is not bounded by
sink.loki.egress. - #7 Retention / Loki out-of-order warnings (
_warn_retention,src/sf2loki/backfill.py:205) still fire in dry-run mode. - #8
tests/test_backfill.py: a fakeEventLogFileClientwhoselist_filesreturns multiple pages with knownlengthvalues — assert the printed/JSON totals equal the expected file count and byte sum, and assertdownloadwas never called. - #9
tests/test_backfill.py: dry-run against a pre-populated backfill state file — assert the reported file count excludes the already-done ids at the watermark boundary, and assert the state file is unchanged after the call. - #10
tests/test_backfill.py: dry-run with anuntilcutoff — assert files at/after the cutoff are excluded from the totals, and that thepage_size-boundary bail-out path still terminates (no infinite listing loop) in dry-run mode. - #11
tests/test_cli.py:--dry-runand--jsonparse and reachrun_backfillwith the expected keyword arguments; the flags are absent-by-default so existing invocations are unchanged. - #12
docs/reference/cli.mdflag table anddocs/sources/cost-controls.mdupdated as described;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