You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[auction] allowed_context_keys is a HashSet<String>. Serde writes a HashSet as a JSON array in the set's iteration order. Rust's default RandomState gives every newly built set its own hash keys, so the same configuration can serialize in a different order each time it is parsed. With n keys there are up to n! orders. Two hashes are computed over that array:
template_fingerprint in publisher.rs, which is part of every shared-template cache key. The Fastly adapter parses Settings in a fresh Wasm instance for every request. With [creative_opportunities] assembly_mode = "esi" and two or more context keys, the fingerprint, and therefore the template cache key, changes from request to request. Each iteration order gets its own template, so the cache fills with duplicates and hits drop. With six keys I measured no hits at all.
The EdgeZero envelope sha256 that ts config push and ts config diff compare. Each CLI run parses the TOML again, so an unchanged TOML can produce a different sha. Whenever the order differs from the stored one, diff reports a change and push rewrites the store.
Who is affected:
Fastly deployments that opt into ESI assembly (assembly_mode = "esi", default inline) and list two or more context keys.
Operators running ts config push or ts config diff with two or more context keys, on any adapter. Both commands share the EdgeZero CLI code that compares the sha.
Add this test inside mod template_fingerprint_tests in crates/trusted-server-core/src/publisher.rs:
#[test]fna_context_key_allowlist_fingerprints_identically_across_parses(){// Fastly parses `Settings` once per request, so every request builds its// own `allowed_context_keys` set with fresh hasher keys.let toml = format!("{}\n[auction]\nallowed_context_keys = [\"alpha\", \"bravo\", \"charlie\", \\"delta\", \"echo\", \"foxtrot\"]\n",crate::test_support::tests::crate_test_settings_str());let fingerprints = (0..32).map(|_| {let settings = Settings::from_toml(&toml).expect("should parse settings");template_fingerprint(&settings)}).collect::<std::collections::BTreeSet<_>>();assert_eq!(
fingerprints.len(),1,"should fingerprint one configuration identically, got {} distinct values",
fingerprints.len());}
Add this test inside mod template_cache_end_to_end_tests in the same file, next to one_integration_configuration_keys_one_template:
#[tokio::test]asyncfnone_context_key_allowlist_keys_one_template(){let stub = Arc::new(StubHttpClient::new());let cache = Arc::new(MemoryTemplateCache::default());let services = services(Arc::clone(&stub),Arc::clone(&cache));for _ in0..8{queue_shareable_html(&stub);}for _ in0..8{// One `Settings` per request, as on Fastly.letmut settings = settings_with_mode("esi");
settings.auction.allowed_context_keys =
["alpha","bravo","charlie","delta","echo","foxtrot"].into_iter().map(str::to_string).collect();let _ = run(&Arc::new(settings),&services,navigation_request()).await;}let distinct_keys = looked_up_cache_keys(&cache).into_iter().collect::<std::collections::BTreeSet<_>>();assert_eq!(
distinct_keys.len(),1,"should name one key for one configuration, got {} distinct keys",
distinct_keys.len());assert_eq!(
stub.recorded_request_uris().len(),1,"should serve every later request from the first template");}
Add this test at the end of mod tests in crates/trusted-server-core/src/config.rs:
#[test]fnpushed_envelope_sha_is_stable_for_one_context_key_allowlist(){// `ts config push` and `ts config diff` compare this sha to decide whether// anything changed.let toml = format!("{}\n[auction]\nallowed_context_keys = [\"alpha\", \"bravo\", \"charlie\", \\"delta\", \"echo\", \"foxtrot\"]\n",
crate_test_settings_str());let shas = (0..16).map(|_| {let app_config:TrustedServerAppConfig =
toml::from_str(&toml).expect("should deserialize app config wrapper");let data =
serde_json::to_value(&app_config).expect("should serialize app config");
edgezero_core::blob_envelope::BlobEnvelope::new(data,String::new()).sha256}).collect::<HashSet<_>>();assert_eq!(
shas.len(),1,"should build one envelope sha for an unchanged TOML, got {} distinct values",
shas.len());}
Run them one at a time. On wasm32-wasip1 the test binary aborts at the first failing test.
assertion `left == right` failed: should fingerprint one configuration identically, got 32 distinct values
left: 32
right: 1
assertion `left == right` failed: should name one key for one configuration, got 8 distinct keys
left: 8
right: 1
assertion `left == right` failed: should build one envelope sha for an unchanged TOML, got 16 distinct values
left: 16
right: 1
2. ts config diff reports changes for an unchanged TOML
From the repository root, after cargo build -p trusted-server-cli --target aarch64-apple-darwin (use your host triple). The manifests are copied to a temporary directory so the tracked fastly.toml is not edited.
With allowed_context_keys = [] the same steps report # no changes (sha256 matches: ...) and exit 0 (10 of 10 runs in my check).
3. Measured effect (supporting data)
Template cache, end to end under Viceroy, which runs each request in a fresh instance like Fastly. I used an adaptation of scripts/template-cache-local-test.sh in esi mode: the same stub origin, generated config and isolated config-store seeding, with allowed_context_keys as the only config change. It sends identical navigations to /article and reads x-ts-template-cache.
allowed_context_keys
Requests
x-ts-template-cache
Origin GETs
[]
20
1 miss-stored, 19 hit
1
2 keys
30
2 miss-stored, 28 hit
2
3 keys
30
6 miss-stored, 24 hit
6
6 keys
20
20 miss-stored
20
Distinct values, from a scratch crate that depends on this repository's trusted-server-core. For the fingerprint, each wasmtime instance loads one fixed envelope through settings_from_config_blob (the Fastly loader) and computes a verbatim copy of template_fingerprint. For the envelope, each process serializes the TOML through TrustedServerAppConfig, as the CLI does.
Context keys
Distinct fingerprints in 100 instances
Distinct envelope sha256 in 40 processes
0
1
1
1
1
1
2
2
2
3
6
6
6
92
39
With the real CLI and an unchanged TOML, ts config diff --exit-code reported a change in 10 of 10 runs with 6 keys, 9 of 10 with 3 keys and 8 of 10 with 2 keys. Repeated ts config push --yes rewrote the store in 5 of 5 runs with 6 keys.
Expected behavior
One configuration produces one template fingerprint, one template cache key per URL variant, and one envelope sha, whatever the process or instance. A second eligible request for the same URL is a template cache hit. ts config diff --exit-code on an unchanged TOML exits 0, and a repeated ts config push reports no changes.
Actual behavior
With n context keys, each value takes up to n! forms (one per iteration order). Every order needs its own cold template fill, ts config diff reports a change to auction.allowed_context_keys on most runs, and ts config push --yes rewrites the config store.
Root cause
The field is a hash set (crates/trusted-server-core/src/auction_config_types.rs:78-79):
template_fingerprint hashes the serialized settings (crates/trusted-server-core/src/publisher.rs:2080-2084). Its comment covers maps only. serde_json::Value sorts object keys when preserve_order is off, but it keeps array order as written:
// `serde_json::Value` uses a sorted object map without `preserve_order`, making// independently deserialized HashMaps canonical before they are serialized again.let canonical = serde_json::to_value(settings).and_then(|value| serde_json::to_vec(&value)).expect("serializing typed settings should be infallible");
The fingerprint goes into the key at publisher.rs:4616 (template_fingerprint: template_fingerprint(settings),) and TemplateCacheKey::to_cache_key hashes it (platform/template_cache.rs:107).
On Fastly every request builds new settings. main handles one request (crates/trusted-server-adapter-fastly/src/main.rs:78-79, let req = FastlyRequest::from_client();), and AppState is documented as "effectively per-request" (app.rs:176-177).
The envelope sha uses EdgeZero's canonical form (edgezero-core v0.0.8, canonical_form.rs:55-70), which sorts object keys and writes arrays in order. The CLI compares that sha to decide whether anything changed (edgezero-cli v0.0.8, config.rs:685 for diff and config.rs:1035 for push: if remote_envelope.sha256 == local_sha {).
The existing stability tests miss it because their fixtures have no context keys: the_fingerprint_is_stable_across_calls_for_one_configuration (publisher.rs:8883) and one_integration_configuration_keys_one_template (publisher.rs:11696).
Other hash-typed fields reachable from Settings
I checked every HashSet and HashMap field in the types Settings serializes (settings.rs, creative_opportunities.rs, auction_config_types.rs, auction/plan.rs, consent_config.rs). Only allowed_context_keys serializes as an array.
Objects are stable in the fingerprint because the Fastly adapter graph does not enable serde_json/preserve_order. I confirmed this with cargo tree -p trusted-server-adapter-fastly --target wasm32-wasip1 -e features -i serde_json.
The CLI graph does enable preserve_order (through handlebars v6.4.2 from edgezero-cli), so object key order in the stored envelope bytes can vary. The sha sorts object keys, so push and diff are unaffected. The control run above, with the example config and its several [integrations.*] tables, reported no changes in 10 of 10 runs.
Integration configs reach Settings only as raw JSON, so typed integration structs do not affect either hash. The only integration config with hash maps, LegacyPrebidServerConfig (integrations/prebid.rs:336-338), is #[cfg(test)]. DataDome's ProtectionScope hash sets (integrations/datadome/protection_scope.rs:146, 155) belong to a compiled runtime struct that is never serialized.
vec_from_seq_or_map (settings.rs:3610-3630), which accepts env-overlay index maps for Vec fields, sorts by index and is deterministic.
Impact
Default deployments are not affected by the cache problem:
assembly_mode defaults to inline (creative_opportunities.rs:209-220). The example config calls esi an "opt-in Fastly Core Cache experiment" and ships allowed_context_keys = [] (trusted-server.example.toml:284).
Only the Fastly adapter has a template cache. The other adapters use UnavailableTemplateCache (platform/template_cache.rs:736-745).
When ESI is enabled with two or more keys, the damage grows with the number of keys:
Each URL variant needs up to n! cold fills instead of one: 2 for 2 keys, 6 for 3, 24 for 4, 120 for 5 and 720 for 6. With 2 or 3 keys the cost is a few extra misses per URL per template lifetime. From about 4 keys up it dominates.
Templates live at most 60 seconds by default (creative_opportunities.rs:19). A URL whose eligible requests within one lifetime are well below n! almost never hits.
Every miss fetches the origin, runs the full transform, writes a Core Cache entry, and buffers the response instead of streaming it (publisher.rs:2406-2413). Inline mode streams, so each miss is slower than the same request in inline mode. With 6 keys every request in my run paid that cost.
Only requests already eligible for a shared template are affected: GET, no disqualifying Authorization or cookies (publisher.rs:4557-4563), and an origin response that grants shared freshness (publisher.rs:6253-6335).
For the CLI:
ts config diff --exit-code reports a change on most runs of an unchanged TOML, so a CI gate built on it is unreliable for such a config.
ts config push --yes writes the store again whenever the order differs (5 of 5 runs with 6 keys).
For envelopes over 8,000 characters, each rewrite creates a new generation of content-addressed chunk entries (edgezero-adapter-fastly v0.0.8, chunked_config.rs:229, 251). The old chunks stay until ts config gc runs.
Issue #1192, implemented by PR #1193, caches template_fingerprint once per warm instance. PR #1179 adds opt-in reusable sandboxes. Both changes make the value stable within one instance, but each instance still builds its own set. They reduce the number of distinct keys but do not remove the bug.
Proposed fix
Make the set ordered. Change allowed_context_keys to BTreeSet<String>, as NotificationConfig::suppress_seats already is (auction/plan.rs:232). Serde then writes it sorted. The change touches:
auction_config_types.rs:5, 79, 93 and 156-158 (the import, the field, Default and the default function).
The only runtime consumer, settings.auction.allowed_context_keys.contains(key) (auction/formats.rs:236), which works unchanged.
Three test constructors: settings.rs:6244, auction/formats.rs:1060 and auction/orchestrator.rs:4704. After the change, use std::collections::HashSet; in the settings test module (settings.rs:3685) becomes unused.
I applied exactly this in a scratch copy of a4e01eb. The three tests above passed, along with the existing context-key tests and all of template_fingerprint_tests: 12 passed, 0 failed.
Also correct the comment at publisher.rs:2080-2081 so it no longer implies that all collections are canonical.
To catch the next set-typed field, add a determinism test. It should parse a config that gives every map- and set-typed field two or more entries, several times, and assert identical serde_json::to_vec(&serde_json::to_value(&settings)) bytes and an identical envelope sha.
Compatibility:
The wire format stays a JSON array of strings, and older binaries accept a sorted array.
After the upgrade, the first ts config push may write once more (to store the sorted order). Later pushes report no changes.
The first deploy changes each template fingerprint once, which costs one cold fill per URL variant, the same as any bundle change today.
No config migration is needed.
Alternative considered: sort arrays inside template_fingerprint or in the canonical form. I rejected this. Other arrays are ordered (handlers, slots, cookie lists), and it would not fix the envelope sha that push and diff compare.
Done when
auction.allowed_context_keys serializes in a deterministic order (BTreeSet<String> or a sorted Vec).
a_context_key_allowlist_fingerprints_identically_across_parses, one_context_key_allowlist_keys_one_template and pushed_envelope_sha_is_stable_for_one_context_key_allowlist are added and pass.
A determinism test covers every set- and map-typed field reachable from Settings, each with two or more entries.
Repeated ts config diff --exit-code on an unchanged TOML with six context keys exits 0.
The comment at publisher.rs:2080-2081 is corrected.
Description
[auction] allowed_context_keysis aHashSet<String>. Serde writes aHashSetas a JSON array in the set's iteration order. Rust's defaultRandomStategives every newly built set its own hash keys, so the same configuration can serialize in a different order each time it is parsed. With n keys there are up to n! orders. Two hashes are computed over that array:template_fingerprintinpublisher.rs, which is part of every shared-template cache key. The Fastly adapter parsesSettingsin a fresh Wasm instance for every request. With[creative_opportunities] assembly_mode = "esi"and two or more context keys, the fingerprint, and therefore the template cache key, changes from request to request. Each iteration order gets its own template, so the cache fills with duplicates and hits drop. With six keys I measured no hits at all.sha256thatts config pushandts config diffcompare. Each CLI run parses the TOML again, so an unchanged TOML can produce a different sha. Whenever the order differs from the stored one,diffreports a change andpushrewrites the store.Who is affected:
assembly_mode = "esi", defaultinline) and list two or more context keys.ts config pushorts config diffwith two or more context keys, on any adapter. Both commands share the EdgeZero CLI code that compares the sha.Since when:
HashSetsince send permutive segments to adserver #263 (7f342ea).ts config pushandts config diffwere added by Add ts cli #799 (6800560).I verified the behavior only at a4e01eb.
Steps to reproduce
1. Failing tests
Add this test inside
mod template_fingerprint_testsincrates/trusted-server-core/src/publisher.rs:Add this test inside
mod template_cache_end_to_end_testsin the same file, next toone_integration_configuration_keys_one_template:Add this test at the end of
mod testsincrates/trusted-server-core/src/config.rs:Run them one at a time. On
wasm32-wasip1the test binary aborts at the first failing test.Observed at a4e01eb:
2.
ts config diffreports changes for an unchanged TOMLFrom the repository root, after
cargo build -p trusted-server-cli --target aarch64-apple-darwin(use your host triple). The manifests are copied to a temporary directory so the trackedfastly.tomlis not edited.Observed (first run shown in full, the other two abbreviated):
With
allowed_context_keys = []the same steps report# no changes (sha256 matches: ...)and exit 0 (10 of 10 runs in my check).3. Measured effect (supporting data)
Template cache, end to end under Viceroy, which runs each request in a fresh instance like Fastly. I used an adaptation of
scripts/template-cache-local-test.shinesimode: the same stub origin, generated config and isolated config-store seeding, withallowed_context_keysas the only config change. It sends identical navigations to/articleand readsx-ts-template-cache.allowed_context_keysx-ts-template-cache[]miss-stored, 19hitmiss-stored, 28hitmiss-stored, 24hitmiss-storedDistinct values, from a scratch crate that depends on this repository's
trusted-server-core. For the fingerprint, each wasmtime instance loads one fixed envelope throughsettings_from_config_blob(the Fastly loader) and computes a verbatim copy oftemplate_fingerprint. For the envelope, each process serializes the TOML throughTrustedServerAppConfig, as the CLI does.With the real CLI and an unchanged TOML,
ts config diff --exit-codereported a change in 10 of 10 runs with 6 keys, 9 of 10 with 3 keys and 8 of 10 with 2 keys. Repeatedts config push --yesrewrote the store in 5 of 5 runs with 6 keys.Expected behavior
One configuration produces one template fingerprint, one template cache key per URL variant, and one envelope sha, whatever the process or instance. A second eligible request for the same URL is a template cache hit.
ts config diff --exit-codeon an unchanged TOML exits 0, and a repeatedts config pushreportsno changes.Actual behavior
With n context keys, each value takes up to n! forms (one per iteration order). Every order needs its own cold template fill,
ts config diffreports a change toauction.allowed_context_keyson most runs, andts config push --yesrewrites the config store.Root cause
The field is a hash set (
crates/trusted-server-core/src/auction_config_types.rs:78-79):template_fingerprinthashes the serialized settings (crates/trusted-server-core/src/publisher.rs:2080-2084). Its comment covers maps only.serde_json::Valuesorts object keys whenpreserve_orderis off, but it keeps array order as written:The fingerprint goes into the key at
publisher.rs:4616(template_fingerprint: template_fingerprint(settings),) andTemplateCacheKey::to_cache_keyhashes it (platform/template_cache.rs:107).On Fastly every request builds new settings.
mainhandles one request (crates/trusted-server-adapter-fastly/src/main.rs:78-79,let req = FastlyRequest::from_client();), andAppStateis documented as "effectively per-request" (app.rs:176-177).The envelope sha uses EdgeZero's canonical form (edgezero-core v0.0.8,
canonical_form.rs:55-70), which sorts object keys and writes arrays in order. The CLI compares that sha to decide whether anything changed (edgezero-cli v0.0.8,config.rs:685fordiffandconfig.rs:1035forpush:if remote_envelope.sha256 == local_sha {).The existing stability tests miss it because their fixtures have no context keys:
the_fingerprint_is_stable_across_calls_for_one_configuration(publisher.rs:8883) andone_integration_configuration_keys_one_template(publisher.rs:11696).Other hash-typed fields reachable from
SettingsI checked every
HashSetandHashMapfield in the typesSettingsserializes (settings.rs,creative_opportunities.rs,auction_config_types.rs,auction/plan.rs,consent_config.rs). Onlyallowed_context_keysserializes as an array.auction.allowed_context_keys(auction_config_types.rs:79)HashSet<String>integrations(settings.rs:218-222, flattened)HashMap<String, JsonValue>response_headers(settings.rs:2869)HashMap<String, String>image_optimizer.profile_sets(settings.rs:1021)HashMap<String, ImageOptimizerProfileSet>image_optimizer.profile_sets.*.profiles(settings.rs:1076)HashMap<String, String>creative_opportunities.slot[].targeting(creative_opportunities.rs:603)HashMap<String, String>creative_opportunities.slot[].providers.prebid.bidders(creative_opportunities.rs:1017)HashMap<String, Value>template_cache_key_cookies,template_cache_bypass_cookies(creative_opportunities.rs:344, 350), andtemplate_cache_vary(creative_opportunities.rs:327)Option<Vec<String>>auction.providers.*.notifications.suppress_seats(auction/plan.rs:232)BTreeSet<String>Notes on the table:
serde_json/preserve_order. I confirmed this withcargo tree -p trusted-server-adapter-fastly --target wasm32-wasip1 -e features -i serde_json.preserve_order(throughhandlebars v6.4.2fromedgezero-cli), so object key order in the stored envelope bytes can vary. The sha sorts object keys, sopushanddiffare unaffected. The control run above, with the example config and its several[integrations.*]tables, reported no changes in 10 of 10 runs.Settingsonly as raw JSON, so typed integration structs do not affect either hash. The only integration config with hash maps,LegacyPrebidServerConfig(integrations/prebid.rs:336-338), is#[cfg(test)]. DataDome'sProtectionScopehash sets (integrations/datadome/protection_scope.rs:146, 155) belong to a compiled runtime struct that is never serialized.vec_from_seq_or_map(settings.rs:3610-3630), which accepts env-overlay index maps forVecfields, sorts by index and is deterministic.Impact
Default deployments are not affected by the cache problem:
assembly_modedefaults toinline(creative_opportunities.rs:209-220). The example config callsesian "opt-in Fastly Core Cache experiment" and shipsallowed_context_keys = [](trusted-server.example.toml:284).UnavailableTemplateCache(platform/template_cache.rs:736-745).When ESI is enabled with two or more keys, the damage grows with the number of keys:
creative_opportunities.rs:19). A URL whose eligible requests within one lifetime are well below n! almost never hits.publisher.rs:2406-2413). Inline mode streams, so each miss is slower than the same request in inline mode. With 6 keys every request in my run paid that cost.Authorizationor cookies (publisher.rs:4557-4563), and an origin response that grants shared freshness (publisher.rs:6253-6335).For the CLI:
ts config diff --exit-codereports a change on most runs of an unchanged TOML, so a CI gate built on it is unreliable for such a config.ts config push --yeswrites the store again whenever the order differs (5 of 5 runs with 6 keys).chunked_config.rs:229, 251). The old chunks stay untilts config gcruns.Issue #1192, implemented by PR #1193, caches
template_fingerprintonce per warm instance. PR #1179 adds opt-in reusable sandboxes. Both changes make the value stable within one instance, but each instance still builds its own set. They reduce the number of distinct keys but do not remove the bug.Proposed fix
Make the set ordered. Change
allowed_context_keystoBTreeSet<String>, asNotificationConfig::suppress_seatsalready is (auction/plan.rs:232). Serde then writes it sorted. The change touches:auction_config_types.rs:5,79,93and156-158(the import, the field,Defaultand the default function).settings.auction.allowed_context_keys.contains(key)(auction/formats.rs:236), which works unchanged.settings.rs:6244,auction/formats.rs:1060andauction/orchestrator.rs:4704. After the change,use std::collections::HashSet;in the settings test module (settings.rs:3685) becomes unused.I applied exactly this in a scratch copy of a4e01eb. The three tests above passed, along with the existing context-key tests and all of
template_fingerprint_tests: 12 passed, 0 failed.Also correct the comment at
publisher.rs:2080-2081so it no longer implies that all collections are canonical.To catch the next set-typed field, add a determinism test. It should parse a config that gives every map- and set-typed field two or more entries, several times, and assert identical
serde_json::to_vec(&serde_json::to_value(&settings))bytes and an identical envelope sha.Compatibility:
ts config pushmay write once more (to store the sorted order). Later pushes reportno changes.Alternative considered: sort arrays inside
template_fingerprintor in the canonical form. I rejected this. Other arrays are ordered (handlers, slots, cookie lists), and it would not fix the envelope sha thatpushanddiffcompare.Done when
auction.allowed_context_keysserializes in a deterministic order (BTreeSet<String>or a sortedVec).a_context_key_allowlist_fingerprints_identically_across_parses,one_context_key_allowlist_keys_one_templateandpushed_envelope_sha_is_stable_for_one_context_key_allowlistare added and pass.Settings, each with two or more entries.ts config diff --exit-codeon an unchanged TOML with six context keys exits 0.publisher.rs:2080-2081is corrected.Affected area
Fastly runtime
Version
main at a4e01eb
Related
allowed_context_keysas aHashSet.template_fingerprintand the shared template cache.ts config pushandts config diff, which compare the envelope sha.Vecs and are deterministic.AppCache). Same limitation.