Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
3ff9e94
Spec: Fastly chunked-config GC to reclaim orphaned chunk entries on r…
aram356 Jul 8, 2026
77d65bd
Spec review v2: fix file map (cli.rs, not the unmerged split), valida…
aram356 Jul 8, 2026
533c644
Spec review v3: thread logical roots (--key is free-form, infix infer…
aram356 Jul 8, 2026
d484fa5
Add Fastly chunk-GC implementation plan, reconciled with spec v3
aram356 Jul 8, 2026
93a5886
Self-review fixes (spec+plan): expand new_keys in local dry-run so id…
aram356 Jul 8, 2026
1089b5b
Review v4 (spec+plan): close cloud GC concurrency hole via post-commi…
aram356 Jul 8, 2026
98bc468
Review v5 (spec+plan): document cloud GC as single-writer/best-effort…
aram356 Jul 8, 2026
b8eb07b
Reframe cloud concurrency as last-writer-wins (per decision)
aram356 Jul 8, 2026
3cc25af
Plan/spec tighten-ups (non-blocking): non-vacuous local suspicious-po…
aram356 Jul 8, 2026
67ac0dd
feat(fastly): add prior_chunk_keys for chunk GC (Value-first, v1-vali…
aram356 Jul 8, 2026
653b220
feat(fastly): add chunk-GC planning helpers (expand_root, orphan_chun…
aram356 Jul 8, 2026
e8853b9
feat(fastly): prune prior config chunks on local re-push
aram356 Jul 8, 2026
77a30ec
feat(fastly): reclaim prior config chunks after cloud re-push (last-w…
aram356 Jul 8, 2026
2c36b5d
chore(fastly): satisfy strict clippy for chunk-GC code; render fake f…
aram356 Jul 8, 2026
0f516dd
chore(fastly): hoist fake-fastly TEMPLATE const above statements (ite…
aram356 Jul 8, 2026
205048d
test(fastly): close self-review coverage gaps
aram356 Jul 8, 2026
891ecc6
fix(fastly): dry-run classifies malformed local state as unknown; tes…
aram356 Jul 9, 2026
6f65e51
fix(fastly): redact describe payloads from diagnostics (P1); reject d…
aram356 Jul 12, 2026
33932de
fix(fastly)!: defer cloud chunk reclamation (eager delete is unsafe u…
aram356 Jul 13, 2026
2047095
docs: spec the deferred cloud reclamation design + payload-redaction …
aram356 Jul 13, 2026
5454402
fix(fastly): complete the payload-redaction invariant — redact stderr…
aram356 Jul 13, 2026
abda226
refactor(fastly)!: derive cloud reclamation from the store, deleting …
aram356 Jul 13, 2026
481f590
docs: reconcile spec to the shipped store-derived design; mark the pl…
aram356 Jul 13, 2026
e5ac9d0
fix(fastly)!: remove automatic cloud chunk deletion; add operator-inv…
aram356 Jul 14, 2026
6e909bd
docs: spec `config gc`; record why automatic cloud reclamation is imp…
aram356 Jul 14, 2026
5793bc1
Merge remote-tracking branch 'origin/main' into spec/fastly-chunk-gc
aram356 Jul 14, 2026
9a40098
feat(cli): wire `config gc` into the bundled + app-demo CLIs
aram356 Jul 15, 2026
60a0a22
fix(fastly): harden config gc against the merge-blocking review findings
aram356 Jul 16, 2026
d74406c
docs: rewrite the implementation plan to the shipped config gc design
aram356 Jul 16, 2026
4ff45ec
PR #314 v5 review: fail-closed GC classifier, both-age rule, honest c…
aram356 Jul 16, 2026
5e7f377
PR #314 v6 review: never delete roots, validate pointers, fix WASM cl…
aram356 Jul 16, 2026
c8ff0c1
PR #314 v7 review: prove chunks by content, not metadata
aram356 Jul 17, 2026
4aae23c
PR #314 v8 review: round-trip against the writer; stop claiming proof
aram356 Jul 17, 2026
0126e1c
PR #314 v9 review: classify roots by value, redact runtime paths, hon…
aram356 Jul 17, 2026
b288ee9
Verify inner envelope on read, protect envelope-valued roots, key-lim…
aram356 Jul 18, 2026
0bde0e9
Redact typed-deserialize errors, degrade local reads, validate pointe…
aram356 Jul 18, 2026
37cadc5
Redact stored values across all read/error paths; protect local prune…
aram356 Jul 18, 2026
98e05fa
Redact writer/response/Debug leaks, restore field-path contract, fix …
aram356 Jul 19, 2026
f9709f2
Redact remaining parse leaks, redact field-path, char-limit, body-awa…
aram356 Jul 19, 2026
74fa24e
Reject Unicode char/byte pointer mismatch; fix redaction docs/tests; …
aram356 Jul 19, 2026
e42e9f2
Restore v1 byte threshold on read, pin exact split boundaries
aram356 Jul 20, 2026
31008ad
Compile the fastly+cli wasm32-wasip1 combination cleanly
aram356 Jul 21, 2026
b54e710
Restore raw ConfigStore contract; atomic local rewrite; align GC with…
aram356 Jul 23, 2026
c5689c0
Serialise local pushes with a lock; refine GC foreign-value handling
aram356 Jul 26, 2026
a17d159
Fix GC unknown-kind data loss, widen manifest lock, harden staging
aram356 Jul 27, 2026
210416b
Make corrupt generations repairable; symmetric unknown-kind protection
aram356 Jul 28, 2026
a2c2eac
Refine read taxonomy: repairable corruption vs infra/unknown; local s…
aram356 Jul 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions .github/workflows/format.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,19 @@ jobs:
include:
- adapter: cloudflare
target: wasm32-unknown-unknown
features: cloudflare
- adapter: fastly
target: wasm32-wasip1
features: fastly
# The `cli` feature is a valid wasip1 build combination (the CLI host
# code compiled for the guest target); gate it so a dead-code or
# portability regression there is caught, not only in the fastly-only build.
- adapter: fastly
target: wasm32-wasip1
features: fastly cli
- adapter: spin
target: wasm32-wasip2
features: spin
steps:
- uses: actions/checkout@v6

Expand Down Expand Up @@ -110,8 +119,8 @@ jobs:
- name: Fetch dependencies (locked)
run: cargo fetch --locked

- name: Clippy ${{ matrix.adapter }} on ${{ matrix.target }}
run: cargo clippy -p edgezero-adapter-${{ matrix.adapter }} --features ${{ matrix.adapter }} --target ${{ matrix.target }} --all-targets -- -D warnings
- name: Clippy ${{ matrix.adapter }} (${{ matrix.features }}) on ${{ matrix.target }}
run: cargo clippy -p edgezero-adapter-${{ matrix.adapter }} --features "${{ matrix.features }}" --target ${{ matrix.target }} --all-targets -- -D warnings

format-docs:
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/edgezero-adapter-fastly/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,5 @@ walkdir = { workspace = true, optional = true }

[dev-dependencies]
edgezero-core = { path = "../edgezero-core", features = ["test-utils"] }
handlebars = { workspace = true }
tempfile = { workspace = true }
2,294 changes: 2,125 additions & 169 deletions crates/edgezero-adapter-fastly/src/chunked_config.rs

Large diffs are not rendered by default.

8,902 changes: 7,022 additions & 1,880 deletions crates/edgezero-adapter-fastly/src/cli.rs

Large diffs are not rendered by default.

154 changes: 121 additions & 33 deletions crates/edgezero-adapter-fastly/src/config_store.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Fastly adapter config store: wraps `fastly::ConfigStore`.

use std::cell::Cell;
#[cfg(test)]
use std::collections::HashMap;

Expand Down Expand Up @@ -28,18 +29,6 @@ impl FastlyConfigStore {
}
}

/// Synchronous key lookup used by the chunk-pointer resolver callback.
/// Returns `Ok(Some(value))`, `Ok(None)` (missing), or `Err(message)`.
fn get_sync(&self, key: &str) -> Result<Option<String>, String> {
match &self.inner {
FastlyConfigStoreBackend::Fastly(inner) => inner
.try_get(key)
.map_err(|err| format!("config store lookup failed for `{key}`: {err}")),
#[cfg(test)]
FastlyConfigStoreBackend::InMemory(data) => Ok(data.get(key).cloned()),
}
}

/// Open a Fastly Config Store by resource link name.
///
/// Returns an error if the configured store cannot be opened.
Expand Down Expand Up @@ -68,30 +57,83 @@ impl ConfigStore for FastlyConfigStore {
let Some(value) = root_value else {
return Ok(None);
};
// Resolve chunk pointers transparently. Direct BlobEnvelope values
// pass through unchanged; pointer values fan out to chunk entries
// in the same store. Missing / malformed / hash-mismatched chunks
// are corrupt platform state — spec 9.3 (line 6272) calls this an
// internal config-store error with re-push remediation, NOT a
// transient unavailable. Mapping to `internal` surfaces as HTTP
// 500 and pushes operators toward `<app-cli> config push` instead
// of waiting for a 503 to clear.
// Resolve chunk pointers transparently. Direct BlobEnvelope values and
// any other raw value pass through; pointer values fan out to chunk
// entries in the same store.
//
// A chunk fetch can fail in two classes that must NOT collapse to one
// status:
// - Corrupt state (a hash mismatch, a bad/oversized derived key) →
// Internal (HTTP 500) with re-push remediation, per spec 9.3.
// Re-pushing rewrites the generation and fixes it.
// - Transient (invalid store handle, lookup exhaustion, an unclassified
// error, a value that outgrew the read buffer, OR a referenced chunk
// not yet visible at this POP) → Unavailable (HTTP 503, retryable).
//
// A MISSING referenced chunk is deliberately transient. Config Store is
// eventually consistent ACROSS keys, so right after a push the flipped
// root pointer can be visible at a POP before all of its
// content-addressed chunks have propagated there. That gap is not
// corruption — a retry moments later resolves it — so it must read as 503,
// not a re-push-me 500. (Genuine, lasting corruption then shows as a
// persistent 503 the operator repairs by re-pushing; that is strictly
// safer than a spurious 500 during the normal propagation window.)
// `transient` records whether any chunk fetch hit that class so the outer
// result can pick the right status.
let transient = Cell::new(false);
let resolved = resolve_fastly_config_value(key, value, |chunk_key| {
self.get_sync(chunk_key)
let got = match &self.inner {
FastlyConfigStoreBackend::Fastly(inner) => inner.try_get(chunk_key).map_err(|err| {
if is_transient_lookup(&err) {
transient.set(true);
}
// The pointer-controlled chunk key is not echoed (the resolver
// adds a safe position locator); the SDK `err` carries no value.
format!("config store lookup failed: {err}")
})?,
#[cfg(test)]
FastlyConfigStoreBackend::InMemory(data) => data.get(chunk_key).cloned(),
};
if got.is_none() {
// Referenced chunk absent at this POP: treat as propagation lag
// (transient) rather than corruption.
transient.set(true);
}
Ok(got)
})
.map_err(|err| {
log::warn!(
"Fastly config-store chunk resolution failed for `{key}`: {err}. \
if transient.get() {
log::warn!(
"Fastly config-store chunk lookup for `{key}` was transiently unavailable: {err}"
);
ConfigStoreError::unavailable("config store temporarily unavailable")
} else {
log::warn!(
"Fastly config-store chunk resolution failed for `{key}`: {err}. \
Re-run `<app-cli> config push` to repair the store."
);
ConfigStoreError::internal(anyhow::anyhow!(
"config store entry is corrupt or incomplete; re-run config push to repair: {err}"
))
);
ConfigStoreError::internal(anyhow::anyhow!(
"config store entry is corrupt or incomplete; re-run config push to repair: {err}"
))
}
})?;
Ok(Some(resolved))
}
}

/// Is a CHUNK lookup failure environmental (retry) rather than corrupt config
/// (`config push` to repair)? Only a bad KEY names corrupt state a re-push
/// rewrites; everything else — an invalid store handle, lookup exhaustion, an
/// unclassified/future failure, or a value that outgrew the read buffer — is
/// transient, because re-pushing cannot fix a request-scoped condition.
fn is_transient_lookup(err: &LookupError) -> bool {
// `ValueTooLong` is TRANSIENT, not corruption: the SDK already retried with
// the reported buffer size, so a `ValueTooLong` reaching us means the value
// GREW between host calls (a concurrent write) — a race a retry resolves, not
// a re-push-me corruption.
!matches!(err, LookupError::KeyInvalid | LookupError::KeyTooLong)
}

fn map_lookup_error(err: &LookupError) -> ConfigStoreError {
// `LookupError` is from the `fastly` crate; using a wildcard arm guards
// against new variants being added in upstream point releases without
Expand Down Expand Up @@ -134,6 +176,53 @@ mod tests {
assert!(matches!(err, ConfigStoreError::InvalidKey { .. }));
}

/// A CHUNK lookup that fails transiently (lookup exhaustion, an invalid
/// store handle, an unclassified error, or a value that outgrew the read
/// buffer) must keep Unavailable semantics — re-pushing cannot repair a
/// request-scoped condition. Only a bad KEY is corrupt state a re-push fixes.
#[test]
fn transient_chunk_lookups_are_not_treated_as_corruption() {
assert!(is_transient_lookup(&LookupError::TooManyLookups));
assert!(is_transient_lookup(&LookupError::ConfigStoreInvalid));
assert!(is_transient_lookup(&LookupError::Other));
// ValueTooLong is TRANSIENT: the SDK already retried at the reported size,
// so it reaching us means the value grew between host calls (a race).
assert!(is_transient_lookup(&LookupError::ValueTooLong));
assert!(!is_transient_lookup(&LookupError::KeyInvalid));
assert!(!is_transient_lookup(&LookupError::KeyTooLong));
}

/// A referenced chunk that is ABSENT maps to Unavailable (HTTP 503), not
/// Internal. Config Store is eventually consistent across keys, so a flipped
/// root pointer can reach a POP before all its chunks propagate there; that
/// window is retryable, not a re-push-me corruption.
#[test]
fn a_missing_chunk_maps_to_unavailable_not_internal() {
use crate::chunked_config::prepare_fastly_config_entries;
use futures::executor::block_on;

// A real chunked value, but seed ONLY the root pointer -- the chunks it
// references are "not yet propagated" to this POP.
let envelope = {
use edgezero_core::blob_envelope::BlobEnvelope;
use serde_json::json;
serde_json::to_string(&BlobEnvelope::new(
json!({ "pad": "x".repeat(9_000) }),
"2026-01-01T00:00:00Z".to_owned(),
))
.expect("envelope")
};
let entries = prepare_fastly_config_entries("app_config", &envelope).expect("expand");
let (root_key, pointer_json) = entries.last().expect("pointer").clone();
let store = FastlyConfigStore::from_entries([(root_key.clone(), pointer_json)]);

let err = block_on(store.get(&root_key)).expect_err("a missing chunk must error");
assert!(
matches!(err, ConfigStoreError::Unavailable { .. }),
"a not-yet-propagated chunk must be retryable (Unavailable), not Internal: {err:?}"
);
}

/// Spec 9.3 (line 6272): missing chunks, hash mismatches, pointer
/// parse failures, and full-envelope mismatches are CORRUPT PLATFORM
/// STATE — the runtime returns an internal config-store error with
Expand All @@ -142,14 +231,13 @@ mod tests {
#[test]
fn corrupt_chunk_pointer_maps_to_internal_not_unavailable() {
use futures::executor::block_on;
// A root value that is neither a valid BlobEnvelope nor a valid
// FastlyChunkPointer triggers the resolver's "malformed pointer"
// path. We do NOT add the chunks the (would-be) pointer expects,
// so any chunk fetch would also fail — but the resolver bails
// earlier on the parse failure.
// A root value that ANNOUNCES our chunk-pointer kind but is malformed.
// It must be a pointer-kind value: an unrelated raw value is a
// legitimate Config Store entry and passes through untouched, so it
// would not exercise the corruption path at all.
let store = FastlyConfigStore::from_entries([(
"app_config".to_owned(),
"not-a-valid-envelope-or-pointer".to_owned(),
r#"{"edgezero_kind":"fastly_config_chunks"}"#.to_owned(),
)]);
let err = block_on(store.get("app_config"))
.expect_err("corrupt root must map to a ConfigStoreError");
Expand Down
74 changes: 74 additions & 0 deletions crates/edgezero-adapter/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,15 @@ impl<'entry> TypedSecretEntry<'entry> {
/// Outcome of a single-key read. See spec 9.0.
#[non_exhaustive]
pub enum ReadConfigEntry {
/// The entry EXISTS but its stored value cannot be resolved to a usable
/// envelope — a missing chunk, a hash mismatch, a malformed/foreign pointer.
/// Distinct from a read/IO failure (which still errors): the store WAS
/// reachable and the key WAS present, so a `config push` can safely overwrite
/// it. This is the runtime's "re-run config push to repair" contract made
/// real — the push treats it like an absent remote (proceeds to write) rather
/// than aborting, so a broken generation is recoverable in-band. The
/// `&'static str` carries a redacted, human-readable reason.
Corrupt(&'static str),
/// The store exists but the key is absent (operator hasn't pushed yet,
/// or pushed under a different key).
MissingKey,
Expand Down Expand Up @@ -243,6 +252,51 @@ pub trait Adapter: Sync + Send {
/// Returns an error string if the requested adapter action fails.
fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String>;

/// Reclaim chunk entries that no LIVE config pointer references.
///
/// Deliberately NOT part of `config push`. On an eventually-consistent
/// store, a chunk may only be deleted once the pointer that referenced it
/// has stopped being served everywhere — and the platform may record no
/// pointer-supersession time (Fastly does not: `updated_at` is not bumped
/// by an upsert) and offer no compare-and-swap with which to record one
/// safely. Only the OPERATOR knows their deploy history, so `older_than`
/// carries that assertion.
///
/// **`older_than` is a STORE-WIDE assertion, and it is stronger than "old
/// creations are no longer served".** `config gc` sweeps every root in the
/// selected store, so the caller asserts: **no root in this store changed
/// within the window, AND no writer is targeting the store** while `gc` runs.
/// A recently-changed sibling root — especially one that shrank to a direct
/// value, leaving no live chunk to age by — makes a wide window unsafe. Direct
/// trait callers MUST honour this stronger contract, not the weaker
/// paraphrase. A `dry_run` lists exactly what would be deleted, for review.
///
/// # Errors
///
/// Returns `Err` if the adapter has no `config gc` impl, if the platform
/// state cannot be read, or if it cannot be classified with confidence —
/// reclamation FAILS CLOSED: when in doubt, nothing is deleted.
#[inline]
#[expect(
clippy::too_many_arguments,
reason = "mirrors `push_config_entries`: manifest root, adapter manifest path, component selector, resolved store, push-time overlay, the operator's age assertion, and dry-run — each distinct; an aggregate struct is a worse ergonomic trade for implementers."
)]
fn gc_config_entries(
&self,
_manifest_root: &Path,
_adapter_manifest_path: Option<&str>,
_component_selector: Option<&str>,
_store: &ResolvedStoreId,
_push_ctx: &AdapterPushContext<'_>,
_older_than_secs: u64,
_dry_run: bool,
) -> Result<Vec<String>, String> {
Err(format!(
"adapter `{}` does not implement `config gc`",
self.name()
))
}

/// Store kinds whose logical-id namespaces the adapter merges into
/// a single backend at runtime — declaring the SAME logical id
/// under two merged kinds causes silent write collisions because
Expand All @@ -263,6 +317,26 @@ pub trait Adapter: Sync + Send {
/// Name used to reference the adapter (case-insensitive).
fn name(&self) -> &'static str;

/// Reject a config `(key, body)` that this adapter cannot store, BEFORE any
/// provider I/O. Called by `config push` ahead of the remote read, so an
/// infeasible push fails offline instead of after a `list`/`describe`
/// round-trip.
///
/// `body` is the serialised blob-envelope JSON that would be written, so
/// body-dependent feasibility (e.g. Fastly's derived chunk-key length and
/// pointer-size limits, which depend on whether the value chunks) can be
/// checked here rather than during the later write expansion.
///
/// Default: accept. The Fastly adapter overrides this to reject a reserved
/// or over-limit key and to run the full chunk expansion offline.
///
/// # Errors
/// Returns a human-readable error string if the push is infeasible.
#[inline]
fn preflight_config_write(&self, _key: &str, _body: &str) -> Result<(), String> {
Ok(())
}

/// Provision the platform resources backing each store id the
/// user declared. Returns a list of human-readable
/// status lines the CLI logs verbatim — one line per resource
Expand Down
Loading