Skip to content

Thread EC KV read through the request and recover orphaned cookies - #885

Open
prk-Jr wants to merge 22 commits into
mainfrom
fix/kv-eid-request-snapshot-ec-ttl
Open

Thread EC KV read through the request and recover orphaned cookies#885
prk-Jr wants to merge 22 commits into
mainfrom
fix/kv-eid-request-snapshot-ec-ttl

Conversation

@prk-Jr

@prk-Jr prk-Jr commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Remove the redundant per-request EC identity-graph reads that gated the publisher-navigation hot path, and overlap the Fastly origin fetch with the KV lookup + auction dispatch. One request-scoped snapshot now feeds auction EID resolution, finalize, and pull sync (was up to three reads).
  • Recover an orphaned ts-ec cookie (valid cookie, missing KV row) by rotating to a freshly generated, KV-backed EC ID on consent-granted real-browser navigations — fixing the TTL-decay identity loss where quiet long-lived users silently lost their identity graph.
  • Collapse pull sync's per-partner read-modify-write into one request-wide bulk CAS, and make withdrawal tombstones existing-key-only so a forged cookie can never create a KV root.
  • Preserve publisher response streaming while starting the Fastly origin request before EC KV preload and auction dispatch. Pending-stream completion is an explicit platform capability and remains independent from ordinary eager-send streaming support.

Changes

File Change
ec/mod.rs EcKvSnapshot request-scoped state (NotRead/Missing/Present/Failed), EC-ID binding, recovery-eligibility flag; generate_if_needed uses bounded Add-only creation
ec/kv.rs load_snapshot, create_if_absent, snapshot-aware bulk upsert, existing-key-only conditional tombstone; CAS/refresh semantics + tests
ec/finalize.rs Consume/return snapshot, orphan recovery (browser-nav gated, atomic add-then-cookie), two-ID existing-only withdrawal
ec/prebid_eids.rs Split validated update collection (collect_eid_cookie_updates) from KV persistence
ec/pull_sync.rs Reuse finalized snapshot for eligibility; aggregate partner results across batches into one bulk write
auction/endpoints.rs Resolve auction EIDs from a snapshot instead of performing KV I/O
platform/http.rs Add explicit pending-response streaming capability, direct single-handle wait contract, request-method metadata, and buffered select safeguards
platform/test_support.rs Model eager streaming and pending streaming independently; add direct-wait, bodiless-response, and capability-matrix coverage
publisher.rs Origin/auction scheduling (pending-stream capable: origin start, KV preload, auction dispatch, direct wait; fallback: preload, dispatch, eager send); preserve request snapshot, rewrite, cache bypass, DataDome privacy, and failure telemetry
http_util.rs Comment documenting the fastly-ssl scheme-signal layering wart (no behavior change)
adapter-fastly/platform.rs Implement Fastly pending streaming with direct PendingRequest::wait(), retain method/stream metadata, preserve HEAD and bodiless-status framing, and reject stream-marked handles from auction select
adapter-fastly/{app,main}.rs Carry snapshot + navigation eligibility through EcRequestState/EcFinalizeState; mutable finalize outcome into post-send pull sync

Closes

Closes #851

Test coverage

  • Concurrent pending path proves origin start -> EC KV lookup -> auction dispatch -> origin wait.
  • Pending-stream support is tested independently from ordinary eager-send streaming support.
  • Origin-start failure performs no KV read or auction dispatch.
  • Origin-wait failure preserves the existing single origin_proxy_error abandonment outcome.
  • Pending path verifies origin URI/Host rewriting, internal-header removal, conditional/range removal, cache bypass, the original client snapshot used for auction construction, configured publisher domain, DataDome suppression/privacy, and streamed response bodies.
  • Direct pending wait, buffered auction select, HEAD, and 1xx/204/205/304 bodiless semantics are covered.

Verification

  • cargo test-fastly
  • cargo test-axum
  • cargo test-cloudflare
  • cargo test-spin
  • cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity
  • cargo clippy-fastly
  • cargo clippy-axum
  • cargo clippy-cloudflare
  • cargo clippy-cloudflare-wasm
  • cargo clippy-spin-native
  • cargo clippy-spin-wasm
  • cargo fmt --all -- --check
  • cd crates/trusted-server-js/lib && node build-all.mjs
  • cd crates/trusted-server-js/lib && npx vitest run — 44 files, 829 tests, no type errors
  • cd crates/trusted-server-js/lib && npm run format
  • cd docs && npm run format
  • Manual testing via fastly compute serve

PR #1013 compatibility

PR #1013 does not supersede this change. A synthetic merge found a real conflict in publisher.rs: the inline pending-origin optimization consumes the origin request before PR #1013's ESI/C2 lookup expects to inspect it. The combined implementation must branch by assembly mode:

  • Inline: retain this PR's origin-first pending flow.
  • Esi: retain the request through the C2 lookup and fetch the origin only on a cache miss.

This compatibility work belongs in the branch that combines the two PRs; it is not included in this PR.

Hardening note

N/A — no config-derived regex or pattern compilation touched. Fail-closed invariants covered by tests: KV errors never become authoritative misses (no rotation), tombstones are never revived, cookies are emitted only after a backing row exists, store/CAS failures return a failed snapshot rather than claiming request-local IDs were persisted, and stream-marked pending requests cannot enter the buffered auction-selection path.

Checklist

  • Changes follow CLAUDE.md conventions
  • No unwrap() in production code — use expect("should ...")
  • Uses log macros (not println!)
  • New code has tests
  • No secrets or credentials committed

prk-Jr added 4 commits July 10, 2026 15:24
Harden the request-scoped EC KV snapshot work (#851) with the test
coverage the spec's test strategy called for, plus small cleanups the
plan requested.

Tests:
- ec::kv: snapshot upsert (write-without-read, unchanged-preserves-gen,
  refresh-once, CAS re-merge, tombstone-rejects, store-fail) and
  conditional tombstone (CAS conflict, store failure, disappear-on-retry)
- ec::mod: generate_if_needed collision retry + exhaustion; default and
  read-path recovery-ineligibility (non-Fastly adapter contract)
- ec::prebid_eids: collect_eid_cookie_updates merge and empty-registry
- ec::finalize: NotRead/Failed/tombstone/subresource no-rotate paths and
  two-ID existing-only withdrawal
- ec::pull_sync: request-wide aggregation into one bulk write across
  concurrency batches, plus no-dispatch for non-present snapshots
- publisher: concurrent-vs-eager origin scheduling order and origin-start
  failure, using recording HTTP/KV collaborators

Cleanups:
- Extract rewrite_origin_request to remove the duplicated origin-rewrite
  logic across the concurrent and eager paths
- Bind the orphaned EC ID once in recover_orphaned_ec

Docs:
- Note the fastly-ssl vendor-header layering wart in core scheme detection
  and the origin-forwarding strip (comments only; behavior unchanged)
@prk-Jr prk-Jr self-assigned this Jul 10, 2026
prk-Jr and others added 3 commits July 13, 2026 17:36
A Failed request-scoped snapshot no longer short-circuits
tombstone_existing_from_snapshot. A transient read error earlier in the
request must not silently drop a consent withdrawal, so a non-authoritative
snapshot is re-read (bounded by MAX_CAS_RETRIES) and the row is tombstoned
when present. An authoritative Missing snapshot stays a no-op.
@prk-Jr
prk-Jr marked this pull request as ready for review July 13, 2026 15:01
@prk-Jr
prk-Jr requested review from ChristianPavilonis and aram356 and removed request for ChristianPavilonis July 13, 2026 15:01

@ChristianPavilonis ChristianPavilonis 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

Changes requested. The snapshot/recovery design is promising, but I found two high-severity identity/privacy issues and three medium correctness, latency, and resource-usage issues. Details are attached inline.

Comment thread crates/trusted-server-adapter-fastly/src/app.rs Outdated
Comment thread crates/trusted-server-core/src/ec/kv.rs
Comment thread crates/trusted-server-core/src/publisher.rs Outdated
Comment thread crates/trusted-server-core/src/ec/kv.rs
Comment thread crates/trusted-server-core/src/auction/endpoints.rs Outdated
Address PR review findings:

- Gate orphan-recovery eligibility to the publisher fallback after a
  successful origin start. Named routes, integration proxies, and
  request-filter short circuits no longer reach EC finalization with
  recovery authorized, so a blocked or non-publisher response cannot
  rotate an identity.
- Never downgrade an in-request Add-confirmed Present snapshot on a
  preload refresh miss, and confirm an authoritative miss with a second
  read (after the origin round trip) before rotating. A single
  eventually-consistent edge miss can no longer rotate a valid identity;
  a now-visible row is adopted instead.
- Preload the origin-overlapped snapshot with the unfiltered active EC
  ID while keeping the consent-filtered ID for auction identity, so
  consent-withdrawn navigations keep the withdrawal CAS off the
  post-origin latency path.
- Resolve the initial usable snapshot outside the CAS retry counter in
  both partner upsert and conditional tombstoning, so a
  generation-unavailable or refreshed snapshot keeps all five write
  attempts.
- Defer /auction and page-bids identity-graph reads until a live auction
  actually runs with a partner registry, avoiding billable KV reads that
  cannot be consumed.

Add tests for four-conflicts-then-fifth-write CAS, transient
Add->Missing->Present confirmation, and recovery-eligibility lifecycle
across named routes, filter short circuits, and origin-start failures.
prk-Jr added 2 commits July 16, 2026 14:07
…apshot-ec-ttl

# Conflicts:
#	crates/trusted-server-adapter-fastly/src/app.rs
#	crates/trusted-server-core/src/auction/endpoints.rs
#	crates/trusted-server-core/src/ec/finalize.rs
#	crates/trusted-server-core/src/ec/prebid_eids.rs
#	crates/trusted-server-core/src/ec/pull_sync.rs
#	crates/trusted-server-core/src/http_util.rs
@aram356

aram356 commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

@prk-Jr Please confirm that is still issue with SSAT and ESI

@ChristianPavilonis ChristianPavilonis 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

Request changes. I found three P1 identity/consent races and two P2 snapshot-contract regressions in the request-scoped EC snapshot implementation. All findings are inline.

Comment thread crates/trusted-server-core/src/ec/kv.rs Outdated
Comment thread crates/trusted-server-core/src/ec/kv.rs
Comment thread crates/trusted-server-core/src/ec/pull_sync.rs Outdated
Comment thread crates/trusted-server-core/src/auction/endpoints.rs Outdated
Comment thread crates/trusted-server-core/src/ec/finalize.rs Outdated
prk-Jr added 3 commits August 26, 2026 16:17
Point lookups on edge data stores are eventually consistent, so `Ok(None)`
cannot prove a key is absent. Three identity and consent paths treated a
single stale read as authoritative.

Fastly's KV list API reads the primary data source unless
`eventual_consistency()` is requested, and `EcKvStore::count_keys_with_prefix`
already rides that path on every adapter. `KvIdentityGraph::key_exists_confirmed`
wraps it as a consistency-safe existence check, used wherever absence has real
consequences.

Withdrawal tombstones: a preloaded `Missing` no longer short-circuits
`tombstone_existing_from_snapshot`. It is re-read — on the publisher path the
re-read is separated from the preload by the full origin round trip — and a
second miss is escalated to the existence check. A proven-absent key stays a
no-op so forged cookies still mint nothing; a key the store still lists is
tombstoned unconditionally, because no CAS generation survives a missed read
and a withdrawal must win; a failed check leaves the withdrawal unresolved
rather than silently dropped.

EID ingestion: `upsert_partner_ids_from_snapshot` carries the existence proof
held by an incoming `Present` snapshot — an `Add`-confirmed create or an
earlier authoritative read. A refresh that misses or fails skips the update and
logs it instead of returning `Missing`/`Failed`, so a transient miss can no
longer suppress the `ts-ec` cookie for a root that was written. Write failures
still report `Failed`: those are operation failures, not ambiguous reads.

Post-send pull sync: dispatch was authorized entirely by the request snapshot,
so a CMP withdrawal completing after the page response was flushed could still
send the raw `ec_id` to partners. The live row is now re-read immediately
before the first partner request and anything short of a live, consenting entry
cancels the dispatch. The read sits behind the snapshot eligibility filter, so
a dispatch with nothing to pull still costs no KV operation, and the fresh
snapshot feeds the write-back instead of the stale one.

Orphan recovery: rotation abandons a year-lived identity graph, so a second
point-read miss is no longer enough. It now requires proven absence; a key the
store still lists, or an existence check that fails, leaves the identity intact
and retries recovery on a later navigation.
`/auction` and `/_ts/page-bids` loaded the identity-graph row into a local
snapshot to resolve server-side EIDs, then dropped it. The Fastly adapter
passed an unchanged `EcContext` into response finalization, which therefore saw
`NotRead` and paid for a second billable lookup whenever `ts-eids` or
`sharedId` carried an update — working against this PR's read-reduction goal.

Both handlers now take `&mut EcContext` and store the loaded snapshot before
returning, so finalization ingests EID updates from the read the endpoint
already performed. `ec_id` and the consent context are taken by value in both
handlers so the store-back does not conflict with a live borrow. The other
adapters own their `EcContext` locally and only needed the binding made
mutable.

Adds a counting-KV test asserting `/auction` plus finalization performs exactly
one lookup and still ingests the `sharedId` update.
The single conflict is in `handle_publisher_request`, where both sides changed
what happens between the auction dispatch and the origin fetch.

This branch moved origin-request preparation above the auction dispatch so the
origin fetch overlaps the EC identity-graph read. Main (#1008) added the shared
template cache in the space that move vacated: request-derived cacheability
flags, the pre-fetch cache key, and a `lookup_or_reserve` whose hit returns
`AssembleTemplate` without ever contacting the origin.

Resolution splits main's block along the line the move drew. Everything that
reads the request — `request_had_authorization`, `cookie_disqualifies`, the
DataDome suppression flags, reader-compression negotiation,
`request_can_use_shared_template`, and the cache key itself — moves up beside
this branch's origin preparation, because that is now the last point where the
request is in hand. `configured_assembly_mode` moves with them since the key
depends on it. The URI and Host rewrite stays folded into this branch's
`rewrite_origin_request` helper rather than being reinstated inline. The lookup
and its hit handling stay where main put them: they need `dispatched_auction`,
which the hit hands to the assembler.

The two features then contend over one request. A template hit skips the origin
entirely, and an in-flight request cannot be recalled, so the concurrent origin
start is gated on `template_cache_key.is_none()`. A request eligible for a
shared template waits for the lookup instead of speculatively fetching an origin
response a hit would discard. Every other request — including all traffic in the
default `Inline` assembly mode, where the key is always `None` — keeps the
overlap unchanged. The cost is one lost overlap on an ESI-mode cache miss, which
is the correct trade against paying for a discarded fetch on every hit.

Test fallout from main, resolved in kind rather than by reverting either side:

- `handle_publisher_request` gained `edge_header: EdgeCacheHeader` (#860); this
  branch's three scheduling-probe call sites now pass `SurrogateControl`.
- Main renamed the `private, no-store` cache-control value to `no-store,
  private`. The probe now asserts against
  `cache_policy::NO_STORE_PRIVATE_CACHE_CONTROL` so it cannot drift again.
- Main removed the `queue_cacheable_html_response` fixture along with its own
  callers. This branch's surviving caller moves to main's replacement,
  `queue_html_response_with_cache_control`, instead of reviving a duplicate.
- `PublisherResponse::AssembleTemplate` is new; the probe's response matches
  cover it.
- Main added an `/auction` test that predates this branch's `&mut EcContext`
  signature.

Verified: fmt, all six clippy gates, core (2269), fastly under Viceroy, axum,
cloudflare, spin, parity, CLI, openrtb-codegen, JS (888), and both
`scripts/template-cache-local-test.sh` modes — esi 21/21 including a warm hit
that skips the origin, inline 8/8.

@ChristianPavilonis ChristianPavilonis 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

Reviewed 7b71f83331a5506e078537c749d283ff7fc9324a against 616b74051a4ec4147160d882ae8ac77bfe6785dd. Two medium-severity findings are included inline. All reported CI checks pass, and the existing review threads were checked to avoid duplicate feedback.

Comment thread crates/trusted-server-core/src/ec/kv.rs Outdated
Comment thread crates/trusted-server-core/src/ec/kv.rs
prk-Jr added 2 commits August 28, 2026 21:42
A `Missing` snapshot recorded earlier in the request short-circuited
`upsert_partner_ids_from_snapshot`, so a stale point read on the
eventually-consistent identity graph dropped the request's collected
partner IDs. `/auction` and `/_ts/page-bids` save their first lookup into
`EcContext` and are never recovery eligible, so those routes had no later
chance to retry and a live row could miss a newly collected `ts-eids` or
`sharedId` value. Revalidate a matching `Missing` once before the updates
are dropped; a refresh that still misses keeps the no-create behavior,
and an already-failed lookup is still not retried on the hot path.

Withdrawal tombstones that exhausted every CAS attempt returned
`EcKvSnapshot::Failed` with no log, and finalization discarded the
outcome entirely for a non-active cookie ID. The browser cookie is
cleared while the KV row can stay live with consent granted, so operators
had no signal that withdrawal enforcement failed. Log CAS exhaustion for
both the tombstone and the snapshot partner upsert, and warn on every
failed withdrawal outcome in finalization.
`main` replaced the template cache's plain `Authorization` presence check
with `authorization_disqualifies`, which lets a single unchanged
edge-terminated value stay shareable and rejects unmarked, replaced, or
repeated values. Every downstream use merged cleanly to the new name, so
only the definition conflicted. Take main's gate and keep this branch's
rationale for computing it here: the origin send below may start before
the auction is dispatched, so this is the last point where the request is
still in hand.
@jwrosewell

Copy link
Copy Markdown
Contributor

No objection to the change itself. One sequencing note before it lands.

This PR and the provider stack in #1043#1047 and #1094 both change the Edge Cookie path and overlap on ec/kv.rs, ec/finalize.rs, ec/pull_sync.rs and publisher.rs, so whichever merges second will need to resolve conflicts there. Could this merge behind that stack?

If the stack can't be merged quickly and #885 needs to go first, the alternative we put to Rowena, Jason and Shailley is a release branch model, keeping the current live and next release branches in sync so neither blocks the other.

Either order works for us. Flagging it now so whoever merges first knows the other is coming.

@jwrosewell

jwrosewell commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Sequencing note on this PR and the provider stack.

This PR and the open provider stack (#1043 to #1047, #1084, #1094) rework the same Edge Cookie code. A merge simulation of this PR's head (d414740) against the stack's tip (35584db) conflicts in 8 files, and against #1043 alone, the first PR of the stack, in 6 files, so whichever lands second re-resolves this by hand across the chain:

  • crates/trusted-server-adapter-axum/src/app.rs
  • crates/trusted-server-adapter-cloudflare/src/app.rs
  • crates/trusted-server-adapter-spin/src/app.rs
  • crates/trusted-server-core/src/auction/endpoints.rs
  • crates/trusted-server-core/src/ec/finalize.rs
  • crates/trusted-server-core/src/ec/mod.rs
  • crates/trusted-server-core/src/ec/pull_sync.rs
  • crates/trusted-server-core/src/publisher.rs

Reproduce:

git fetch upstream main refs/pull/885/head:pr-885
git merge-tree --write-tree --name-only pr-885 <stack head>

The request is the one we have made on #940 and #1094. The stack has been open since 19 August, carrying work that has been under review since 2 July as #838, is green on required CI, and its branches are kept rebased close to main, so please land the stack first, or say here that this PR goes first so we rebase once against a known base. The KV read-through and orphaned-cookie recovery this PR does are close to the stack's read-back and finalize changes, so sequencing them deliberately will also make both reviews easier than untangling a surprise conflict later.

aram356 added a commit that referenced this pull request Sep 3, 2026
}

log::warn!(
"snapshot partner upsert for '{}': CAS conflict after {MAX_CAS_RETRIES} retries; {} partner updates were not persisted",

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 — This string literal contains a run of spaces where a \ line-continuation was evidently intended, so the emitted warning reads …retries; 3 partner updates were not persisted. Neighboring multi-line log messages in this file use the \ continuation.

Suggested change
"snapshot partner upsert for '{}': CAS conflict after {MAX_CAS_RETRIES} retries; {} partner updates were not persisted",
"snapshot partner upsert for '{}': CAS conflict after {MAX_CAS_RETRIES} retries; \
{} partner updates were not persisted",

(compile-verified: cargo fmt --all -- --check and cargo clippy-fastly pass with this applied — pure log-output change)

// live with consent granted while the browser cookie is cleared. That
// divergence is only visible to operators if it is logged here.
log::warn!(
"withdrawal tombstone for '{}': CAS conflict after {MAX_CAS_RETRIES} retries; the identity-graph row may still be live with consent granted",

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 — Same embedded-spaces defect as the partner-upsert warning above. This is the operator-facing message the adjacent comment says exists precisely so the cookie/KV divergence is visible — worth keeping it legible.

Suggested change
"withdrawal tombstone for '{}': CAS conflict after {MAX_CAS_RETRIES} retries; the identity-graph row may still be live with consent granted",
"withdrawal tombstone for '{}': CAS conflict after {MAX_CAS_RETRIES} retries; the \
identity-graph row may still be live with consent granted",

(compile-verified: cargo fmt --all -- --check and cargo clippy-fastly pass with this applied — pure log-output change)

Comment on lines +107 to 109
let Some(request_entry) = context.snapshot.entry_for(context.ec_id()) else {
return;
};

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.

🤔 thinking — This exit narrows pull sync relative to main, and it does so silently. On main, dispatch_pull_sync read KV itself, so any real-browser request (subresources included) could dispatch. Now dispatch requires a Present request snapshot, which only navigations (preload), /auction, /_ts/page-bids, and requests whose EID-cookie ingestion loaded the row produce — a real-browser subresource request with a valid EC and no EID cookies, previously a dispatch opportunity, now exits here with no log at all, while every other ineligibility exit in this function logs. The narrowing itself looks like the intended KV-read saving from the design doc; the silent exit is the actionable part.

Suggested change
let Some(request_entry) = context.snapshot.entry_for(context.ec_id()) else {
return;
};
let Some(request_entry) = context.snapshot.entry_for(context.ec_id()) else {
log::debug!(
"Pull sync: skipping dispatch for '{}' because the request captured no usable \
identity snapshot",
super::log_id(context.ec_id())
);
return;
};

(compile-verified: cargo fmt --all -- --check and cargo clippy-fastly pass with this applied)

if ec_context.kv_snapshot().entry_for(&ec_id).is_some() {
set_ec_cookie_on_response(settings, ec_context, response);
} else {
log::warn!("Skipping generated EC cookie because backing row is not authoritative");

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.

🤔 thinking — When EID updates are present and the partner-ID CAS write fails (or exhausts its five attempts), upsert_partner_ids_from_snapshot returns Failed and this branch suppresses the cookie — even though the Add in generate_if_needed already proved the backing row exists. That is asymmetric with keep_proven, which correctly refuses to let a failed read retract the Add-confirmed proof: a failed write doesn’t disprove existence either. Net effect: the user churns to a fresh ID on the next visit and the just-created row becomes KV garbage, with no privacy gain (the row is live either way). Low probability, and the fail-closed choice is documented as deliberate — raising it as a design question, not a bug. A fix would carry the existence proof through the write-failure path for cookie issuance while still logging the enrichment failure.

message: "Failed to persist generated EC ID to KV identity graph".to_string(),
}));
if let Some(graph) = kv {
match graph.create_if_absent(&ec_id, &entry) {

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 — This migration (and the pull-sync/finalize ones) leaves several pub entry points with zero production callers — they’re reachable only from their own tests, and no dead-code lint fires because they’re pub in a lib crate. Verified across all workspace crates:

  • KvIdentityGraph::create_or_revive (ec/kv.rs:362) — last caller was this function, now on create_if_absent. (Generation no longer reviving tombstones is a behavior improvement worth keeping — a withdrawn ID stays withdrawn.)
  • KvIdentityGraph::upsert_partner_id (ec/kv.rs:689) — last caller was pull sync’s drain_pull_batch, now aggregated into the bulk snapshot path.
  • ingest_eid_cookies (ec/prebid_eids.rs:158) plus wrappers ingest_prebid_eids (:197) and ingest_sharedid_cookie (:322) — finalize now uses collect_eid_cookie_updates + snapshot upserts.

Apply manually — the deletion spans multiple files and their tests, so it can’t be a one-click suggestion. Removing them (here or in a fast follow-up) makes the snapshot-aware paths the only entry points, so future callers can’t reach for a non-snapshot write path that bypasses the new invariants.

) {
// Snapshot the orphaned ID once so every fail-closed exit binds the failed
// snapshot to the same key.
let orphan_id = ec_context.ec_value().unwrap_or_default().to_owned();

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.

nitpickunwrap_or_default() can’t fire on the current call path (recovery is only reachable with an active EC value), but if it ever did, every fail-closed exit below would store EcKvSnapshot::Failed { ec_id: "" }, which belongs_to/entry_for then silently never match. A let Some(orphan_id) = ec_context.ec_value().map(str::to_owned) else { return; } (or an expect("should have an active EC during orphan recovery") per house style) would make the invariant explicit.

/// Supporting adapters must override `wait` with direct single-handle
/// completion. A pending request marked for streaming must never be passed
/// to [`select`](Self::select), whose fan-out contract remains buffered.
fn supports_pending_streaming_responses(&self) -> bool {

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.

🌱 seedling — The default wait routes through select, and both implemented selects now reject stream-marked handles — so an adapter that returns true here without overriding wait fails at runtime on every publisher navigation. The doc comment states the requirement, but a future adapter author could still miss it. Not for this PR: consider a shared conformance test (or a debug_assert) tying this capability to an overridden wait.

Comment on lines +199 to +207
Valid partner responses are collected across every HTTP concurrency batch into
one request-wide set of `PartnerIdUpdate` values and merged in one final bulk
CAS operation. Draining a network batch never writes KV. When the finalized snapshot still
has a usable generation, the uncontended case performs one write and no
additional read. When finalization already wrote the row, pull sync uses its
updated entry for eligibility, collects responses, then performs one refresh
lookup to obtain the new generation before its bulk CAS. A conflict rereads the
latest entry, rejects a tombstone, re-merges every collected update, and retries
within the existing bound. Pull sync never creates a missing root.

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.

📝 note — This paragraph predates the consent-revalidation fix (af56a10f, added for the round-2 “post-send pull sync can disclose an ID after withdrawal” finding): the shipped code always re-reads the live row before the first partner request and cancels on anything short of a live, consenting entry, so “the uncontended case performs one write and no additional read” no longer holds — every dispatching request pays exactly one revalidation read, and the bulk CAS writes from that refreshed snapshot rather than the finalized one. Since this spec ships in this PR and reads as authoritative, the stale sentence misstates exactly the KV-read accounting the PR is about.

Suggested change
Valid partner responses are collected across every HTTP concurrency batch into
one request-wide set of `PartnerIdUpdate` values and merged in one final bulk
CAS operation. Draining a network batch never writes KV. When the finalized snapshot still
has a usable generation, the uncontended case performs one write and no
additional read. When finalization already wrote the row, pull sync uses its
updated entry for eligibility, collects responses, then performs one refresh
lookup to obtain the new generation before its bulk CAS. A conflict rereads the
latest entry, rejects a tombstone, re-merges every collected update, and retries
within the existing bound. Pull sync never creates a missing root.
Valid partner responses are collected across every HTTP concurrency batch into
one request-wide set of `PartnerIdUpdate` values and merged in one final bulk
CAS operation. Draining a network batch never writes KV. Because pull sync runs
after the response is flushed and discloses the raw EC ID to partners, the live
row is re-read once before the first partner request; anything short of a live,
consenting entry cancels the dispatch, and that refreshed snapshot supplies the
generation for the bulk CAS. A conflict rereads the latest entry, rejects a
tombstone, re-merges every collected update, and retries within the existing
bound. Pull sync never creates a missing root.

(format-verified: prettier --check passes with this applied)

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.

Thread the KV EID read through the request and fix the EC TTL decay bug

4 participants