Task · SFL-0064

state: Azure Blob checkpoint store backend

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

Description

What

build_store (src/sf2loki/state/__init__.py:13-46) supports exactly three backends: s3 (:25-32), gcs (:34-44), and a file fallback (:46). The config Literal is Literal["file", "s3", "gcs"] (src/sf2loki/config.py:1050-1058), with sub-config fields only for those three (:1059-1067) and a bucket-required validator covering only s3/gcs (:1069-1075). Extras are s3/gcs/k8s (pyproject.toml:28-31). There is no Azure Blob Storage backend anywhere in the tree — a repo-wide grep for azure matches only the azure/setup-helm action at .github/workflows/ci.yml:132.

The two remote backends already implement the same primitive under different vendor names, and both raise the same error type:

Documented at docs/deployment/state.md:31-35. Azure Blob Storage supports the identical primitive natively — Put Blob with If-Match on the blob ETag, returning 412 Precondition Failed when another writer won the race, and If-None-Match: * for create-only (see Azure’s own optimistic-concurrency guidance) — reachable from asyncio via azure.storage.blob.aio.BlobServiceClient with azure.identity.aio.DefaultAzureCredential.

Why it matters

The published Helm chart makes an object-store state backend mandatory for HA and names only s3/gcs:

AKS is already a first-class HA target because the k8s_lease coordinator exists (src/sf2loki/coordinate/k8s_lease.py, config.py:1123+). So an AKS or Azure Container Apps operator gets a native coordinator but has no native stateless checkpoint store: Azure Blob exposes no S3-compatible endpoint, so the options are (a) an RWX Azure Files / PVC mount with the file store — which re-introduces the shared-volume dependency the s3/gcs backends exist to remove, and interacts badly with the file store’s flock-based exclusivity (src/sf2loki/state/file_store.py), (b) a third-party S3-gateway sidecar, or (c) cross-cloud egress to S3/GCS. Every one of those is infrastructure an equivalent EKS/GKE deployment does not need, for a CAS pattern this codebase has already implemented twice.

Proposed approach

Port the s3/gcs shape rather than inventing a new one.

  1. Config — add AzureStateConfig next to GcsStateConfig (src/sf2loki/config.py:1023-1047):

    • account_url: str = "" (e.g. https://<account>.blob.core.windows.net)
    • container: str = "" (required when state.store == "azure")
    • blob_name: str = "sf2loki/state.json"
    • connection_string_file: Path | None = None — read the connection string from a file (secret-mount friendly, mirrors how other secrets are handled); when unset, auth is DefaultAzureCredential (workload identity on AKS) Extend the Literal at config.py:1050 to Literal["file", "s3", "gcs", "azure"], add the azure: field alongside :1062-1067, and extend _require_bucket_for_remote (:1069-1075) to require container (and account_url unless a connection string is configured) when store == "azure".
  2. Storesrc/sf2loki/state/azure_store.py, AzureCheckpointStore, method-for-method with S3CheckpointStore: load (s3_store.py:256), commit (:262), commit_many (:265), delete (:310), set_fence (:184), reset (:193), close (:361), plus the lazily-cached client (_get_client, :217) and cached document (_ensure_loaded, :224). Reuse StateStoreConflictError / StateObjectCorruptError and the _is_transient / _retry_transient retry discipline (s3_store.py:45, :56, :112-148) exactly as gcs_store.py:28 does — do not fork a second retry policy.

    • Update: upload_blob(payload, overwrite=True, etag=<current>, match_condition=MatchConditions.IfNotModified).
    • First write: match_condition=MatchConditions.IfMissing (If-None-Match: *).
    • Map 412/409 (azure.core.exceptions.ResourceModifiedError, ResourceExistsError) to StateStoreConflictError and never retry it; retry only transient 5xx / connection errors.
    • Do not implement set_epoch. It is file-store-only (src/sf2loki/state/file_store.py:80) and the app installs it via getattr (src/sf2loki/app.py:966-968); neither remote store has it.
    • No top-level import of azure.*. Follow the documented reason at gcs_store.py:1-16 and _default_client_factory (gcs_store.py:159): build the client lazily inside a factory so the module stays importable, unit-testable with an injected fake client, and mypy --strict-clean without the extra installed (which is why no [[tool.mypy.overrides]] entry exists for aiobotocore/gcloud at pyproject.toml:95-97 — keep it that way for azure).
  3. Factory — add an azure branch to build_store (state/__init__.py) with the same explicit importlib.util.find_spec guard and actionable ConfigError the s3/gcs branches use (:26-32, :36-44). Probe the top-level package name (azure.storage.blob is a namespace package — verify which bare name find_spec resolves cleanly, per the gcloud note at :35-38, and guard on that).

  4. Extraazure = ["azure-storage-blob>=12.24", "azure-identity>=1.19"] in pyproject.toml:28-31; refresh uv.lock.

  5. Doctor — extend the state probe: _probe_state_config (src/sf2loki/doctor.py:531-548) needs an azure branch producing a probe-suffixed blob_name, and _state_object_target (:550-554) an azure://<container>/<blob_name> target string. _check_state (:487) already routes every non-file store through _check_state_object (:557), so no dispatch change is needed there.

  6. Docs / generated artifacts — run just gen-config to regenerate config.example.yaml and docs/config-reference.md (drift-gated by tests/test_config_artifacts_drift.py); add an azure row to the backend table at docs/deployment/state.md:31-35 and to the see also line at :105; update docs/installation.md:16, README.md:37/:449-459, deploy/helm/values.yaml:14-15/:179/:216 (and the regenerated values.yaml config block plus its Helm drift gate), deploy/helm/templates/deployment.yaml:178, deploy/helm/templates/networkpolicy.yaml:156-157, deploy/helm/templates/rbac.yaml:30. sf2loki state needs no change — src/sf2loki/statecmd.py goes through build_store.


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