Skip to content

Separate shared async EC on the compatible demo baseline - #1534

Open
ChristianPavilonis wants to merge 18 commits into
baseline/http-auction-20261110from
baseline/shared-async-ec-20261110
Open

ChristianPavilonis wants to merge 18 commits into
baseline/http-auction-20261110from
baseline/shared-async-ec-20261110

Conversation

@ChristianPavilonis

@ChristianPavilonis ChristianPavilonis commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • Existing local-future EC trait and callers, retaining the managed Fastly post-send lifecycle.
  • Reorganize the existing working demo using EdgeZero83d7a629, not current-main forwarding compatibility.

Draft baseline review unit. Not a current-main merge candidate, deployment approval or completed current-head CI claim.

Baseline and sequence

Head: a8e61218fea4ccccfa77b8d0b932b38d1090dcbe. Base: baseline/http-auction-20261110.

Historical main anchor: 2dd968aeebeb2cc0d5110115f64e0ad58e2e1d13. Original demo: TS2423a2ed plus separately preserved dirty inputs. Matching EdgeZero: draft431, exact revision 83d7a6296d285e135c4ea70021b19e6c8dca7e9f.

The user selected compatible-baseline cleanup. Current-main integration with EdgeZero462 and the authenticated-forwarder incompatibility are deferred. Preserve current-main PR1525/1526 ready and1527/1528 draft, with their existing bases. No excluded1499 or independent1506 implementation, relay/privacy experiment, new transport fallback, group/codec/phase design or operational deployment is included.

Changes

Area Change
This unit Existing local-future EC trait and callers, retaining the managed Fastly post-send lifecycle.
Unchanged work Original checkouts, dirty Prebid inputs, recovery references and existing PRs remain preserved.

Closes

Refs #1530. This unit completes one part of the baseline extraction and does not close the overall tracker on its own.

Spec

Not a new feature. This is extraction of existing demo behavior and bounded build/caller corrections. Original native and EC storage design/review records remain in the baseline where applicable; they are not presented as approval of later combined source. No new forwarding, consent, timer or provider policy is designed here.

Test plan

Core3006, native all-feature suites and strict Core lint pass; Fastly WASM check passes. Fastly-only synchronous hooks bridge the original async operations; native and worker executors do not gain nested blocking executors. Current-main PR1526 remains untouched.

  • Required current-head hosted checks complete before ready-state transition.
  • Final baseline combined-check results recorded with exact input revisions and limitations.
  • Focused failures and corrections retained rather than assertions relaxed.

The b67ffc6 baseline checkpoint has passing documented local gates: Fastly/default/reuse/native/Cloudflare/Spin suites, all adapter strict lint lanes, 17 cross-adapter parity cases, 1252 JS cases, CLI suites, formatting, clock/digest/proposal checks and changed-lines domain lint. Passing checks at121ebcc36 are reused only for unchanged relevant inputs; the final follow-up covers the CLI test-import and documentation-only corrections. These results are not a substitute for this PR's current-head hosted checks.

Checklist

  • Existing behavior extracted into a scoped review unit.
  • Public examples use fictional inputs and no licensed Geo data.
  • No deployment, main merge, old-RC closure or recovery deletion.
  • No new worker launched or paused experiment resumed.
  • Review readiness confirmed independently from later units.

Operational Terraform remains in trusted-server-deployer. Local checks and prototype tests do not qualify cloud access, fleet durability or a live publisher.

Hosted docs-build correction

The first hosted format-docs job caught an additional site-build gate that the original local formatter checkpoint had not run: a relative link from the native EC guide to a Terraform example outside the site. Native EC correction8b11dbad3 replaces it with the plain repository path; no dead-link check is disabled. That docs-only correction is carried through the existing upper PR identities. Native EC and final integration8985b111 both pass docs ESLint and VitePress build; the final docs formatter also passes. Runtime, dependency, image-build-context and test inputs are unchanged from b67ffc6, so their prior exact-input proofs are reused. Hosted current-head checks still control readiness.

Startup negative-probe correction

A hosted native EC case failed the no-listener probe while its child logged the expected startup validation failure. The old log has no socket endpoints, so the failure's exact subtype is not claimed confirmed. A separate controlled Linux reproduction demonstrates a successful TCP self-connection with no listening server. Native correction8490c691d distinguishes equal local/peer endpoints from a real listener; actual socket controls prove the self-connection is excluded and a real listener is still detected. Nonzero exit, expected diagnostic, secret-redaction and the original startup deadline remain required. This changes only the acceptance fixture, not production admission, transport or trust.

The earliest native unit and final9356c557 default/all-feature acceptance checks and strict lint pass, including both controls and the original failure case. Ignores remain explicit; earlier separately executed celld proof is not relabeled as a new ignored-case run. Correction is merged upward through existing PR identities without force-pushing or retargeting. Current-head hosted checks must pass; the old failure is retained rather than rerun-only waived. Runtime/API/dependency inputs remain unchanged. A fresh exact9356 producer build and real runtime smoke now pass: image sha256:65a735d38e9dbc0573d0f6068a8b261bd202c43aea05085c2b2d6bccc91ebdbd, linux/amd64 UID/GID10001, including startup rejection and in-flight SIGTERM drain. Prior b67 image receipts remain separate; no image was pushed. All local parent runners are complete.

Browser CI host compatibility

Preserved older browser failures show Playwright1.58.2 rejecting ubuntu-latest's new Ubuntu26.04 image before browser installation; later missing-executable failures are consequences, not passing browser tests. Nativeb1216763a pins only the browser job to ubuntu-24.04, leaving dependency versions, tests and assertions unchanged. Direct Actionlint1.7.12 passes at the owning native and final integration points; the first shim-only attempt could not run because no global version was configured, and is not counted as lint proof. No global tooling was changed. The only9356 ->ee7 delta is this workflow host/comment; runtime, Cargo, docs, tests and Docker-context inputs are unchanged. Actual browser execution on the supported hosted runner remains pending and must pass before readiness. No waiver or browser skip.

@prk-Jr prk-Jr left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Summary

Converts EcKvStore and its core callers to local (?Send) async futures, keeping Fastly's blocking KV hostcalls behind a futures::executor::block_on bridge in the synchronous response hook and post-send pull sync. The conversion is mechanical: CAS/retry loops, withdrawal-marker handling, tombstone and finalize ordering are unchanged, and no lock or borrow is held across an await.

Checked and not a problem:

  • Nested executor: in the pinned edgezero revision the finalize hook runs only after block_on(app.dispatch_admitted(..)) has returned, and post-send pull sync after delivery, so the new block_on calls are not nested. The Fastly EC lifecycle integration job exercises this path under Viceroy.
  • Other adapters: FastlyEcKvStore is the only production implementation and all newly async callers are in core or the Fastly adapter, so Axum/Cloudflare/Spin gain no blocking bridge.
  • Pull sync concurrency: all partner requests are still dispatched before any are awaited.
  • Removed apply_withdrawal_tombstones test: coverage is retained end-to-end by finalize_withdrawal_tombstones_both_present_ids_once.

1 of the inline comments below carries a one-click GitHub suggestion.

Non-blocking

⛏ nitpick

  • Assertions without messages — see inline at crates/trusted-server-core/src/ec/pull_sync.rs:977

CI Status

All 17 checks PASS at a8e61218: CLAUDE.md symlink guard, browser integration tests, cargo check (cloudflare native + wasm32-unknown-unknown), cargo check/build/test (spin native + wasm32-wasip2), cargo fmt (includes all adapter clippy lanes), cargo test, cargo test (axum native), cargo test (cross-adapter parity), cargo test (ts CLI, native) (macos-latest), cargo test (ts CLI, native) (ubuntu-latest), docs-proposal scripts, format-docs, format-typescript, integration tests, integration tests (Fastly EC lifecycle), prepare integration artifacts, vitest.

Comment on lines +977 to +986
assert_eq!(stub.recorded_backend_names().len(), 1);
let (entry, _) = graph
.get(&ec_id)
.await
.expect("should read row")
.expect("row should exist");
assert_eq!(
entry.ids.get("alpha.example.com").map(|id| id.uid.as_str()),
Some("synced-uid"),
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⛏ nitpick: assertions without messages

Both assert_eq! calls in this new test lack a descriptive message, which AGENTS.md asks for in test code.

Suggested change
assert_eq!(stub.recorded_backend_names().len(), 1);
let (entry, _) = graph
.get(&ec_id)
.await
.expect("should read row")
.expect("row should exist");
assert_eq!(
entry.ids.get("alpha.example.com").map(|id| id.uid.as_str()),
Some("synced-uid"),
);
assert_eq!(
stub.recorded_backend_names().len(),
1,
"should dispatch one pull request to the single eligible partner"
);
let (entry, _) = graph
.get(&ec_id)
.await
.expect("should read row")
.expect("row should exist");
assert_eq!(
entry.ids.get("alpha.example.com").map(|id| id.uid.as_str()),
Some("synced-uid"),
"should persist the pulled partner UID"
);

@ChristianPavilonis
ChristianPavilonis marked this pull request as ready for review November 11, 2026 13:56
Retain the existing shared EC head, async local non-Send store futures, snapshot/withdrawal/CAS behavior and the cutover's bounded direct pull-sync groups. Preserve current body-read failures and response ownership without restoring pending/select or legacy HTTP interfaces.

Await EC work through handlers and use the existing Fastly synchronous hook as the production executor boundary. Migrate the current post-delivery fixture to the async graph and verify real Fastly runtime paths with the explicitly repaired local Viceroy.

@aram356 aram356 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Summary

Converts EcKvStore and its core and Fastly callers to local (?Send) async futures, and bridges the synchronous EdgeZero response hook and post-send pull sync with futures::executor::block_on. The core changes match #1526 at e529e5576 byte for byte. With async/.await stripped and both sides rustfmt-normalized, production kv.rs, identify.rs, admin.rs and the auction, publisher and registry callers are identical to the base. The remaining production changes are the inlined withdrawal loop, the two removed pull-sync block_on calls, the two bridges and the app.rs async blocks. In EdgeZero 83d7a629 the finalize hook runs after the dispatch block_on returns and pull sync runs after delivery, so no bridge nests, and the v0.0.12-rc-axum.202611.1 repin resolves to that same commit. The blocking items are a test gap (no unit test runs finalization or EC generation under the executor Fastly uses, and four nested-block_on mutations survive), integration jobs that did not run at this head, and a PR description that does not describe this diff.

7 of the 9 inline comments below carry a one-click GitHub suggestion. Use Commit suggestion, or Add suggestion to batch for several at once, to apply them as commits on the PR branch. The other 2 describe the fix in prose because it touches lines outside the diff hunks.

Each suggestion was applied alone to this head and passed rustfmt, and each new test passes on its own and fails under its target mutation. All of them applied together pass the six adapter clippy aliases, cargo test-fastly, test-fastly-reuse, test-cloudflare, test-spin and the parity suite. test-axum also passes except managed_ingress_ignores_direct_spoof_and_isolates_two_gateway_clients, which fails the same way at this head on macOS because it binds 127.0.0.2; it passes in CI on Linux, and no suggestion touches Axum.

Blocking

🔧 wrench

  • No test drives finalization under the executor Fastly uses: see inline at crates/trusted-server-core/src/ec/finalize.rs:681
  • EC generation has the same gap, on the router path: see inline at crates/trusted-server-core/src/ec/mod.rs:844

Non-blocking

♻️ refactor

  • State the cancellation contract on the trait, and drop "native backends": see inline at crates/trusted-server-core/src/ec/kv_backend.rs:75

🏕 camp site

  • The active-ID guard on the snapshot correction is untested: see inline at crates/trusted-server-core/src/ec/finalize.rs:492
  • dispatch_pull_sync still documents itself as background work: see inline at crates/trusted-server-core/src/ec/pull_sync.rs:116
  • generate_if_needed under-documents its errors: see inline at crates/trusted-server-core/src/ec/mod.rs:382
  • handle_batch_sync documents failures it does not return: see inline at crates/trusted-server-core/src/ec/batch_sync.rs:118
  • Record the EdgeZero ordering this bridge relies on: see inline at crates/trusted-server-adapter-fastly/src/main.rs:522

⛏ nitpick

  • The Fastly module doc prescribes behavior for other backends: see inline at crates/trusted-server-adapter-fastly/src/ec_kv.rs:7

Cross-cutting / body-level findings

  • 🔧 The integration jobs did not run at this head. prepare integration artifacts failed at its upload step: GitHub's artifact service returned an HTML page instead of JSON on all five attempts (Failed to CreateArtifact: ... Unexpected token '<'), so integration tests, integration tests (Fastly EC lifecycle) and browser integration tests were skipped. This is an infrastructure failure, not a code one, but the Fastly EC lifecycle job is the only check that drives finalization through the real block_on bridge (its ConsentWithdrawal scenario). Please re-run the workflow at b3ff6f795. No checks are configured as required on this base, so nothing blocks the merge mechanically.

  • 🔧 The PR description does not describe this diff.

    • It names head a8e61218; the head is b3ff6f795, and the EdgeZero pin is now the tag v0.0.12-rc-axum.202611.1 (the same commit, 83d7a629).
    • Numbers are glued to the preceding word throughout ("EdgeZero83d7a629", "TS2423a2ed", "PR1525/1526 ready and1527/1528", "Core3006", "only9356 ->ee7").
    • "Hosted docs-build correction", "Startup negative-probe correction" and "Browser CI host compatibility" describe base-branch commits; they touch none of the 14 files in this diff.
    • "The user selected compatible-baseline cleanup", "No new worker launched or paused experiment resumed" and "All local parent runners are complete" are working-session context a reviewer cannot see or act on.
    • The "Changes" table repeats the summary instead of listing the changes.

    Suggested replacement; keep only the checks you ran at the current head:

    ## Summary
    
    - Make `EcKvStore` a local (`?Send`) async trait and await it through `KvIdentityGraph` and every core and Fastly caller.
    - Bridge Fastly's two synchronous entry points with `futures::executor::block_on`: EC finalization in the EdgeZero response hook, and post-send pull sync. Pull sync no longer calls `block_on` itself.
    - No Fastly behavior change: `FastlyEcKvStore` still completes each operation with a blocking KV hostcall, and CAS, tombstone, withdrawal and finalize ordering are unchanged.
    
    Unit of the #1530 baseline stack, on top of #1533. The core change is the same as #1526.
    
    ## Changes
    
    | Area | Change |
    | --- | --- |
    | `ec/kv_backend.rs` | `EcKvStore` I/O methods are `async` under `#[async_trait(?Send)]`; the test stores follow. |
    | `ec/kv.rs`, `ec/mod.rs`, `ec/identify.rs`, `ec/admin.rs`, `ec/batch_sync.rs` | Graph operations and their callers are awaited. |
    | `ec/finalize.rs` | Finalization is async; the withdrawal closure helper becomes an inline loop and its unit test is removed. |
    | `ec/pull_sync.rs` | `dispatch_pull_sync` awaits the platform HTTP client instead of calling `block_on`. |
    | `auction/endpoints.rs`, `publisher.rs`, `integrations/registry.rs` | Snapshot loads and EC generation are awaited. |
    | `adapter-fastly/src/ec_kv.rs` | `FastlyEcKvStore` implements the async trait over the blocking SDK. |
    | `adapter-fastly/src/main.rs`, `app.rs` | `block_on` bridges for finalization and post-send pull sync; route handlers await. |
    
    ## Test plan
    
    - [ ] `cargo test-fastly`, `cargo test-fastly-reuse`, `cargo test-axum`, `cargo test-cloudflare`, `cargo test-spin` and the parity suite
    - [ ] The six adapter clippy aliases
    - [ ] New regressions: `graph_lookup_yields_to_other_work_with_a_non_send_store`, `dispatch_pull_sync_runs_inside_the_fastly_executor_boundary`
    - [ ] Hosted checks at the current head, including `integration tests (Fastly EC lifecycle)`
    
    Refs #1530.
  • 📌 The real post-send pull sync never runs end to end. The integration fixture (crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml) declares two [[ec.partners]], and neither sets pull_sync_enabled or pull_sync_url. The Fastly EC lifecycle job therefore exercises the finalize bridge but never the post-send pull-sync bridge with the real FastlyPlatformHttpClient, and the new regression test uses StubHttpClient. Please add a pull-enabled partner backed by a fixture endpoint, or open a follow-up issue and link it here.

  • 🌱 A native backend for this trait needs EdgeZero KV features that do not exist yet. The trait doc anticipates a backend that yields, but EdgeZero's KvStore at 83d7a629 (imported in core as PlatformKvStore) offers only get_bytes, exists, put_bytes, put_bytes_with_ttl, delete and list_keys_page. It has no generation token, no conditional (add or if-generation-match) write, no metadata and no strong-consistency listing, and EcKvStore needs all of these. A shared native backend therefore depends on EdgeZero asks rather than Trusted Server plumbing.

CI Status

  • prepare integration artifacts: FAIL
  • integration tests: SKIPPED
  • integration tests (Fastly EC lifecycle): SKIPPED
  • browser integration tests: SKIPPED
  • cargo fmt: PASS
  • cargo test: PASS
  • cargo test (axum native): PASS
  • cargo test (cross-adapter parity): PASS
  • cargo check (cloudflare native + wasm32-unknown-unknown): PASS
  • cargo check/build/test (spin native + wasm32-wasip2): PASS
  • cargo test (ts CLI, native) (macos-latest): PASS
  • cargo test (ts CLI, native) (ubuntu-latest): PASS
  • CLAUDE.md symlink guard: PASS
  • docs-proposal scripts: PASS
  • format-docs: PASS
  • format-typescript: PASS
  • vitest: PASS
  • ts dev lint domains (changed lines): not run on this base; the added lines name only example.com hosts

@@ -687,23 +679,6 @@ mod tests {
assert!(ids.contains(&valid_ec), "should keep the valid EC ID");
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔧 wrench: No test drives finalization under the executor Fastly uses

This PR moves apply_edgezero_ec_finalize into futures::executor::block_on inside the EdgeZero response hook (crates/trusted-server-adapter-fastly/src/main.rs:523). Every ec_finalize_response test runs under #[tokio::test(flavor = "current_thread")], and tokio tolerates a nested futures block_on. A block_on reintroduced anywhere under finalization would pass the unit suites and panic in production with EnterError.

Reproduced at this head. Each of these mutations passes all 3006 core lib tests:

  • line 480: futures::executor::block_on(graph.tombstone_existing_from_snapshot(ec_id, initial)) (withdrawal)
  • line 314: futures::executor::block_on(graph.create_if_absent(&ec_id, &entry)) (orphan recovery)
  • line 377: futures::executor::block_on(graph.key_exists_confirmed(ec_id)) (orphan confirmation)

The ConsentWithdrawal scenario in integration tests (Fastly EC lifecycle) would catch the first one through the real binary, but that job did not run at this head, and no lifecycle scenario reaches orphan recovery. dispatch_pull_sync_runs_inside_the_fastly_executor_boundary already guards pull sync this way. The suggestion adds the same guard for finalization where the removed apply_withdrawal_tombstones_invokes_writer_for_each_ec_id sat. It passes at this head, and under each mutation above it is the only failing test (cannot execute LocalPool executor from within another executor: EnterError).

Suggested change
#[test]
fn finalize_runs_inside_the_fastly_executor_boundary() {
// Fastly's entry point drives finalization with
// `futures::executor::block_on`, which panics if an awaited step
// nests a second blocking executor.
futures::executor::block_on(async {
let settings = create_test_settings();
let withdrawn = sample_ec_id("fxwd01");
let consent = ConsentContext {
jurisdiction: Jurisdiction::UsState("CA".to_owned()),
gpc: true,
source: ConsentSource::Cookie,
..Default::default()
};
let mut ec_context =
make_context_with_consent(Some(&withdrawn), Some(&withdrawn), true, false, consent);
let graph = KvIdentityGraph::in_memory("test_store");
graph
.create(&withdrawn, &live_entry())
.await
.expect("should seed the withdrawn identity");
ec_context.set_kv_snapshot(graph.load_snapshot(&withdrawn).await);
let mut response = empty_response();
ec_finalize_response(
&settings,
&mut ec_context,
Some(&graph),
&PartnerRegistry::empty(),
None,
None,
&mut response,
)
.await;
let (entry, _) = graph
.get(&withdrawn)
.await
.expect("should read the withdrawn row")
.expect("should keep the withdrawn row");
assert!(
!entry.consent.ok,
"withdrawal should tombstone the identity under the Fastly executor"
);
let orphan = sample_ec_id("fxor01");
let mut ec_context = returning_user_context(
&orphan,
EcKvSnapshot::Missing {
ec_id: orphan.clone(),
},
true,
);
let mut response = empty_response();
ec_finalize_response(
&settings,
&mut ec_context,
Some(&graph),
&PartnerRegistry::empty(),
None,
None,
&mut response,
)
.await;
assert_ne!(
ec_context.ec_value(),
Some(orphan.as_str()),
"orphan recovery should rotate the identity under the Fastly executor"
);
});
}


#[test]
fn generate_if_needed_retries_id_collision_then_persists() {
#[tokio::test(flavor = "current_thread")]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔧 wrench: EC generation has the same gap, on the router path

EdgeZero drives the router with futures::executor::block_on(app.dispatch_admitted(..)) (edgezero-adapter-fastly request.rs:420 at 83d7a629), so generate_if_needed runs inside that executor for every new visitor. With futures::executor::block_on(graph.create_if_absent(&ec_id, &entry)) at line 423, all 3006 core lib tests and all 222 Fastly adapter tests still pass. The suggested test drives one collision and the retry under the same executor. It passes at this head and is the only test that fails under that mutation.

Suggested change
#[tokio::test(flavor = "current_thread")]
#[test]
fn generate_if_needed_runs_inside_the_fastly_executor_boundary() {
// Fastly's router runs under `futures::executor::block_on`, which
// panics if an awaited step nests a second blocking executor.
futures::executor::block_on(async {
let settings = create_test_settings();
let mut ec = EcContext::new_for_test_with_ip(
None,
granting_consent(),
Some("192.0.2.7".to_owned()),
);
let graph = KvIdentityGraph::new(AddCollidingEcKv::new(1));
ec.generate_if_needed(&settings, Some(&graph))
.await
.expect("should generate inside the Fastly executor");
assert!(
matches!(ec.kv_snapshot(), EcKvSnapshot::Present { .. }),
"should persist the generated EC ID under the Fastly executor"
);
});
}
#[tokio::test(flavor = "current_thread")]

Comment on lines +74 to +75
/// I/O returns local futures so native backends can yield without blocking the
/// request runtime. Neither stores nor their futures need to be `Send` or `Sync`.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

♻️ refactor: State the cancellation contract on the trait, and drop "native backends"

No native backend exists in this tree, so the doc should describe the capability rather than a backend type. More importantly, graph operations now have await points between dependent writes: generate_if_needed commits create_if_absent before it sets ec_value (crates/trusted-server-core/src/ec/mod.rs:423 and :451), and the withdrawal loop can tombstone one ID and not the other (crates/trusted-server-core/src/ec/finalize.rs:469). Nothing drops these futures today, since EdgeZero runs the handler and both Fastly bridges run to completion, so this documents the contract for the next backend author and caller.

Suggested change
/// I/O returns local futures so native backends can yield without blocking the
/// request runtime. Neither stores nor their futures need to be `Send` or `Sync`.
/// I/O returns local futures so a backend with real async I/O can yield without
/// blocking the request runtime. Neither stores nor their futures need to be
/// `Send` or `Sync`. Dropping a returned future does not roll back a write that
/// may already have committed; callers must not infer either outcome without
/// the returned result.

@@ -491,7 +492,7 @@ fn finalize_unusable_consent(
if ec_context.ec_value() == Some(ec_id) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🏕 camp site: The active-ID guard on the snapshot correction is untested

if ec_context.ec_value() == Some(ec_id) keeps the context snapshot on the active identity when the withdrawal cookie names a different one. Replacing the guard with if true passes all 3006 core lib tests at this head: no test withdraws two present IDs and then checks which one the context snapshot belongs to. The closure-to-loop change here is a good moment to pin it. Withdrawal IDs come from a HashSet, so the test repeats with fresh sets to cover both visit orders:

    #[tokio::test(flavor = "current_thread")]
    async fn finalize_withdrawal_keeps_the_active_snapshot_when_the_cookie_id_differs() {
        // Withdrawal IDs come from a HashSet, so visit order varies between
        // runs. Repeat with fresh sets so a snapshot overwritten by the
        // non-active cookie ID's outcome cannot pass by luck.
        for round in 0..16 {
            let settings = create_test_settings();
            let active_ec = sample_ec_id("activ9");
            let cookie_ec = sample_ec_id("cook9e");
            let consent = ConsentContext {
                jurisdiction: Jurisdiction::UsState("CA".to_owned()),
                gpc: true,
                source: ConsentSource::Cookie,
                ..Default::default()
            };
            let mut ec_context =
                make_context_with_consent(Some(&active_ec), Some(&cookie_ec), true, false, consent);
            let graph = KvIdentityGraph::in_memory("test_store");
            graph
                .create(&active_ec, &live_entry())
                .await
                .expect("should seed active row");
            graph
                .create(&cookie_ec, &live_entry())
                .await
                .expect("should seed cookie row");
            ec_context.set_kv_snapshot(graph.load_snapshot(&active_ec).await);
            let mut response = empty_response();

            ec_finalize_response(
                &settings,
                &mut ec_context,
                Some(&graph),
                &PartnerRegistry::empty(),
                None,
                None,
                &mut response,
            )
            .await;

            assert!(
                ec_context.kv_snapshot().belongs_to(&active_ec),
                "round {round}: the context snapshot should stay on the active ID"
            );
            assert!(
                ec_context
                    .kv_snapshot()
                    .entry_for(&active_ec)
                    .is_some_and(|entry| !entry.consent.ok),
                "round {round}: the context snapshot should hold the active ID's tombstone"
            );
        }
    }

It passes at this head and fails with the guard removed. Apply manually: it adds a test outside the diff hunks, for example just before finalize_withdrawal_tombstones_both_present_ids_once.

/// Panics if the HTTP request builder produces an invalid request, which
/// cannot happen with the hardcoded method and well-formed URI used here.
pub fn dispatch_pull_sync(
pub async fn dispatch_pull_sync(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🏕 camp site: dispatch_pull_sync still documents itself as background work

The function doc (line 96, "Dispatches partner pull-sync requests in the background.") and the module doc (line 1, "Pull sync background dispatch.") predate this PR, but on an async fn they read as spawned or fire-and-forget work. Nothing is spawned: the returned future does the live-row read, the sends, the waits and the CAS merge, and does none of it unless the caller drives it, which crates/trusted-server-adapter-fastly/src/main.rs:551 does after delivery.

Proposed fix (apply manually; lines 1 and 96 are outside the diff hunks, so this cannot be a suggestion):

//! Pull sync dispatch, run after the client response is sent.
/// Dispatches partner pull-sync requests and merges the returned UIDs.
///
/// Run this after the client response is sent. Nothing is spawned: the caller
/// must drive the returned future to completion.

In crates/trusted-server-core/src/ec/mod.rs:30, change Background pull-sync dispatcher to Post-send pull-sync dispatcher.

Comment on lines 381 to 382
/// Returns an error if the client IP is unavailable and generation is
/// needed, or if HMAC generation fails.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🏕 camp site: generate_if_needed under-documents its errors

The # Errors section predates this PR, but the signature changes here. It omits two Err paths: identity-graph persistence failure (line 438) and collision exhaustion after MAX_CREATE_ATTEMPTS (line 457).

Suggested change
/// Returns an error if the client IP is unavailable and generation is
/// needed, or if HMAC generation fails.
/// Returns an error if the client IP is unavailable and generation is
/// needed, if HMAC generation fails, if persisting the new ID to the
/// identity graph fails, or if five generated IDs in a row collide.

@@ -114,16 +116,16 @@ struct MappingGroup {
/// # Errors
///
/// Returns [`TrustedServerError`] on serialization or KV store failures.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🏕 camp site: handle_batch_sync documents failures it does not return

KV write failures never return Err from this handler: process_mappings turns them into per-mapping kv_unavailable rejections in a 207 response. The real error paths are the rate limiter (the ? at line 141), a body the adapter did not buffer (line 156), invalid JSON (line 168) and response serialization (line 194). This predates the PR, but the signature changes here.

Suggested change
/// Returns [`TrustedServerError`] on serialization or KV store failures.
/// Returns [`TrustedServerError`] when the rate limiter fails, the adapter did
/// not buffer the body or it is not valid JSON, or the response cannot be
/// serialized. Identity-graph write failures do not return an error: they
/// reject the affected mappings as `kv_unavailable` in a `207 Multi-Status`
/// response.

Comment on lines +521 to +522
// The Fastly response hook is synchronous; its KV operations
// use hostcall waits. Keep this bridge out of native executors.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🏕 camp site: Record the EdgeZero ordering this bridge relies on

This block_on is safe only because EdgeZero calls the finalize hook after its own block_on(app.dispatch_admitted(..)) has returned (dispatch_core_request and deliver_with_hooks in edgezero-adapter-fastly request.rs at 83d7a629). EdgeZero does not document that ordering on send_request_with_registries_and_hooks, so an EdgeZero change that ran the hook inside dispatch would turn this into an EnterError panic on every request with EC state. Saying so here gives the next EdgeZero bump something to check.

Suggested change
// The Fastly response hook is synchronous; its KV operations
// use hostcall waits. Keep this bridge out of native executors.
// The Fastly response hook is synchronous; its KV operations
// use hostcall waits. EdgeZero calls this hook only after its
// dispatch `block_on` returns, so this bridge does not nest.
// Keep it out of native executors.

Comment on lines +5 to +7
//! writes (`if_generation_match`). The SDK exposes only blocking completion
//! hostcalls for KV operations, so these local async methods retain that Fastly
//! behavior. Native backends must yield during network I/O instead.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⛏ nitpick: The Fastly module doc prescribes behavior for other backends

"Native backends must yield during network I/O instead" is a rule for code that does not exist, placed in the Fastly module. Describing what this backend does is enough; the trait doc is the place for the general contract.

Suggested change
//! writes (`if_generation_match`). The SDK exposes only blocking completion
//! hostcalls for KV operations, so these local async methods retain that Fastly
//! behavior. Native backends must yield during network I/O instead.
//! writes (`if_generation_match`). The SDK exposes only blocking completion
//! hostcalls for KV operations, so these async methods finish their I/O before
//! returning and never yield.

@dhruv8sh dhruv8sh left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Superseded by a follow-up review with inline comments: #1534 (review)

@dhruv8sh dhruv8sh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Summary

This PR moves EcKvStore and the identity-graph callers to local (?Send) async futures. Most of the change is mechanical .await threading. The Fastly adapter bridges the async finalize path with one block_on, which runs after EdgeZero dispatch has returned. I checked the conversion for held locks across awaits and for nested executors and found neither. I have two small non-blocking items.

1 of the inline comments below carries a one-click GitHub suggestion. Use Commit suggestion to apply it. The other comment describes the fix in prose because the new test goes outside this PR's hunks.

Non-blocking

⛏ nitpick

  • Explain why the finalize block_on is not nested. See inline at crates/trusted-server-adapter-fastly/src/main.rs:459.

🏕 camp site

  • Two-ID withdrawal coverage dropped with apply_withdrawal_tombstones. See inline at crates/trusted-server-core/src/ec/finalize.rs (removed lines 690-705).

CI Status

  • 25 checks reported by gh pr checks: all PASS. No failed, cancelled or pending checks. Branch protection lists no required checks for this base branch.


let state = ec_state.and_then(|mut ec_state| {
match apply_edgezero_ec_finalize(inputs.settings, &mut ec_state, response) {
match futures::executor::block_on(apply_edgezero_ec_finalize(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⛏ nitpick — Say why this block_on is safe here. A reader seeing futures::executor::block_on inside the Fastly entry point will reasonably worry about a nested executor. It is not one: send_request_with_registries_and_hooks runs dispatch to completion and only then calls the finalize hook from finish_request, and the Fastly KV backend's async methods wrap blocking hostcalls, so they complete on first poll. A short comment saves the next reviewer that trace.

Suggested change
match futures::executor::block_on(apply_edgezero_ec_finalize(
// Not a nested executor: EdgeZero calls this finalize hook after its
// dispatch executor has returned, and the Fastly KV backend's futures
// complete on first poll because the SDK only exposes blocking hostcalls.
match futures::executor::block_on(apply_edgezero_ec_finalize(

Comment on lines -690 to -705
#[test]
fn apply_withdrawal_tombstones_invokes_writer_for_each_ec_id() {
let first = sample_ec_id("first1");
let second = sample_ec_id("second");
let mut ids = HashSet::new();
ids.insert(first.clone());
ids.insert(second.clone());

let mut written = Vec::new();
apply_withdrawal_tombstones(&ids, |ec_id| written.push(ec_id.to_owned()));
written.sort();

let mut expected = vec![first, second];
expected.sort();
assert_eq!(written, expected, "should write a tombstone for each EC ID");
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🏕 camp site — This test went away with apply_withdrawal_tombstones, and nothing replaces its two-ID case. The remaining withdrawal tests (finalize_withdrawal_tombstones_a_held_identity and the others) all use one EC ID, where the cookie ID and the active ID are the same. The now-inlined for ec_id in &ids_to_withdraw loop in finalize_unusable_consent still has a separate branch for the non-active cookie ID: it gets EcKvSnapshot::NotRead as its initial snapshot and its outcome is not stored on the context. No test covers that branch now.

Suggested replacement: an async test that seeds two live rows (cookie ID and a different active ID), runs ec_finalize_response with consent withdrawn, and asserts both rows read back with consent.ok == false, and that only the active ID's snapshot is updated on the context.

#[tokio::test(flavor = "current_thread")]
async fn finalize_withdrawal_tombstones_both_cookie_and_active_identities() {
    // seed rows for sample_ec_id("cookie") and sample_ec_id("active"),
    // build the context with both IDs and withdrawn consent,
    // call ec_finalize_response(...).await,
    // then assert kv.get(..) shows !consent.ok for both IDs.
}

Apply manually. This can't be a one-click suggestion because the new test goes outside the hunks this PR changed.

aram356 added a commit that referenced this pull request Nov 12, 2026


Per the user, every feature is built once and works on every adapter in
platform order, with platform gaps sent to EdgeZero (DEC-16, SH-5). T21
moves the identity routes, EC lifecycle, providers and write-back into one
router in core that each adapter mounts; A2, A10, T8, T9, T17 and T18
shrink to turning it on per adapter and passing the conformance suite, and
T7, T14 and A11 fold into A2 and A10. EZI-2a adds EdgeZero's local store
for Axum development and EZI-4 binds Axum's geo provider from the manifest.

Restack the Axum RC units on #1506: #1531 carries A1 in place of #1528,
#1533 and #1534 close as copies of #1525 and #1526, and the image and
Terraform units sit directly on #1532 so A6 and A7 stop waiting on A2.
Record #1566, refresh the provider stack's fixes at their current heads,
and flag #1535 as unplanned.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants