Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 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
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 @@ -47,4 +47,5 @@ walkdir = { workspace = true, optional = true }

[dev-dependencies]
edgezero-core = { path = "../edgezero-core", features = ["test-utils"] }
handlebars = { workspace = true }
tempfile = { workspace = true }
196 changes: 196 additions & 0 deletions crates/edgezero-adapter-fastly/src/chunked_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,87 @@ where
Ok(reconstructed)
}

/// Validate a prior root value and return the chunk keys it referenced,
/// scoped to `root_key`'s own chunk namespace. Used only for chunk GC on
/// re-push (Stage 7 writeback).
///
/// Parse as `serde_json::Value` FIRST so a pointer-kind value with
/// missing/invalid fields still reaches the warning path instead of being
/// silently dropped by a failed struct deserialize.
///
/// Returns:
/// - `Ok(keys)` -- value is a valid v1 chunk pointer; `keys` are its
/// `chunks[].key` entries (all confirmed to match
/// `"{root_key}{CHUNK_KEY_INFIX}"`).
/// - `Ok(vec![])` -- value is a direct `BlobEnvelope`, absent, or not
/// pointer-shaped at all (normal: first push, or was-direct). Silent.
/// - `Err(msg)` -- value IS pointer-kind (`edgezero_kind == POINTER_KIND`)
/// but fails validation: malformed, unsupported `version`, or a
/// referenced key falls outside this root's chunk prefix. Callers log
/// `msg` as a warning and skip GC for this root (delete nothing).
#[cfg(any(feature = "cli", test))]
pub(crate) fn prior_chunk_keys(root_key: &str, raw: &str) -> Result<Vec<String>, String> {
// 1. Parse loosely. Not-JSON, or not our pointer kind => silent.
let value: serde_json::Value = match serde_json::from_str(raw) {
Ok(value) => value,
Err(_) => return Ok(Vec::new()),
};
if value
.get("edgezero_kind")
.and_then(serde_json::Value::as_str)
!= Some(POINTER_KIND)
{
// Direct BlobEnvelope, unrelated JSON, or first push.
return Ok(Vec::new());
}

// 2. It IS pointer-kind: from here every failure WARNS (Err), never silent.
let pointer: FastlyChunkPointer = serde_json::from_value(value).map_err(|err| {
format!("prior chunk pointer at `{root_key}` is malformed: {err}; skipping chunk GC")
})?;
if pointer.version != 1 {
return Err(format!(
"prior chunk pointer at `{root_key}` has unsupported version {}; skipping chunk GC",
pointer.version
));
}

// 3. Prefix-scope every referenced key to this root's own namespace.
let prefix = format!("{root_key}{CHUNK_KEY_INFIX}");
let mut keys = Vec::with_capacity(pointer.chunks.len());
for chunk_ref in pointer.chunks {
if !chunk_ref.key.starts_with(&prefix) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😃 The delete-set blast radius is well constrained. Validating every prior reference against the root-specific chunk namespace, combined with keep-set subtraction and tests for foreign prefixes, sibling roots, identical pushes, and --all prevention, provides strong protection against malformed pointers and accidental broad deletion.

return Err(format!(
"prior chunk pointer at `{root_key}` references chunk key `{}` outside expected prefix `{prefix}`; skipping chunk GC",
chunk_ref.key
));
}
keys.push(chunk_ref.key);
}
Ok(keys)
}

/// Extract the content-address (envelope SHA) of the generation a chunk key
/// belongs to, for `root_key`. Returns `None` when `key` is not a well-formed
/// chunk key of that root.
///
/// This is how reclamation groups the store's ACTUAL keys into generations. It
/// validates the shape (`<root>.__edgezero_chunks.<hex-sha>.<index>`) rather
/// than trusting it: a hand-edited or foreign key never becomes a delete target.
#[cfg(any(feature = "cli", test))]
pub(crate) fn chunk_key_generation(root_key: &str, key: &str) -> Option<String> {
let prefix = format!("{root_key}{CHUNK_KEY_INFIX}");
let rest = key.strip_prefix(&prefix)?;
let (sha, index) = rest.rsplit_once('.')?;
if sha.is_empty() || !sha.chars().all(|ch| ch.is_ascii_hexdigit()) {
return None;
}
if index.is_empty() || !index.chars().all(|ch| ch.is_ascii_digit()) {
return None;
}
Some(sha.to_owned())
}

// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -657,4 +738,119 @@ mod tests {
);
assert!(err.contains("my_key"), "error must name root key: {err}");
}

// ---- prior_chunk_keys tests ----

#[test]
fn prior_chunk_keys_returns_valid_v1_pointer_keys() {
let envelope = make_envelope_json(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1));
let entries = prepare_fastly_config_entries("app_config", &envelope).unwrap();
let (_, pointer_json) = entries.last().unwrap();

let keys = prior_chunk_keys("app_config", pointer_json).expect("valid pointer");

let expected: Vec<String> = entries[..entries.len().saturating_sub(1)]
.iter()
.map(|(key, _)| key.clone())
.collect();
assert_eq!(keys, expected);
}

#[test]
fn prior_chunk_keys_returns_empty_for_direct_envelope() {
let envelope = make_envelope_json(FASTLY_CONFIG_ENTRY_LIMIT);
assert_eq!(
prior_chunk_keys("app_config", &envelope).unwrap(),
Vec::<String>::new()
);
}

#[test]
fn prior_chunk_keys_returns_empty_for_unrelated_json() {
assert_eq!(
prior_chunk_keys("app_config", r#"{"hello":"world"}"#).unwrap(),
Vec::<String>::new()
);
}

#[test]
fn prior_chunk_keys_returns_empty_for_wrong_kind() {
let raw = r#"{"edgezero_kind":"other","version":1,"chunks":[],"data_sha256":"","envelope_len":0,"envelope_sha256":""}"#;
assert_eq!(
prior_chunk_keys("app_config", raw).unwrap(),
Vec::<String>::new()
);
}

#[test]
fn prior_chunk_keys_rejects_unsupported_pointer_version() {
let envelope = make_envelope_json(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1));
let entries = prepare_fastly_config_entries("app_config", &envelope).unwrap();
let (_, pointer_json) = entries.last().unwrap();
let mut pointer: FastlyChunkPointer = serde_json::from_str(pointer_json).unwrap();
pointer.version = 2;
let raw = serde_json::to_string(&pointer).unwrap();

let err = prior_chunk_keys("app_config", &raw).expect_err("version 2 should warn");
assert!(err.contains("unsupported version"), "{err}");
}

#[test]
fn prior_chunk_keys_rejects_foreign_chunk_prefix() {
let envelope = make_envelope_json(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1));
let entries = prepare_fastly_config_entries("app_config", &envelope).unwrap();
let (_, pointer_json) = entries.last().unwrap();
let mut pointer: FastlyChunkPointer = serde_json::from_str(pointer_json).unwrap();
pointer.chunks[0].key =
pointer.chunks[0]
.key
.replacen("app_config", "app_config_staging", 1);
let raw = serde_json::to_string(&pointer).unwrap();

let err = prior_chunk_keys("app_config", &raw).expect_err("foreign chunk should warn");
assert!(err.contains("outside"), "{err}");
}

// ---- chunk_key_generation ----

#[test]
fn chunk_key_generation_extracts_the_sha() {
let envelope = make_envelope_json(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1));
let entries = prepare_fastly_config_entries("app_config", &envelope).unwrap();
let (chunk_key, _) = &entries[0];
let sha = chunk_key_generation("app_config", chunk_key).expect("a chunk key");
assert_eq!(sha.len(), 64, "sha256 hex");
assert!(chunk_key.contains(&sha));
}

#[test]
fn chunk_key_generation_rejects_foreign_and_malformed_keys() {
// Different root.
assert!(chunk_key_generation("app_config", "other.__edgezero_chunks.abc123.0").is_none());
// The root key itself.
assert!(chunk_key_generation("app_config", "app_config").is_none());
// Non-hex content address.
assert!(
chunk_key_generation("app_config", "app_config.__edgezero_chunks.zzzz.0").is_none()
);
// Non-numeric index.
assert!(
chunk_key_generation("app_config", "app_config.__edgezero_chunks.abc123.x").is_none()
);
// Missing index.
assert!(
chunk_key_generation("app_config", "app_config.__edgezero_chunks.abc123").is_none()
);
}

// Regression for the Value-first rule: a value that IS pointer-kind but
// is missing required fields (`chunks`, `data_sha256`, …) must WARN, not
// be silently dropped by a failed struct deserialize.
#[test]
fn prior_chunk_keys_warns_on_pointer_kind_with_missing_fields() {
let raw = r#"{"edgezero_kind":"fastly_config_chunks","version":2}"#;
let err = prior_chunk_keys("app_config", raw)
.expect_err("pointer-kind but malformed must warn, not Ok([])");
assert!(!err.is_empty(), "{err}");
}
}
Loading