Repository navigation
Conversation
db9e915 to
bdd12a8
Compare
Pull-sync partners become provider-owned: browser ingest skips their sources and batch sync rejects them. The post-send dispatcher and the completeness marker are removed, and leftover marker cookies are expired. Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
The mirror cookie now holds only UIDs with a provider owner, so browser, batch and legacy pull-sync records are never mirrored, and its size budget counts the pending EID suffix for later UIDs. A pull-sync source with any live UID counts as held, as legacy pull sync did, so ownerless records no longer send the EC ID to the partner on every navigation. Config load warns when a pull-sync partner also sets a batch api_token, whose mappings batch sync rejects. Restore a test that an oversized pull-sync UID is dropped, and correct the guide on browser fallback ingest. Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Batch sync rejects only sources owned by a configured identity provider. A source owned only by its partner's pull sync accepts batch mappings again, as it did before pull sync became a provider. Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
The mirror was re-set on every consented navigation with a provider UID, and any Set-Cookie makes the response private and uncacheable, so returning users lost all caching of publisher HTML. The request's mirror value is now kept and the cookie is set only when the new value differs. An unchanged mirror is not re-extended; once it lapses the next navigation sets it again. Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
…he cap Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
…rror exposure Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
Signed-off-by: dhruv8sh <dhruv8sh@proton.me>
bdd12a8 to
9f1e764
Compare
ChristianPavilonis
left a comment
There was a problem hiding this comment.
Summary
This PR moves pull sync into after-response identity collection and adds a bounded, JavaScript-readable mirror of provider-written bidstream UIDs. One startup-validation issue needs addressing; there is also an opportunity to avoid a redundant KV read on steady-state navigations.
The inline comment is prose-only: the proposed fix and regression coverage touch lines outside the diff, so there are no one-click suggestions.
Blocking
🔧 wrench
- Reject malformed pull tokens during startup — see inline at
crates/trusted-server-identity-providers/src/pull_sync.rs:59-60.
Cross-cutting / body-level findings
♻️ Avoid the follow-up KV read when all lookups were skipped
Introduced by crates/trusted-server-identity-providers/src/lib.rs:169-182; affected helper: crates/trusted-server-core/src/ec/after_response.rs:76-79.
When every pull-sync source already holds a UID, dispatch produces Skipped(NotDue) results. Those results are nonempty, so the follow-up helper rereads KV despite having nothing to persist. Moving existing pull-sync partners into this path adds an avoidable read on every eligible returning navigation.
Import LookupStatus and SkipReason alongside LookupResults, then replace the helper's empty-results guard with:
if lookups.iter().all(|lookup| {
lookup.entries.is_empty()
&& lookup.cleared.is_empty()
&& lookup.status != LookupStatus::Skipped(SkipReason::OptedOut)
}) {
return AfterResponseOutcome::Unchanged;
}
This preserves writes that remember provider opt-outs.
Apply manually — the helper, imports, and regression test are outside the diff.
Verification
- A focused scratch reproduction confirmed that a newline-containing pull token passes runtime config validation but prevents construction of the coordinator for both an invalid and an otherwise valid partner.
- A counting-store scratch reproduction confirmed that all-
NotDueresults still cause one KV read in the follow-up helper. - Both reproductions passed. Scratch changes were removed and the worktree was restored to the reviewed head.
- The proposed fixes have not been compiled or run through the full local gate.
CI Status
- integration tests: PASS
- browser integration tests: PASS
- integration tests (Fastly EC lifecycle): PASS
- trace runtime and browser acceptance: PASS
- cargo fmt: PASS
- cargo test (axum native): PASS
- cargo test (cross-adapter parity): PASS
- format-docs: PASS
- worker-deadlines (spin): PASS
- worker-deadlines (cloudflare): PASS
- ts dev lint domains (changed lines): PASS
- cargo test: PASS
- format-typescript: PASS
- cargo test (ts CLI, native) (macos-latest): PASS
- HTML EOF runtime (Fastly): PASS
- cargo check/build/test (spin native + wasm32-wasip1): PASS
- CLAUDE.md symlink guard: PASS
- docs-proposal scripts: PASS
- CI Node pin guard: PASS
- cargo test (ts CLI, native) (ubuntu-latest): PASS
- cargo check (cloudflare native + wasm32-unknown-unknown): PASS
- prepare integration artifacts: PASS
- vitest: PASS
No required checks were reported by the required-check query.
| let mut value = http::HeaderValue::try_from(format!("Bearer {}", token.expose())) | ||
| .map_err(|_| Report::new(ProviderConfigError::Invalid))?; |
There was a problem hiding this comment.
🔧 Reject malformed pull tokens during startup
A pull token containing a newline passes runtime config validation but fails here. Because provider construction collects into one Result (src/lib.rs:169-182), that single invalid token prevents every after-response provider from running. The request-time caller merely logs the failure.
Reject invalid resolved tokens in crates/trusted-server-core/src/ec/registry.rs during startup, while retaining the disabled-partner bypass:
if require_nonempty_token
&& token_value.is_some_and(|value| {
value.trim().is_empty()
|| http::HeaderValue::try_from(format!("Bearer {value}")).is_err()
})
{
return Err(Report::new(TrustedServerError::Configuration {
message: "ts_pull_token must be nonempty and valid in an HTTP Authorization header"
.to_owned(),
}));
}
Add regression coverage for invalid enabled tokens and disabled partners. Validate resolved secret values at runtime config load, not secret-store key references during deploy validation.
Apply manually — the fix and regression tests touch lines outside this PR's diff hunks.
A focused scratch reproduction confirmed the startup-acceptance/coordinator-failure sequence with two partners. The proposed fix itself has not been compiled.
aram356
left a comment
There was a problem hiding this comment.
Summary
This PR moves pull sync onto the after-response provider path and adds a JavaScript-readable mirror of provider-written bidstream UIDs. The move keeps the live-row consent re-check and the bot gate. Before the mirror ships, it needs an operator opt-in and a per-vendor consent check. The after-response path also adds KV reads that the legacy dispatcher avoided.
3 of the 10 inline comments carry a one-click GitHub
suggestion. Use Commit suggestion, or Add suggestion to batch to apply several at once. The other comments describe the fix in prose, because those changes span files or touch lines outside the diff.
Blocking
🔧 wrench
- Make the EID mirror opt-in, separate from
bidstream_enabled: see inline atcrates/trusted-server-core/src/ec/finalize.rs:90 - Withhold mirrored UIDs whose provider lacks TCF vendor consent: see inline at
crates/trusted-server-core/src/ec/mirror.rs:77 - Skip the after-response row read when nothing is due: see inline at
crates/trusted-server-adapter-fastly/src/app.rs:453 - Cover the
enabled = falsebypass of the double-claim check (suggestion): see inline atcrates/trusted-server-core/src/config.rs:1455
❓ question
- Should pull-sync sources keep accepting browser UIDs?: see inline at
crates/trusted-server-core/src/ec/registry.rs:274
Non-blocking
♻️ refactor
- Delete
pull_enabled_partners; nothing calls it now: see inline atcrates/trusted-server-core/src/ec/registry.rs:358 - Keep one definition of "runs pull sync": see inline at
crates/trusted-server-core/src/ec/registry.rs:395
🤔 thinking
- The pull-sync cap is logged as
skipped_rate_limited: see inline atcrates/trusted-server-core/src/ec/coordinator/mod.rs:578
⛏ nitpick
- Describe where pull sync runs now (suggestion): see inline at
docs/guide/edge-cookies.md:268 - The
Nonecase cannot happen (suggestion): see inline atcrates/trusted-server-identity-providers/src/lib.rs:71
Cross-cutting / body-level findings
- 📝 "Behaviour changes from
main" is missing items. In addition to the four already listed:- Browser
ts-eidsandsharedIdUIDs are no longer stored for pull-sync sources. See the question atregistry.rs:274. - Pull sync runs only on Fetch Metadata navigations that get a page-load permit. It no longer runs after
/auctionresponses, on navigations without Fetch Metadata, or on prefetches. - Each pull used to have a 15-second first-byte timeout. Pull sync now shares the 2-second
identity_after_response_budget_mswith every after-response provider, so a partner slower than that is never filled. pull_sync_concurrency = 0used to mean one request at a time (.max(1), with every partner still pulled). It now means no limit.- A steady-state returning navigation makes up to two more KV reads after the response. See the inline comment at
app.rs:453and the open thread onpull_sync.rs:60.
- Browser
- 📝 Earlier findings are still open. ChristianPavilonis's two findings apply unchanged at this head, and that thread has no reply yet:
- 🔧 malformed pull tokens (
pull_sync.rs:60) - ♻️ the follow-up read when every result is
NotDue
- 🔧 malformed pull tokens (
Verification
- Scratch tests at this head did three things:
- reproduced the browser-ingest change (
trusted-server-identity-providers, native target) - reproduced the mirror's vendor-consent gap (
ec/mirror.rs,wasm32-wasip1under Viceroy) - confirmed that the double-claim bypass works
- reproduced the browser-ingest change (
- I applied each suggestion to a scratch copy, first alone and then together with the others:
- The config test passed rustfmt, the new test itself, all six adapter clippy aliases,
test-fastly,test-axum,test-cloudflare,test-spinand the parity suite. - The doc comment passed rustfmt and
clippy-fastly. - The guide line passed prettier.
- The scratch changes were then discarded.
- The config test passed rustfmt, the new test itself, all six adapter clippy aliases,
- The prose fixes (mirror opt-in, vendor filter,
any_due,runs_pull_sync) have not been compiled.
CI Status
- browser integration tests: PASS
- cargo check (cloudflare native + wasm32-unknown-unknown): PASS
- cargo check/build/test (spin native + wasm32-wasip1): PASS
- cargo fmt: PASS
- cargo test: PASS
- cargo test (axum native): PASS
- cargo test (cross-adapter parity): PASS
- cargo test (ts CLI, native) (macos-latest): PASS
- cargo test (ts CLI, native) (ubuntu-latest): PASS
- CI Node pin guard: PASS
- CLAUDE.md symlink guard: PASS
- docs-proposal scripts: PASS
- format-docs: PASS
- format-typescript: PASS
- HTML EOF runtime (Fastly): PASS
- integration tests: PASS
- integration tests (Fastly EC lifecycle): PASS
- prepare integration artifacts: PASS
- trace runtime and browser acceptance: PASS
- ts dev lint domains (changed lines): PASS
- vitest: PASS
- worker-deadlines (cloudflare): PASS
- worker-deadlines (spin): PASS
No required checks are reported for this branch.
| /// cacheability. An unchanged mirror is therefore not re-extended; once it | ||
| /// lapses, the next navigation sets it again. When the row could not be read | ||
| /// authoritatively (KV failure), the mirror is left untouched. | ||
| fn apply_mirror( |
There was a problem hiding this comment.
🔧 Make the EID mirror opt-in, separate from bidstream_enabled
apply_mirror runs on every finalized response, and no setting turns it off. Once a row holds a provider-written UID for a bidstream_enabled partner, navigations set __Host-ts-eids-mirror, which any script on the page can read. With this PR that includes every pull-sync partner that bids; before, those UIDs stayed in KV. The guide's only opt-out is bidstream_enabled = false, which also removes the UIDs from user.eids. An operator therefore cannot keep a partner's UIDs in the bidstream and off the page.
An always-on default has two more costs:
- Nothing reads the cookie yet.
crates/trusted-server-jshas no reader, and Add identity providers for ID5, LiveIntent, LiveRamp, ConnectID and UID2 #1488 adds none. - The value can reach 3,584 bytes and is sent on every same-origin request.
rewrite_origin_request(publisher.rs:3471) forwards theCookieheader unchanged, so the publisher origin receives it too. Withts-eids(capped at 3,072 bytes by the Prebid integration), Trusted Server cookies come to about 6.7 KB before any publisher or CMP cookie. That is close to the 8 KB per-header default of nginx (large_client_header_buffers) and Apache (LimitRequestFieldSize).
Proposed fix (apply manually: touches settings.rs, ec/registry.rs, ec/mirror.rs and the guide). Add an explicit switch that defaults to off, preferably per partner, carry it onto PartnerConfig, and check it in build_mirror:
// settings.rs, EcPartner
/// Whether this partner's provider-written UIDs are mirrored to the
/// JS-readable `__Host-ts-eids-mirror` cookie.
#[serde(default, deserialize_with = "from_value_or_str")]
pub mirror_enabled: bool,
// ec/mirror.rs, build_mirror
.filter(|partner| partner.bidstream_enabled && partner.mirror_enabled)
Then change the guide's opt-out bullet to name it.
| let uids = eid | ||
| .uids() | ||
| .iter() | ||
| .filter(|uid| uid.owner().is_some() && uid.is_live(now)) |
There was a problem hiding this comment.
🔧 Withhold mirrored UIDs whose provider lacks TCF vendor consent
The coordinator skips a provider when the request's TCF string denies its tcf_vendor_id, but the mirror checks only EC and EID-persistence consent. Suppose a provider stored a UID while vendor consent was granted, and the user then denies that vendor. The UID keeps being written to a cookie that every page script can read. This is the same gap as the live_eids thread on #1487, through a new path to the page. The vendors in #1488 carry TCF vendor IDs, so it applies as soon as they land.
A scratch test in this module reproduces it. With Purposes 1 and 4 granted and vendor 9999 denied, tcf_vendor_consent_denied(&consent, 9999) is true, and build_mirror still returns MirrorCookie::Set with the stored UID.
Proposed fix (apply manually: build_mirror has no provider configs today, so pass the denied owners in from ec_finalize_response, which has settings). Skip a UID when its owner's vendor consent is denied:
// ec/finalize.rs, built before calling build_mirror
let denied: HashSet<&str> = settings
.ec
.identity_providers
.iter()
.filter(|config| {
config
.tcf_vendor_id
.is_some_and(|vendor| tcf_vendor_consent_denied(consent, vendor))
})
.map(|config| config.id.as_str())
.collect();
// ec/mirror.rs, the UID filter
.filter(|uid| {
uid.owner()
.is_some_and(|owner| !denied.contains(owner.as_str()))
&& uid.is_live(now)
})
Please add a regression test for the denied-vendor case.
| .iter() | ||
| .any(|config| config.enabled && config.collect == CollectTiming::AfterResponse); | ||
| .any(|config| config.enabled && config.collect == CollectTiming::AfterResponse) | ||
| || pull_sync_providers(&state.settings.ec).next().is_some(); |
There was a problem hiding this comment.
🔧 Skip the after-response row read when nothing is due
This line sends every pull-sync deployment through run_after_response_lookups (main.rs:730). That function calls graph.load_snapshot (main.rs:761) before it dispatches. Finalize issues the follow-up permit when the page-load write "succeeded or had nothing to change" (finalize.rs:221-224), so a steady-state returning visitor reaches that read on every navigation.
The legacy dispatcher avoided this read on purpose. It filtered partners against the request snapshot first ("a dispatch with nothing to pull must not add a KV operation", base ec/pull_sync.rs:158-160), so a row that held every pull UID cost no KV operation. Now a returning visitor whose pull sources are all held costs two reads and no partner request: this one, plus the commit_after_response_lookups re-read from the open thread on pull_sync.rs:60.
Proposed fix (apply manually: touches coordinator/mod.rs and main.rs, outside this hunk; not compiled). Check the request's snapshot, which finalize leaves holding the committed row, and return before the live read when no after-response provider has a due source. A source held in that snapshot is still held in the live row unless consent was withdrawn, and a withdrawal blocks the lookup anyway.
// coordinator/mod.rs
/// Whether any provider has a due source in `entry`, ignoring consent and
/// rate limits, so a caller can skip a row read that cannot lead to a lookup.
#[must_use]
pub fn any_due(&self, entry: Option<&KvEntry>, now: UnixSeconds) -> bool {
self.providers.iter().any(|registered| {
!entry.is_some_and(|entry| entry.opted_out(®istered.id, now))
&& !Self::due_sources(registered, entry, now).0.is_empty()
})
}
// main.rs, run_after_response_lookups, before `graph.load_snapshot`
let request_entry = ec_state.ec_context.kv_snapshot().entry_for(&lookups.ec_id);
if !coordinator.any_due(request_entry, UnixSeconds::new(current_timestamp())) {
log::debug!("After-response lookups skipped: nothing due");
return;
}
A NotRead or Failed request snapshot yields no entry, so every source counts as due and the live read still runs.
| err.to_string().contains("pull-sync partner"), | ||
| "should explain the double claim: {err:?}" | ||
| ); | ||
| } |
There was a problem hiding this comment.
🔧 Cover the enabled = false bypass of the double-claim check
The new check in validate_identity_providers filters on config.enabled, but only the rejection is tested. The test below passes at this head, and it fails if a later change drops the filter.
| } | |
| } | |
| #[test] | |
| fn runtime_validation_accepts_disabled_provider_sharing_a_pull_sync_source() { | |
| let mut settings = settings_with_identity_provider(serde_json::json!({ "enabled": false })); | |
| settings.ec.partners = vec![ | |
| serde_json::from_value(serde_json::json!({ | |
| "name": "ids", | |
| "source_domain": "ids.example.com", | |
| "pull_sync_enabled": true, | |
| "pull_sync_url": "https://sync.example.com/pull", | |
| "pull_sync_allowed_domains": ["sync.example.com"], | |
| "ts_pull_token": "example-pull-token-32-bytes-minimum", | |
| })) | |
| .expect("should build partner"), | |
| ]; | |
| validate_settings_for_runtime(&settings) | |
| .expect("should accept a disabled provider sharing a pull-sync source"); | |
| } |
| .iter() | ||
| .filter(|partner| pull_sync_registered && runs_pull_sync(partner)) | ||
| { | ||
| registry.mark_provider_owned(&partner.source_domain); |
There was a problem hiding this comment.
❓ Should pull-sync sources keep accepting browser UIDs?
This marks every pull-sync partner's source as provider-owned. apply_browser_ingest (write_plan.rs:94) skips provider-owned sources, so ts-eids and sharedId UIDs for these sources are no longer stored. On the base branch and on main, a missing pull-sync partner UID was filled from the browser. I ran a scratch test that builds the registry with partner_registry and plans a write with a browser UID for the partner's source. The UID is stored with pull_sync_enabled = false and dropped with pull_sync_enabled = true.
For configured providers, ownership keeps a UID cleared by an opt-out from being refilled from the browser. Pull sync never returns LookupOutcome::OptedOut, so that reason doesn't apply here. The PR already splits batch_sync_rejected from provider_owned so that batch sync keeps working for these partners.
Is dropping browser ingest intended, for example so that pull sync still runs and its owned UIDs reach the mirror? If it is, please list it under "Behaviour changes from main". If it isn't, a matching split for browser ingest would restore the old fill.
|
|
||
| /// Returns all partners with `pull_sync_enabled = true`. | ||
| #[must_use] | ||
| pub fn pull_enabled_partners(&self) -> Vec<&PartnerConfig> { |
There was a problem hiding this comment.
♻️ Delete pull_enabled_partners; nothing calls it now
Its only caller was the legacy dispatcher in ec/pull_sync.rs, which this PR deletes. The only remaining use is its own test, pull_enabled_partners_filters_correctly (line 806). Please remove both.
Apply manually: the function and its test are in separate hunks.
|
|
||
| /// Whether the partner runs pull sync as a provider, making its source | ||
| /// provider-owned. | ||
| fn runs_pull_sync(partner: &EcPartner) -> bool { |
There was a problem hiding this comment.
♻️ Keep one definition of "runs pull sync"
pull_sync_enabled && pull_sync_url.is_some() now appears in three places: here, in config.rs:387, and as the filter pair in pull_sync_providers (trusted-server-identity-providers/src/lib.rs:73-77). These copies decide which sources become provider-owned, which double claims are rejected, and which providers are built. If one copy changes without the others, a source can become provider-owned with no provider to fill it.
Separately, prepare_after_response_lookups (app.rs:453) builds a full IdentityProviderConfig, cloning the URL, domains and token, on every navigation just to ask whether one exists.
Proposed fix (apply manually: spans settings.rs, registry.rs, config.rs, the providers crate and the Fastly adapter):
impl EcPartner {
/// Whether this partner runs pull sync as a provider, owning its source.
#[must_use]
pub fn runs_pull_sync(&self) -> bool {
self.pull_sync_enabled && self.pull_sync_url.is_some()
}
}
Use it in all three places, and in app.rs:
|| state.settings.ec.partners.iter().any(EcPartner::runs_pull_sync);
| .pull_sync_concurrency | ||
| .is_some_and(|max| pull_sync_dispatched >= max.get()) | ||
| { | ||
| pending.early.push(skip(SkipReason::RateLimited)); |
There was a problem hiding this comment.
🤔 The pull-sync cap is logged as skipped_rate_limited
A partner past pull_sync_concurrency and a partner over its hourly limit both log outcome=skipped_rate_limited. The logs therefore can't show that the cap is too low. This is a new way for a partner to go unpulled, since pull sync used to reach every eligible partner. A separate SkipReason, for example ConcurrencyCap logged as skipped_concurrency_cap, would make it visible.
| ### Pull Sync on Navigations | ||
|
|
||
| When the identity graph contains a UID for every pull-enabled partner, Trusted Server sets a signed, host-only `ts-ec-pull-complete` cookie. The cookie contains no partner UID or EC ID. It authenticates a one-hour expiration and a fingerprint of the current pull-partner source-domain set, bound to the active EC ID with key material derived from `ec.passphrase`. | ||
| Pull sync runs as an identity provider on top-level page navigations, after the response has been sent, as it did before it became a provider. It uses the after-response budget (`identity_after_response_budget_ms`), so a slow partner never delays the page, and its result reaches the row in the follow-up write and is served from the next page load. Like other after-response lookups, it never creates a row. A pull-sync partner's source is provider-owned, so browser EID ingestion skips it. Batch sync still accepts the partner's mappings, as before. A source that already holds any live UID, including a batch-synced one, is not pulled again; a source with no UID is pulled at most `pull_sync_rate_limit` times per hour per EC. On Fastly that limit is enforced as a per-minute budget of at least one, so values below 60 behave like 60 per hour. At most `pull_sync_concurrency` partners are pulled per navigation (0 means no limit), unlike before, when every eligible partner was pulled in batches of that size. The partner order rotates every hour, so partners past the cap still get turns. |
There was a problem hiding this comment.
⛏ Describe where pull sync runs now
"As it did before it became a provider" no longer holds. The post-send dispatcher ran after any real-browser response whose request had read the row. That included /auction (auction/endpoints.rs:345 loads it) and navigations matched by the Accept: text/html fallback, prefetches among them. Each pull had a 15-second first-byte timeout (DEFAULT_FIRST_BYTE_TIMEOUT). Now pull sync runs only on Fetch Metadata navigations that get the page-load permit, inside the shared after-response budget. Suggested wording:
| Pull sync runs as an identity provider on top-level page navigations, after the response has been sent, as it did before it became a provider. It uses the after-response budget (`identity_after_response_budget_ms`), so a slow partner never delays the page, and its result reaches the row in the follow-up write and is served from the next page load. Like other after-response lookups, it never creates a row. A pull-sync partner's source is provider-owned, so browser EID ingestion skips it. Batch sync still accepts the partner's mappings, as before. A source that already holds any live UID, including a batch-synced one, is not pulled again; a source with no UID is pulled at most `pull_sync_rate_limit` times per hour per EC. On Fastly that limit is enforced as a per-minute budget of at least one, so values below 60 behave like 60 per hour. At most `pull_sync_concurrency` partners are pulled per navigation (0 means no limit), unlike before, when every eligible partner was pulled in batches of that size. The partner order rotates every hour, so partners past the cap still get turns. | |
| Pull sync runs as an identity provider after the response has been sent, on top-level page navigations that send Fetch Metadata (`Sec-Fetch-Dest: document` and `Sec-Fetch-Mode: navigate`) and are not prefetches. Before it became a provider, it also ran after `/auction` responses and on navigations without Fetch Metadata. It uses the after-response budget (`identity_after_response_budget_ms`, shared with the other after-response providers), so a slow partner never delays the page; a partner that answers after the budget is not filled, where each pull used to wait up to 15 seconds for a first byte. Its result reaches the row in the follow-up write and is served from the next page load. Like other after-response lookups, it never creates a row. A pull-sync partner's source is provider-owned, so browser EID ingestion skips it. Batch sync still accepts the partner's mappings, as before. A source that already holds any live UID, including a batch-synced one, is not pulled again; a source with no UID is pulled at most `pull_sync_rate_limit` times per hour per EC. On Fastly that limit is enforced as a per-minute budget of at least one, so values below 60 behave like 60 per hour. At most `pull_sync_concurrency` partners are pulled per navigation (0 means no limit), unlike before, when every eligible partner was pulled in batches of that size. The partner order rotates every hour, so partners past the cap still get turns. |
| /// Pull sync runs after the response has been sent, as it did before it | ||
| /// became a provider, so a slow partner never delays the page and a new EC | ||
| /// whose row does not exist yet is never pulled. Its hourly rate limit is | ||
| /// the partner's `pull_sync_rate_limit` (`None` when it is 0). |
There was a problem hiding this comment.
⛏ The None case cannot happen
PartnerRegistry::from_config rejects pull_sync_rate_limit = 0 through validate_rate_limits, so NonZeroU32::new here always returns Some.
| /// the partner's `pull_sync_rate_limit` (`None` when it is 0). | |
| /// the partner's `pull_sync_rate_limit`, which partner validation keeps nonzero. |
Summary
__Host-ts-eids-mirrorcookie so publisher-side code can read them, on a best-effort basis.Stacked on #1487; review only the commits on top of it.
Changes
trusted-server-identity-providers/src/pull_sync.rs; removes coreec/pull_sync.rs/ec/pull_sync_marker.rsand the old post-send pull sync path in the Fastly adapter. Pull-sync providers are built withcollect = "after_response", so they only run for an EC whose consented row already existsec/mirror.rs;ec/finalize.rs,ec/cookies.rs,constants.rswrite the__Host-ts-eids-mirrorcookieapi_tokenkeeps batch syncSet-Cookie, so returning visitors' pages stay cacheablekind = "pull-sync"in[[ec.identity_providers]]is rejected; pull sync comes only from partner config with its token and rate limit__Host-ts-eids-mirrorjoins the identity cookies the template cache key refusesdocs/guide/edge-cookies.mdandconfiguration.mdBehaviour changes from
mainidentity_after_response_budget_ms, and only for an EC whose consented row already exists.pull_sync_concurrencyis a cap per navigation. It used to be a batch width with every eligible partner pulled; partners now rotate hourly so each gets turns under the cap.pull_sync_rate_limitis enforced per minute with a minimum of one on Fastly, so the default of 10 per hour behaves like 60.bidstream_enabledpartners they reach the script-readable mirror cookie. Previously they lived only in KV.Closes
Part of #1246 and #1245; the provider implementations that close them come in follow-up PRs.
Spec
Spec: reviewed separately; not included in this PR.
Test plan
cargo test-fastly && cargo test-fastly-reuse && cargo test-axum && cargo test-cloudflare && cargo test-spincargo fmt --all -- --checkscripts/lint-wasm-clock.sh./scripts/test-cli.sh, domain lint against the base branch, CI Node pin guard, docs-proposal scriptsfastly compute serveChecklist
unwrap()in production code — useexpect("should ...")logmacros (notprintln!)