Task · SFL-0059

cli: state export/import for migrating checkpoints between backends (file -> s3/gcs)

Status
To Do
Labels
followup, phase-1, roadmap
Milestone
Feature roadmap — operator ergonomics & platform capabilities
Updated
2026-08-14

Description

cli: state export/import for migrating checkpoints between backends

What

The sf2loki state verb surface is exactly three single-key operations:

verb entrypoint wiring
show src/sf2loki/statecmd.py:145 src/sf2loki/cli.py:151-165
set src/sf2loki/statecmd.py:173 src/sf2loki/cli.py:167-177
delete src/sf2loki/statecmd.py:192 src/sf2loki/cli.py:179-189

Dispatch is src/sf2loki/cli.py:243-249. There is no verb that moves a whole checkpoint document from one configured store to another, and no documented procedure for doing so:

Meanwhile a backend switch is the documented growth path. docs/deployment/high-availability.md:84-88 requires the checkpoint store to be shared between replicas, and for the k8s_lease topology specifically requires state.store: s3 or gcs (no shared volume). docs/deployment/state.md:104-106 repeats the requirement. A single-instance deployment therefore starts on state.store: file and must move its live checkpoints to s3/gcs at the moment it becomes HA.

The plumbing for a bulk write already exists and is unused by the CLI: commit_many is implemented on all three stores (src/sf2loki/state/file_store.py:210, src/sf2loki/state/s3_store.py:265, src/sf2loki/state/gcs_store.py:222) and consumed duck-typed by the flush path at src/sf2loki/app.py:508-513. statecmd._whole_document (src/sf2loki/statecmd.py:138) already enumerates the entire document via the stores’ _cache, and _RESERVED_KEYS (src/sf2loki/statecmd.py:63) already filters the file store’s internal __fence_epoch__ bookkeeping key.

Why it matters

Without a supported migration path an operator promoting a working file deployment to active-passive HA has three options, all bad:

  1. Start the new backend empty. Every SOQL-polled source re-lists its lookback_hours window (a bounded duplicate window), and every Pub/Sub subscription starts a fresh subscribe from “now” — a real ingestion gap, not a duplicate, for the interval between the last commit and the cutover (docs/deployment/state.md:78-83 documents this consequence for state delete; a fresh backend is the same thing for every key at once).
  2. Hand-transcribe key by key. Parse state show output around its summary line, then one sf2loki state set invocation per key against the new config, copying base64 replay_ids and multi-KB JSON carried-id windows by hand. One mangled value stalls or gaps that source. Key counts scale with sources x orgs (multi-org keys are additionally prefixed org=<name>:, docs/deployment/state.md:50-56).
  3. Copy the raw document out of band. Both backends persist the same flat {str: str} JSON object (src/sf2loki/state/file_store.py:170-173 dumps _cache; src/sf2loki/state/s3_store.py:258-280 loads/PUTs the same shape), so aws s3 cp state.json s3://bucket/key does in fact work today. This is undocumented, requires direct bucket credentials the operator may not have outside the service account, has no equivalent one-liner for s3 -> gcs, and copies __fence_epoch__ across — inert on s3/gcs, but on a copy back into a file store a carried-forward high epoch is compared against the live leader’s epoch at src/sf2loki/state/file_store.py:274-296 and can reject commits with StateFenceError (“stale leader … rejected”).

Two verbs turn this into a two-command, reviewable, backend-agnostic operation that reuses the service’s own configured credentials for both ends.

Proposed approach

Add two subcommands over the existing statecmd plumbing. Note that --config is a top-level flag (src/sf2loki/cli.py:176-180), so it must precede the subcommand: sf2loki --config old.yaml state export. (The examples at docs/deployment/state.md:20-22 place it after the subcommand, which argparse rejects with unrecognized arguments: --config; fix those lines while adding the new section.)

state exportrun_state_export(config_path, *, force=False) in src/sf2loki/statecmd.py:

state importrun_state_import(config_path, *, stream=sys.stdin, if_empty=False, force=False):

Docs — a “Migrating state backends” section in docs/deployment/state.md (after “Command surface”), giving the ordered procedure: stop the daemon, sf2loki --config old.yaml state export > state-dump.json, point a copy of the config at the new backend, sf2loki --config new.yaml state import --if-empty < state-dump.json, verify with state show, start the daemon. State explicitly that the dump is plaintext checkpoints (not secret, per src/sf2loki/statecmd.py:20), that __fence_epoch__ is deliberately not carried, that multi-org org=<name>: prefixes travel verbatim so org names must not change in the same step, and that backfill state lives in a separate file (docs/deployment/state.md:58-63) needing its own export if a backfill run is mid-flight. Cross-link from docs/deployment/high-availability.md’s shared-store section (lines 84-88).


Imported from GitHub issue #143 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 == 143)' 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