Task · SFL-0046

state: the s3 checkpoint body read and JSON parse sit outside the transient-retry envelope (#44 follow-up)

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

Description

What

S3CheckpointStore._ensure_loaded retries only the GetObject call, not the body transfer or the parse.

src/sf2loki/state/s3_store.py:230-247:

async def _do_get() -> dict[str, Any] | None:
    try:
        result: dict[str, Any] = await client.get_object(
            Bucket=self._cfg.bucket, Key=self._cfg.key
        )
        return result
    except Exception as exc:
        if _error_code(exc) in _NOT_FOUND_CODES:
            return None
        raise

resp = await _retry_transient(_do_get)      # s3_store.py:241 - retried
if resp is None:
    ...
body = await resp["Body"].read()            # s3_store.py:246 - NOT retried
data = json.loads(body)                     # s3_store.py:247 - NOT translated

aiobotocore’s get_object resolves once the response headers arrive; resp["Body"] is an unread StreamingBody. The byte transfer at s3_store.py:246 is therefore a separate network operation sitting outside the bounded retry that issue #44 added, and the parse at s3_store.py:247 has no corrupt-object translation.

Two distinct defects:

  1. Mid-transfer failure is not retried (S3 only). A connection reset or a botocore read timeout between response headers and the end of the small body read propagates raw out of _ensure_loaded. GcsCheckpointStore has no equivalent hole — gcs_store.py:200-204 wraps the whole client.download(...) (which returns bytes) inside _retry_transient.
  2. A truncated or non-UTF-8 body raises a bare json.JSONDecodeError/UnicodeDecodeError (S3 and GCS). The branch three lines below at s3_store.py:248-252 raises StateObjectCorruptError naming s3://{bucket}/{key} for a non-dict document, and file_store.py:143-150 catches json.JSONDecodeError/UnicodeDecodeError and raises StateFileCorruptError naming the path plus the recovery step. The S3/GCS parse paths (s3_store.py:247, gcs_store.py:205) do neither, so the operator gets Expecting value: line 1 column 1 (char 0) with no bucket, no key, and no pointer to the documented recovery (docs/deployment/state.md:80state delete when “the checkpoint’s own file/object is what’s corrupt”).

Neither exception is contained anywhere upstream:

Secondary problem the fix must account for: a botocore body-read failure (ReadTimeoutError, ResponseStreamingError) is rooted in BotoCoreError, not the builtin OSError/ConnectionError family, and carries no .response dict. _is_transient (s3_store.py:112-131) classifies on a botocore error code, an HTTP status >= 500, or isinstance(exc, TimeoutError | ConnectionError | OSError) — none of which match. Moving the read inside the retried closure is therefore necessary but not sufficient; the classifier needs to recognise that family too (by duck-typed class name, since this module deliberately never imports botocore or aiohttp — see the module docstring at s3_store.py:9-14).

No test covers either defect. The load-retry tests fail the call, not the transfer (tests/state/test_s3_store.py:434-457 monkeypatches backend.get_object; tests/state/test_gcs_store.py:442-463 monkeypatches download_metadata), and the test double FakeStreamingBody.read (tests/state/test_s3_store.py:40-45) cannot raise. The corrupt-document test (tests/state/test_s3_store.py:277-286) covers only a valid-JSON non-dict array.

Why it matters

state.store = s3 with a shared bucket is the required backend for the active-passive HA topology (docs/deployment/high-availability.md). _ensure_loaded runs on the first load of each process and again on the first commit_many after every reset() (leadership demote then re-promote, s3_store.py:193-202), so the exposed window is every startup and every failover — precisely when the object store is most likely to be answering slowly.

Concrete failure: the daemon is promoted to leader, the first flush calls commit_many_ensure_loaded, the GetObject response headers arrive, and the connection resets (or the botocore read timeout fires) before the body is fully read. Instead of the bounded retry #44 added for that class of blip, the process crashes and restarts — dropping every gRPC Pub/Sub stream and re-authing every org, the exact churn #44 set out to prevent. No checkpoint is lost (nothing was committed), so the cost is restart churn, not data loss.

Second failure: an object truncated by a non-CAS writer, a hand-edit, or a partial upload makes both the daemon and sf2loki state show fail with a bare JSON parse error that names neither the bucket/key nor the recovery step, while the equivalent file-store failure (file_store.py:145-150) spells both out.

Proposed approach

  1. Move the body read inside the retried closure in s3_store.py, so a mid-transfer failure re-issues the GET rather than propagating. Return the ETag alongside the bytes so the caller keeps the value it currently reads from resp.get("ETag") at s3_store.py:254:

    async def _do_get() -> tuple[bytes, str | None] | None:
        try:
            resp = await client.get_object(Bucket=self._cfg.bucket, Key=self._cfg.key)
            body: bytes = await resp["Body"].read()
            return body, resp.get("ETag")
        except Exception as exc:
            if _error_code(exc) in _NOT_FOUND_CODES:
                return None
            raise

    Re-reading the whole small document on retry is correct: the read is idempotent and the ETag is re-fetched with it, so a retry cannot pair one generation’s bytes with another’s ETag.

  2. Extend _is_transient (s3_store.py:112-131) to classify botocore’s streaming/connection failures, which have neither a botocore error code nor an HTTP status. Match on class name to preserve the no-botocore-import property, e.g. type(exc).__name__ in {"ReadTimeoutError", "ResponseStreamingError", "ConnectTimeoutError", "EndpointConnectionError", "ConnectionClosedError", "IncompleteReadError", "ClientPayloadError"}. Verify the real hierarchy against the installed botocore/aiohttp before finalising the set, and keep the existing precondition-conflict fail-fast behaviour untouched (s3_store.py:117-119 docstring; StateStoreConflictError must never be retried).

  3. Wrap the parse in both remote stores with the same translation the file store uses. In s3_store.py around line 247 and gcs_store.py around line 205:

    try:
        data = json.loads(body)
    except (json.JSONDecodeError, UnicodeDecodeError) as exc:
        raise StateObjectCorruptError(
            f"state object s3://{self._cfg.bucket}/{self._cfg.key} is corrupt ({exc}); "
            "refusing to start rather than silently discarding checkpoints. Use "
            "`sf2loki state delete KEY` (or replace the object) to reset the affected "
            "sources to their lookback defaults."
        ) from exc

    Keep the existing non-dict branch as-is; both paths then raise the same exception type.

  4. Add StateObjectCorruptError (and StateFileCorruptError) to the handled-exception list in statecmd._run_with_store (statecmd.py:114-131), printing the message plus the state delete recovery hint and returning _OPERATION_ERROR_EXIT_CODE instead of a traceback.

  5. Make the S3 test double able to fail mid-read: give FakeStreamingBody (tests/state/test_s3_store.py:40-45) an injectable failure so read() can raise on the first N calls.


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