Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 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
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
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 }
1,303 changes: 1,255 additions & 48 deletions crates/edgezero-adapter-fastly/src/chunked_config.rs

Large diffs are not rendered by default.

7,114 changes: 5,193 additions & 1,921 deletions crates/edgezero-adapter-fastly/src/cli.rs

Large diffs are not rendered by default.

9 changes: 6 additions & 3 deletions crates/edgezero-adapter-fastly/src/config_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,12 @@ impl FastlyConfigStore {
/// 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}")),
FastlyConfigStoreBackend::Fastly(inner) => inner.try_get(key).map_err(|err| {
// The `key` here is a pointer-controlled chunk key; the resolver
// adds a safe position locator, so it is not echoed. The platform
// `err` is a fastly SDK type that does not embed the stored value.
format!("config store lookup failed: {err}")
}),
#[cfg(test)]
FastlyConfigStoreBackend::InMemory(data) => Ok(data.get(key).cloned()),
}
Expand Down
37 changes: 37 additions & 0 deletions crates/edgezero-adapter/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,43 @@ 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: "nothing created before this is still being
/// served". 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 Down
146 changes: 144 additions & 2 deletions crates/edgezero-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,14 @@ pub enum Command {
Auth(AuthArgs),
/// Build the project for a target edge.
Build(BuildArgs),
/// Inspect or mutate the typed `<name>.toml` app config.
#[command(subcommand, after_help = crate::args::STUB_POINTER_AFTER_HELP)]
/// Inspect or mutate the typed `<name>.toml` app config, or reclaim
/// orphaned config-store entries (`gc`).
///
/// NOTE: no group-level typed-CLI after-help here. `push`/`diff`/`validate`
/// each carry their own, because they need a typed `C`; `gc` does NOT --
/// it inspects the store's physical entries and runs from this bundled
/// binary. A group-level notice would tell operators `gc` is unavailable.
#[command(subcommand)]
Config(ConfigCmd),
/// Run the bundled `app-demo` example locally (contributor-only).
#[cfg(feature = "demo-example")]
Expand Down Expand Up @@ -56,6 +62,23 @@ pub enum ConfigCmd {
/// (Bundled `edgezero` stub — see after-help for the typed CLI.)
#[command(after_help = STUB_POINTER_AFTER_HELP)]
Diff(ConfigCmdStubArgs),
/// Reclaim chunk entries in the adapter's config store 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
/// such timestamp (Fastly does not). Only YOU know your deploy history,
/// so `--older-than` is your assertion — see its help: it covers EVERY
/// root in the selected store, not only the config you have in mind.
///
/// UNTYPED: unlike `push`/`diff`/`validate`, `gc` inspects the store's
/// physical entries rather than your `AppConfig<C>`, so the bundled
/// `edgezero` binary can run it.
///
/// SAFE BY DEFAULT: without `--yes` this only reports what it would
/// delete. Nothing is removed until you pass `--yes`.
Gc(ConfigGcArgs),
/// Push the typed `<name>.toml` as a single blob envelope to the
/// adapter's config store. The blob carries every field verbatim
/// (per spec 3.3 Model A — `#[secret]` fields store the key NAME,
Expand All @@ -79,6 +102,68 @@ pub struct ConfigCmdStubArgs {
pub trailing: Vec<String>,
}

/// Arguments for `config gc`.
///
/// Unlike `push` / `diff`, `gc` needs no typed app-config: it reclaims
/// unreferenced chunk entries by inspecting the store, so it runs in-band in
/// the bundled `edgezero` binary.
#[derive(clap::Args, Debug)]
#[non_exhaustive]
pub struct ConfigGcArgs {
/// Adapter whose config store to reclaim (e.g. `fastly`).
#[arg(long)]
pub adapter: String,
/// Path to `edgezero.toml`.
#[arg(long, default_value = "edgezero.toml")]
pub manifest: PathBuf,
/// Ignore `EDGEZERO__STORES__CONFIG__<ID>__NAME` when resolving which
/// PHYSICAL store to sweep, so the logical id `<ID>` is used as the physical
/// store name instead.
///
/// This is NOT the app-config overlay the other `config` subcommands mean by
/// `--no-env` — `gc` never loads your typed app config. On a DESTRUCTIVE
/// command it selects a DIFFERENT TARGET: if that variable is what maps your
/// logical id onto the real store, `--no-env` points `gc` at a different
/// store (or one that does not exist). Check the store id `gc` reports before
/// passing `--yes`.
#[arg(long)]
pub no_env: bool,
/// Only reclaim entries older than this. This is YOUR SAFETY ASSERTION,
/// and it is about the WHOLE PHYSICAL STORE, not just one config: `gc`
/// sweeps every root in the selected store, so you are asserting "NO root
/// in this store changed within this window, and no writer is targeting
/// it" — so nothing superseded more recently (which POPs may still serve)
/// is deleted. A sibling root you re-pushed minutes ago is enough to make
/// a wide window unsafe, especially if it changed to a value small enough
/// to store directly (that leaves no chunk for `gc` to date it by).
/// Accepts `s`/`m`/`h`/`d` suffixes (e.g. `7d`, `24h`, `90m`); a bare
/// number means seconds, and 0 is rejected for `--yes`. REQUIRED for
/// `--yes` (a destructive run must not guess it); a dry-run without it
/// previews every orphan and its age.
#[arg(long)]
pub older_than: Option<String>,
/// Override the config-store id (defaults to the manifest's).
#[arg(long)]
pub store: Option<String>,
/// Actually delete. Without this, `gc` only reports what it WOULD delete.
#[arg(long)]
pub yes: bool,
}

impl Default for ConfigGcArgs {
#[inline]
fn default() -> Self {
Self {
adapter: String::new(),
manifest: PathBuf::from("edgezero.toml"),
no_env: false,
older_than: None,
store: None,
yes: false,
}
}
}

/// Arguments for the `auth` command.
///
/// Intentionally has no `Default` impl: unlike the other `*Args`
Expand Down Expand Up @@ -417,6 +502,44 @@ fn default_manifest_path() -> PathBuf {
PathBuf::from("edgezero.toml")
}

/// Parse a human duration (`7d`, `24h`, `90m`, `30s`, or bare seconds) into
/// seconds.
///
/// # Errors
///
/// Returns `Err` when the value is empty, non-numeric, or carries an unknown
/// suffix — a destructive command must never guess at its safety threshold.
#[must_use = "the parsed threshold gates a destructive command"]
#[inline]
pub fn parse_duration_secs(raw: &str) -> Result<u64, String> {
let trimmed = raw.trim();
let unknown = || {
format!(
"could not parse `--older-than {raw}`; expected e.g. `7d`, `24h`, `90m`, `30s`, or a number of seconds"
)
};
let (digits, multiplier) = match trimmed.strip_suffix('s') {
Some(rest) => (rest, 1_u64),
None => match trimmed.strip_suffix('m') {
Some(rest) => (rest, 60_u64),
None => match trimmed.strip_suffix('h') {
Some(rest) => (rest, 3_600_u64),
None => match trimmed.strip_suffix('d') {
Some(rest) => (rest, 86_400_u64),
None => (trimmed, 1_u64),
},
},
},
};
if digits.is_empty() {
return Err(unknown());
}
let value: u64 = digits.parse().map_err(|_err| unknown())?;
value
.checked_mul(multiplier)
.ok_or_else(|| format!("`--older-than {raw}` overflows"))
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -844,4 +967,23 @@ mod tests {
"`[TRAILING]` placeholder leaked into push help: {help}"
);
}
#[test]
fn parse_duration_secs_accepts_suffixes_and_bare_seconds() {
assert_eq!(parse_duration_secs("30s").unwrap(), 30);
assert_eq!(parse_duration_secs("90m").unwrap(), 5_400);
assert_eq!(parse_duration_secs("24h").unwrap(), 86_400);
assert_eq!(parse_duration_secs("7d").unwrap(), 604_800);
assert_eq!(parse_duration_secs("3600").unwrap(), 3_600);
assert_eq!(parse_duration_secs(" 7d ").unwrap(), 604_800);
}

/// A destructive command must never guess at its safety threshold.
#[test]
fn parse_duration_secs_rejects_garbage() {
parse_duration_secs("").unwrap_err();
parse_duration_secs("soon").unwrap_err();
parse_duration_secs("7w").unwrap_err();
parse_duration_secs("-1d").unwrap_err();
parse_duration_secs("d").unwrap_err();
}
}
Loading