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:
S3CheckpointStore(src/sf2loki/state/s3_store.py:150) — whole-documentPutObjectwithIfMatchon the current ETag,IfNoneMatch: *for the first write (:265-310), CAS failure classified toStateStoreConflictError(:45).GcsCheckpointStore(src/sf2loki/state/gcs_store.py:105) — same document,ifGenerationMatchprecondition ("0"for first write), sameStateStoreConflictErrorimported from the S3 module (gcs_store.py:28).
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:
deploy/helm/values.yaml:14-15— “expectsconfig.coordinate.type: k8s_leaseAND a SHARED state store (config.state.store: s3 | gcs— the localfilestore is per-pod and INVALID”deploy/helm/values.yaml:179,:216,:620- render guard at
deploy/helm/templates/deployment.yaml:9, comment at:178 deploy/helm/templates/networkpolicy.yaml:156-163(state-store egress),deploy/helm/templates/rbac.yaml:30
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.
-
Config — add
AzureStateConfignext toGcsStateConfig(src/sf2loki/config.py:1023-1047):account_url: str = ""(e.g.https://<account>.blob.core.windows.net)container: str = ""(required whenstate.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 isDefaultAzureCredential(workload identity on AKS) Extend the Literal atconfig.py:1050toLiteral["file", "s3", "gcs", "azure"], add theazure:field alongside:1062-1067, and extend_require_bucket_for_remote(:1069-1075) to requirecontainer(andaccount_urlunless a connection string is configured) whenstore == "azure".
-
Store —
src/sf2loki/state/azure_store.py,AzureCheckpointStore, method-for-method withS3CheckpointStore: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). ReuseStateStoreConflictError/StateObjectCorruptErrorand the_is_transient/_retry_transientretry discipline (s3_store.py:45,:56,:112-148) exactly asgcs_store.py:28does — 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) toStateStoreConflictErrorand 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 viagetattr(src/sf2loki/app.py:966-968); neither remote store has it. - No top-level import of
azure.*. Follow the documented reason atgcs_store.py:1-16and_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, andmypy --strict-clean without the extra installed (which is why no[[tool.mypy.overrides]]entry exists foraiobotocore/gcloudatpyproject.toml:95-97— keep it that way forazure).
- Update:
-
Factory — add an
azurebranch tobuild_store(state/__init__.py) with the same explicitimportlib.util.find_specguard and actionableConfigErrorthe s3/gcs branches use (:26-32,:36-44). Probe the top-level package name (azure.storage.blobis a namespace package — verify which bare namefind_specresolves cleanly, per thegcloudnote at:35-38, and guard on that). -
Extra —
azure = ["azure-storage-blob>=12.24", "azure-identity>=1.19"]inpyproject.toml:28-31; refreshuv.lock. -
Doctor — extend the state probe:
_probe_state_config(src/sf2loki/doctor.py:531-548) needs anazurebranch producing a probe-suffixedblob_name, and_state_object_target(:550-554) anazure://<container>/<blob_name>target string._check_state(:487) already routes every non-filestore through_check_state_object(:557), so no dispatch change is needed there. -
Docs / generated artifacts — run
just gen-configto regenerateconfig.example.yamlanddocs/config-reference.md(drift-gated bytests/test_config_artifacts_drift.py); add anazurerow to the backend table atdocs/deployment/state.md:31-35and to thesee alsoline at:105; updatedocs/installation.md:16,README.md:37/:449-459,deploy/helm/values.yaml:14-15/:179/:216(and the regeneratedvalues.yamlconfig 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 stateneeds no change —src/sf2loki/statecmd.pygoes throughbuild_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
- #1
state.store: azureselectable;AzureStateConfigadded withaccount_url/container/blob_name/connection_string_file, and theLiteralatsrc/sf2loki/config.py:1050extended. - #2
AzureCheckpointStoreimplementsload/commit/commit_many/delete/set_fence/reset/close, with noset_epoch. - #3
src/sf2loki/state/azure_store.pyimports noazure.*symbol at module scope;just gateis green with theazureextra not installed. - #4
pyproject.tomldeclares theazureextra;uv.lockrefreshed. - #5 Doctor probes the Azure backend against a probe-suffixed blob, never the real checkpoint blob.
- #6
just gen-configre-run; config/docs/Helm drift gates green. - #7 Tests, mirroring
tests/state/test_gcs_store.pywith an injected fake client (no live Azure): - [ ] first write uses the create-only precondition (If-None-Match: */MatchConditions.IfMissing); a subsequent write usesIf-Matchwith the ETag returned by the previous upload - [ ] a 412 on update raisesStateStoreConflictErrorand is not retried (assert exactly one upload attempt) - [ ] a transient 503 on upload is retried and then succeeds; a transient 503 on download is retried onload- [ ] non-JSON / truncated blob content raisesStateObjectCorruptError- [ ]commit_manywrites one blob containing all keys (single upload call), andloadreturns each;deleteremoves a key and preserves the rest - [ ]set_fencerefuses a commit once the fence is lost, matching the s3/gcs behaviour asserted intests/state/test_s3_store.py- [ ]resetdrops the cached document so the nextloadre-downloads - [ ]closeis awaited and closes the client (regression guard for the leak fixed in #52) - [ ]tests/state/test_build_store.pygains an azure case asserting the missing-extraConfigErrornamespip install 'sf2loki[azure]'(mirroring:29) - [ ] config validation:state.store: azurewith an emptycontainerfails with an actionable message
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