Durable State Archive
September 10, 2026 · View on GitHub
The state archive captures durable Radius state exported out of a running cluster and restored later, for example across ephemeral CI runs. It is defined by two small Go interfaces in pkg/statearchive/statearchive.go. OCI artifacts are the only durable backend. Consumers depend on the interfaces and can inject test archives without changing their file handling.
This is intentionally distinct from the live, record-oriented persistence
subsystems documented in state-persistence.md
(database.Client, secret.Client, queue.Client). Those serve the running
control plane one record at a time; an Archive captures a whole directory of
state as a durable snapshot.
graph TD
subgraph "Consumers"
Shutdown["rad shutdown<br/>pkg/cli/cmd/shutdown"]
Startup["rad startup<br/>pkg/cli/cmd/startup"]
GraphStore["Graph Store<br/>pkg/graph/persistence/archive"]
end
subgraph "Interfaces (pkg/statearchive)"
Archive["statearchive.Archive<br/>Open(ctx, name) → Session"]
Session["statearchive.Session<br/>Path() / Commit() / Close()"]
end
subgraph "Implementations"
MockArchive["MockArchive / MockSession<br/>mock_archive.go (tests)"]
OCIArchive["OCIArchive / session<br/>pkg/statearchive/oci"]
end
Shutdown -->|"Open → write → Commit"| Archive
Startup -->|"Open → read"| Archive
GraphStore -->|"Open → read/write → Commit"| Archive
Archive -->|"returns"| Session
Archive -.->|implements| OCIArchive
Archive -.->|implements| MockArchive
Figure 1: Archive consumers and implementations
State commands and the graph adapter use the shared interfaces; production storage uses OCI and tests can inject mocks.
Key Components
statearchive.Archive— the entry-point interface. Its single methodOpen(ctx, name)materializes the durable archive identified bynameinto a local working directory and returns aSession. Files persisted by a previousCommitare already present whenOpenreturns.statearchive.Session— a durable working directory. Callers read and write files underPath()with any ordinary tool (pg_dump,kubectl,os.WriteFile),Commit(ctx, message)persists every change made underPath(), andClose(ctx)releases resources (best-effort, safe todefer).OCIArchive/session(pkg/statearchive/oci/oci.go) — a production implementation that stores each archive as a gzipped tar layer in an OCI artifact.MockArchive/MockSession(pkg/statearchive/mock_archive.go) — GoMock doubles generated from the interfaces, used by consumer tests without a registry.
The Contract
The two interfaces are the entire public surface. Everything a consumer needs is expressed here, independent of the storage backend:
type Archive interface {
Open(ctx context.Context, name string) (Session, error)
}
type Session interface {
Path() string
Commit(ctx context.Context, message string) error
Close(ctx context.Context)
}
Contract guarantees that callers rely on and implementations must honor:
- Round-trip durability — a
nameis a stable key. After a successfulCommit, a laterOpen(ctx, name)presents those files again underPath(). - Atomic persistence —
Commiteither durably persists the state or returns an error; it never silently drops changes. With nothing to persist it is a no-op. - Concurrency safety — implementations must be safe for concurrent use.
An implementation may serialize concurrent
Opencalls for the samenamewhen its storage cannot support simultaneous sessions. - Best-effort cleanup —
Closeis safe todefer; it logs failures rather than returning them so it cannot mask the real error on the happy path.
How It Works
A consumer always follows the same three-phase shape, using only the interface:
session, err := archive.Open(ctx, "radius-state")
if err != nil {
return err
}
defer session.Close(ctx)
// ... read/write files under session.Path() with any tool ...
if err := session.Commit(ctx, "radius: backup"); err != nil {
return err
}
The sequence below shows the rad shutdown backup flow against OCI. The consumer only calls Open, Path, Commit, and Close; registry authentication, artifact transfer, and temporary-directory cleanup stay behind the interface.
sequenceDiagram
participant Cmd as rad shutdown<br/>(Runner)
participant Arc as statearchive.Archive
participant Ses as statearchive.Session
participant OCI as OCI registry
Cmd->>Arc: Open(ctx, "radius-state")
Arc->>Arc: lock repository and archive name
Arc->>OCI: resolve and fetch archive tag
Arc->>Arc: unpack into temporary directory (empty if tag missing)
Arc-->>Cmd: Session (Path = <tmp>)
Cmd->>Ses: Path()
Ses-->>Cmd: <tmp>
Note over Cmd: BackupDatabases(...) → pg_dump into <tmp><br/>BackupTerraform(...) → secrets into <tmp>
Cmd->>Ses: Commit(ctx, "radius: shutdown backup")
Ses->>Ses: create deterministic OCI artifact
Ses->>OCI: check GHCR visibility when applicable, upload changed archive
Ses-->>Cmd: nil
Cmd->>Ses: Close(ctx) (deferred)
Ses->>Ses: remove temporary directory, unlock archive
Figure 2: Shutdown persists a whole-directory snapshot to OCI
OCI sessions materialize a temporary directory and upload its contents on commit.
Selecting an Archive
pkg/statearchive/factory configures OCI for both consumers. Configuration errors are returned by Archive.Open, not CLI initialization, so unrelated commands such as rad version --cli do not need archive configuration.
NewStateArchive(used byrad startupandrad shutdown) requiresRADIUS_STATE_REGISTRYwhen opening the archive.NewGraphArchive(used for modeled graph output in GitHub Actions) requiresRADIUS_GRAPH_REGISTRYwhen opening the archive.RADIUS_STATE_BACKENDmay be unset oroci(case-insensitive). Other values are rejected when the archive is opened.RADIUS_STATE_REGISTRYandRADIUS_GRAPH_REGISTRYare OCI repositories without tags, for exampleghcr.io/<owner>/<repo>-stateandghcr.io/<owner>/<repo>-graphs.RADIUS_ARCHIVE_PLAIN_HTTP=trueenables HTTP for a local test registry.
OCI repositories are configured explicitly; Radius does not derive them from GITHUB_REPOSITORY. Authenticate using Docker credentials before using archival. GitHub Actions workflows must configure the relevant repository, log in (for example using docker/login-action), and grant the token package read/write and metadata access. GHCR packages must be private or internal; the visibility guard described below still applies.
Outside GitHub Actions, rad app graph app.bicep writes local app-graph.json without opening an archive or requiring a registry. This is the normal local-output mode, not a fallback for failed archival. Missing registry configuration and archive read/write failures are returned to the caller without silently discarding persistence or writing elsewhere.
The OCI Implementation
pkg/statearchive/oci/oci.go maps an archive
name to an OCI tag. State and modeled graphs use separate repositories because
they have separate lifecycles and access requirements.
- Open resolves the tag and unpacks its single gzipped tar layer into a temporary directory. A missing tag starts an empty archive.
- Commit streams a deterministic tar.gz artifact through a temporary file-backed ORAS store. Memory use stays bounded as archives grow, and unchanged files create the same digest, so no upload occurs.
- GHCR visibility guard checks GitHub Packages metadata immediately before each state-bearing upload. Private and internal packages are accepted; public packages are rejected without uploading the archive contents. When the package does not exist yet, Radius first pushes a valid empty archive under a reserved bootstrap tag, verifies the resulting package visibility, and only then uploads the real archive. The separate tag cannot overwrite a concurrently created state tag.
- Authentication uses Docker credentials, including credentials created by
docker/login-actionin GitHub Actions. GHCR visibility checks use the same token with GitHub Packages metadata access. - Local testing can use
RADIUS_ARCHIVE_PLAIN_HTTP=truewith a local OCI registry.
End-to-End Test
The OCI state archive has a dedicated end-to-end test that exercises the full save/restore lifecycle against a real GHCR package. It deploys an application through one ephemeral Radius control plane to a separate persistent target cluster, saves state to a private GHCR package, replaces the control plane, restores the saved state, and confirms the replacement control plane still manages the existing workload. This validates the round-trip durability contract and the GHCR visibility guard end to end. It runs on a schedule rather than in the per-PR matrix because it needs packages: write and a precreated private package. See Repo Radius GHCR state end-to-end test for how to provision, run, and troubleshoot it.
How Consumers Stay Decoupled
Every consumer stores a statearchive.Archive (the interface) and accepts an injected implementation for tests:
- Graph store — pkg/graph/persistence/archive/store.go requires a non-nil
Options.Archive; it never constructs a default backend.Save/Load/List/DeletecallOpenand usesession.Path()for JSON file I/O.Save/Deletealso callCommit, whileLoad/Listonly read. Missing graphs returnpersistence.ErrNotFound; key validation rejects traversal and path separators. rad shutdown/rad startup— bothRunnerstructs expose anArchive statearchive.Archivefield and drive the sameOpen → Path → Commit → Closeshape.- Tests — consumer tests inject
MockArchive/MockSessionand assert onOpen/Commit/Closecalls. OCI tests cover durable state and graph round trips.
graph LR
Consumer["Consumer<br/>(Store / Runner)"]
Field["field: statearchive.Archive"]
Default["factory.NewStateArchive / NewGraphArchive"]
Inject["injected: MockArchive / other"]
Consumer --> Field
Default -->|"explicit injection"| Field
Inject -->|"explicit injection"| Field
Figure 3: Consumers receive archives explicitly
The CLI supplies factory-configured OCI archives; tests supply mocks. A nil archive is rejected by the graph adapter.
Change This Safely
Run go test ./pkg/statearchive/... ./pkg/graph/persistence/... ./pkg/cli/cmd/app/graph/... ./pkg/cli/cmd/startup ./pkg/cli/cmd/shutdown ./cmd/rad/cmd after changing archive configuration or the graph adapter. Keep the shared interfaces, OCI format/authentication, archive names, and graph source-branch encoding consistent across these consumers. Graph adapter tests use injected archive sessions; the OCI package includes its own artifact fixtures.
Notable Details
nameis a stable durable key. OCI uses it as the artifact tag (radius-state,radius-graph). Graph key namespaces contain the percent-encoded source branch in<namespace>/app-graph.json.Commiton no changes is a deliberate no-op, so idempotent callers (for example a graphSavethat rewrites identical JSON) do not upload another artifact.Closenever returns an error by design — it is meant fordeferand logs failures so cleanup problems cannot overwrite the real result of the operation.- The mock is generated, not hand-written. The
//go:generatedirective in statearchive.go regeneratesmock_archive.goif the interfaces change, keeping the test doubles in sync with the contract.