rfc: 0060 title: Background template-map refresh status: red author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-10-13 supersedes: — superseded-by: —
RFC 0060 — Background template-map refresh
Status:
red. RFC0060.1–.7, .10 and .11 pass: inourios-querier(tests/it/rfc0060_refresh.rs,tests/rfc0060_refresh_observability.rs) andourios-server(tests/it/rfc0060_refresh_config.rs). RFC0060.8’s span shape is asserted with .1; its weaver live-check ofacquire.triggeris still an#[ignore]dtodo!stub, the one open criterion beforegreen. RFC0060.9 is a Dash0 measurement after release, not a CI test (§6). Specified 2026-10-13 (#990, maintainer-approved: the RFC 0033 §4 reversal, the two telemetry names and the §7 decisions as recorded below; checked against the OpenTelemetry docs). This RFC adds a querier background task on top of RFC 0033’s acquisition path. It changes no artifact format, fold rule or publish rule of RFC 0033 (accepted), so it is a new RFC rather than an amendment. It revisits an alternative RFC 0033 §4 rejected, “a background refresher”, on new evidence (§2.1). If this RFC is accepted, RFC 0033 §4 gets a dated pointer here.
Amendment (2026-10-16, #1008, maintainer-approved) — a pass skips the artifact read its listing proves unnecessary. Adds §3.7: a pass remembers, per tenant, the frontier and artifact
ETagof its last verified hit. When its next listing shows the same frontier and the sameETag, it stops there instead of reading the artifact again. The skipped read could only have been a hit, so the freshness argument of §2.1 holds for every change that completed before the listing. A change that lands between the listing and the skipped read is repaired one pass late (§3.7). Queries are untouched. It does not shrink the per-tenant listing itself, which is most of a pass’s requests on a tenant with a long audit history. §7 records the maintainer’s decisions on that and on telemetry. In-place notes in §3.3, §3.5 and §6, and an amended RFC0060.10 (a hit or publish no longer forces an acquisition on the next pass, only a listing). New scenarios RFC0060.12–.16 (drafted); §9 records the history. The frontmatter status and RFC0060.1–.9 and .11 are unchanged.
1. Summary
The querier refreshes each tenant’s template map in the background:
- once at start;
- then on a fixed interval;
- one tenant at a time;
- through the same per-tenant single-flight a query acquires through
(
map_flight).
The full folds that RFC 0033 still runs are paid by the background task
in most cases, not by a user query. These are the no_fold_state
bootstrap, the depth cap, and the files_removed / out_of_order
fallbacks. A query that arrives while the task is folding its tenant
joins that fold instead of starting a second one. The task never fails
the process, is bounded to one acquisition at a time, and is off when
configured off.
2. Motivation
RFC 0033’s guarded incremental fold (§3.3 amendment 2026-10-12, #897) made a stale refresh cheap. On the production node, over the first 24 h of v0.13.0:
- 24 stale derives were incremental, p95 0.45 s;
- 2 derives were full folds: the one-time
no_fold_statebootstrap, one per tenant; - one of those took 49 s and 5,726 GETs, inside a user’s
POST /v1/query.
A querier restart is no longer cold: the artifact lives in the store, so the first query after a restart is a hit or an incremental refresh.
Full folds still happen, and each lands on whichever query asks first:
- Bootstrap. The first acquisition after an artifact-format change
(
no_fold_state,unknown_version) or after a torn artifact. - Depth cap. Every
INCREMENTAL_DEPTH_CAP(256) published incremental steps (RFC 0033 §3.3). An abstained, failed or lost publish leaves the stored depth unchanged. If every one of the 24 incremental derives a day observed above published, that is one cap fold per tenant per ten to eleven days. That is an upper bound on the frequency: each unpublished step makes the interval longer. - Fallbacks.
files_removed(audit objects removed, e.g. by retention) andout_of_order.
For a short-timeout client a 50 s fold is an outage: the client gives
up, the fold completes for the next query (map_flight), and the user
saw an error. This was the failure mode in #853. Moving that cost to a
task nobody waits on is cheaper than making every client patient.
2.1 Revisiting RFC 0033 §4
RFC 0033 §4 rejected a background refresher. Its reasoning was that the refresher adds a scheduling component and a staleness-window policy “for something the write-through gets for free at the moment of demand”. Two things have changed since:
- The moment of demand is not free. A full fold costs a user up to 50 s, and since #897 every remaining full fold is one that some query pays for (above).
- No staleness window is introduced. The task only pays early. It calls the unchanged acquisition, whose frontier check still decides freshness for every query, so the RFC 0033 correctness argument is untouched. A query never reads a map older than its own listing.
What remains is the scheduling component, the cost this RFC proposes to pay.
Why this layer. The querier already owns acquisition, single-flight and publish. A separate process or the ingester would need its own copy of all three and would compete with the querier’s CAS publish. RFC 0033 §4 rejected ingester-side write-through for the same reasons (fold code on the ingest path, publishing for tenants nobody queries).
3. Proposed design
3.1 The task
When the querier role starts, it spawns one tokio task, template-map refresh. Each pass:
- lists the tenants that have an audit stream, one delimited
LISTofaudit/taking thetenant_id=common prefixes, decoded with the partition key’s tenant encoding (the inverse ofpercent_encode_tenant); - for each tenant, in listing order, calls the same
TemplateMapFlights::acquire_fetcheda query uses, and waits for it to finish before starting the next tenant.
The first pass starts immediately at role start. Each later pass starts
interval after the previous pass finished, so passes never
overlap. Nothing new is decided: a background acquisition takes the
usual path (a hit, an incremental fold, or a full fold), publishes
through the usual write-through, and is subject to every RFC 0033 rule.
3.2 Interaction with queries
The background acquisition is a flight like any other:
- a query for the same tenant and frontier joins it and records
joined; - a query at a different frontier waits for it and lists again
(
map_flightsemantics).
So a query never runs a fold concurrently with the background task for its tenant. A query that arrives during a 50 s background fold still waits for the rest of it. The task narrows that window rather than closing it: it only removes the case where the query is the first to ask. §7 lists pre-emptive full folds as an open question.
3.3 Bounds
-
Concurrency. One background acquisition at a time, for the whole process. Its peak memory is one fold’s (the RFC 0058 / #853 bound), never a sum over tenants.
-
Shutdown. The task stops at the next tenant boundary once shutdown begins. An acquisition already on the blocking pool runs to completion, as a dropped query’s does (
map_flight); shutdown does not wait for it. -
Failure. A failed acquisition is recorded (§3.5), and the pass moves on to the next tenant. A failed tenant listing ends the pass; the next pass retries. Neither failure stops the task or the process.
-
Cost on an idle tenant. A tenant with a current artifact costs one audit
LISTplus one artifactGETper pass (a hit). With the default interval that is negligible beside the ingest path’s own requests. It is still paid per tenant, hence §7’s question about large tenant counts.Amendment (2026-10-16, #1008, maintainer-approved). Measured on the production node (one pass, 2026-10-16, five idle tenants): one delimited
LISTofaudit/, then per tenant oneLISTper 1,000 audit objects (8 and 6 pages for the two oldest tenants, 1 for the rest) and one artifactGET. That is 23 requests a pass, about 6,600 a day at the default interval, and theLISTpages grow with each tenant’s audit history. §3.7 removes the artifactGETfrom a pass whose listing proves the tenant unchanged (5 of the 23). TheLISTpages stay; see §7. -
Back-off when nothing persists. An acquisition that ends without a publish and without a hit persists nothing. That covers a write-through abstention (RFC 0033 §3.2 size gate or ceiling, which may recur indefinitely), a failed publish, and a failed acquisition. Re-running it each pass would repeat the fold and its
GETs everyintervalfor no later query’s benefit. So afterkconsecutive such acquisitions, the task skips that tenant for the nextmin(2^k − 1, 11)passes. The cap counts passes, not wall-clock time: at the default interval that is 11 ×intervalplus the skipped passes’ own run time. A hit, a publish of its own, or a lost CAS race (someone else published) resetskto 0. The counter lives in process memory and resets on restart. A successful tenant listing drops the counters of tenants it no longer names, so the state follows the current tenants, not their history; a failed listing keeps them, since it names no current set. A query is never skipped: it acquires exactly as it does without the task.
3.4 Configuration
These keys sit under querier.template_map.refresh.* (RFC 0020;
${env:…} substitution as usual):
| key | type | default | meaning |
|---|---|---|---|
enabled | bool | true | spawn the task |
interval | duration | 5m | idle time between passes; must be ≥ 10s |
enabled: false restores today’s behaviour exactly. As with
compaction.enabled / interval, interval is parsed and validated
only when the task is enabled, so an unused bad value never blocks
startup. When enabled, an interval that is not a duration, or is
below the floor, is a startup configuration error naming the key. It
is not silently clamped, as RFC 0020 validates every other resolved
value.
3.5 Observability (CLAUDE.md §6.3)
- Span. Each pass is one root
INTERNALspan,refresh template_map. It has no remote parent or child, soINTERNALis the kind, and the name follows OTel’s{action} {target}pattern besideload template_map. Each tenant’s acquisition is the existingload template_mapphase span, carryingourios.tenant, with its CLIENT spans beneath it, nested under the pass as it nests underPOST /v1/querytoday (RFC 0038 §3.7.2). Under the defaultparentbased_always_onsampler these roots are always sampled. - Errors (OTel Recording errors). A tenant’s failed acquisition
sets error status and
error.typeon that tenant’sload template_mapspan. The pass handled it and continued, so the pass span and the pass sample carry no error for it. Only a pass that ends early (a failed tenant listing) setserror.type, on both the pass span and itsrefresh.durationsample, with the same value. So the span and its duration metric describe the same operation, as OTel recommends. - Metrics. The RFC 0033 lookup and derive instruments gain one
attribute,
ourios.template_map.acquire.trigger=query|refresh. This splits the existing series so a dashboard can tell a fold a user paid for from one the task paid for. The criterion of this RFC is thatquery-triggered full folds approach zero. - Pass. One histogram,
ourios.template_map.refresh.duration(s), one sample per pass, named per OTel’s{operation}.durationrule. Tenants and tenant counts are not attributes: OTel’s cardinality guidance keeps ID-like values off metrics, and they are on the spans.
ourios.template_map.acquire.trigger follows the precedent of the
upstream faas.trigger (“type of the trigger which caused this
invocation”). Its own values (query, refresh) are more precise here
than faas.trigger’s http / timer: a query can arrive over HTTP or
MCP, and the start pass is not a timer. With two values it at most
doubles the existing series.
ourios.template_map.acquire.trigger and
ourios.template_map.refresh.duration are new names, registered in
ourios-semconv v0.3.17 under the purpose-check and
maintainer-approval rule, after an OTel naming-guidance check.
Amendment (2026-10-16, #1008, maintainer-approved). A tenant that §3.7 skips still gets its
load template_mapspan, with the listing’sCLIENTspans beneath it and noS3.GetObject. It carries noourios.template_map.lookup.outcomeand records nolookupssample: no artifact was looked up. The effect is measured by the existingourios.storage.operation.duration{ourios.storage.caller=template_map, ourios.storage.operation=get}count and theS3.GetObjectspans. No new name; §7 asks whether a registeredunchangedoutcome is wanted.
3.6 Multi-tenancy and replicas (CLAUDE.md §3.7)
Each acquisition is per tenant, as today. Several querier replicas each run their own task. Their publishes are CAS writes, so the worst case is duplicated fold work, never a torn or regressed artifact (RFC 0033 §3.4). Coordinating replicas is out of scope (§7).
sequenceDiagram
participant T as refresh task
participant F as map_flight
participant Q as user query
T->>F: acquire(tenant A)
Note over F: full fold (bootstrap / depth cap)
Q->>F: acquire(tenant A), same frontier
F-->>Q: joined (waits for the fold)
F-->>T: map, published
T->>T: next tenant
3.7 Skipping a read the listing proves unnecessary (amendment 2026-10-16, #1008, maintainer-approved)
Every acquisition starts with one recursive listing of the tenant’s
audit prefix (RFC 0033 §3.3). The artifact lives under that prefix, so
the listing already returns it, with its ETag, next to the audit
objects. Today the listing keeps only the *.parquet keys. A pass now
also keeps the artifact’s listed ETag, and uses it like this.
What a pass remembers. Per tenant, at most one verified listing: a
frontier F and an artifact tag T. It is recorded only when the
pass’s own acquisition was a hit (RFC 0033 §3.3: the artifact
decoded Valid with folded_files == F, the listed frontier), and
the artifact GET returned the same tag the listing showed. Nothing
else records one: a publish (the new object’s tag is not known
without a read), a lost CAS race, an abstention, a failure, or a
joined flight. Any of those, or an error, drops the tenant’s verified
listing.
What a pass skips. Before the acquisition it would start, a pass
compares its listing against the tenant’s verified listing. If the
listed frontier equals F and the listed artifact tag equals T, the
pass stops: no artifact GET, no flight, no derive, no publish. Any
difference (a new audit object, a removed one, a replaced, removed or
unlisted artifact) runs the acquisition exactly as §3.1 describes,
against the same listing.
Why the skip changes nothing a query sees. The skipped acquisition
would have read the artifact the listing shows. That object has tag
T, and an object store’s ETag names one object content, the
identity RFC 0033 §3.4’s CAS publish already relies on. So the read
would have returned the bytes the verified hit read, which decode to a
Valid map at F, which is the listed frontier: a hit. A hit derives
nothing and publishes nothing. So the store after a skip is exactly the
store after the acquisition it replaces, and §2.1’s argument holds
unchanged: the task still never serves anything, and every query still
lists and reads for itself. S3’s listings are strongly consistent, so
the listing reflects every write and delete that completed before it,
by any process: another querier replica, an ingester elsewhere, or a
bucket lifecycle rule. That is why the proof needs no in-process
signal and holds in multi-process deployments.
The one interleaving it does not cover. The proof is about the
store as the listing saw it. The artifact GET is not pinned to the
listed version: it reads whatever is at the key when it runs. If
another process replaces or deletes the artifact after the listing and
before that GET, the acquisition the skip replaces would have read the
new state. On a deletion it would have folded and published. The skip
does neither. So “exactly the store after the acquisition it replaces”
holds for every change that completed before the listing, and not for
one that lands between the listing and the would-be GET. That change
is in the next pass’s listing (a different tag, or no artifact) and is
repaired there. The repair is late by at most one pass, interval plus
the pass’s run time. This RFC accepts that delay. Query correctness
does not depend on it: a query in that window lists and reads for
itself, and pays the fold that the old pass might have paid. The same
window already exists without §3.7 for a change that lands just after a
pass’s GET. §3.7 moves the window’s start from the GET to the
listing, a few milliseconds earlier. RFC0060.16 states the behaviour.
Tags. On the S3 backend the tag is the object’s ETag, compared
after stripping one pair of surrounding double quotes, since some
S3-compatible stores spell it quoted in one response and unquoted in
the other. A weak (W/) tag never equals a strong one. A store that
lists no ETag never skips. On the local backend the tag is the
file’s size, modification time (nanoseconds) and, on Unix, inode. The
listing takes it with a stat. The read takes it from the open file
handle it reads through, so it names the bytes read. A local publish
installs a fresh temp file by rename (RFC 0033 §3.4), which changes
the inode.
Bounds. One verified listing per tenant, the size of one frontier.
That is the same size as the listing a pass already holds for that
tenant. It lives in process memory and resets on restart, so a new
binary (and a new artifact format) always reads. A successful tenant
listing drops the verified listings of tenants it no longer names, as
§3.3 does for the back-off counters. Queries never consult it: a query
needs the map itself, and map_flight keeps no map once a flight ends.
What it saves, and what it does not. On the production node
(2026-10-16) the skip removes the artifact GET from about 1,150 of
the 1,255 daily refresh acquisitions (the hits), together with their
bytes. One tenant listing is still paid per tenant per pass, at one
LIST request per 1,000 audit objects. On a long-lived tenant those
pages are most of the cost (§3.3 note). Cutting them needs a signal
weaker than a listing, or a smaller listing. §7 lists the options; each
needs a maintainer decision.
4. Alternatives considered
Warm only at start. Same task, one pass, no interval. Simpler, but since #897 a restart is not the cold case: the artifact survives in the store. The depth-cap and fallback full folds still land on queries. Rejected as covering only the case that has already been fixed.
Pre-emptive full fold before the depth cap. The task would fold in full when a tenant’s depth nears the cap, so the cap is never reached on the query path. This needs a new rule in RFC 0033’s derive policy (fold in full below the cap, but only off the query path). Deferred to §7 so the task lands first and Dash0 can show whether cap folds still reach queries.
Serve stale while revalidating. Answer a query from the stale artifact and fold in the background. This breaks RFC 0033’s guarantee that a query renders against a map at its own frontier: rows written after the artifact would render against missing or old bindings (CLAUDE.md §3.3). Rejected.
Raise client timeouts. Not ours to control, and it hides a 50 s fold instead of removing it from the request.
Fold in the ingester. See §2: the ingester does not own the read path, and a second publisher contends for the CAS.
5. Acceptance criteria
Scenario RFC0060.1 — A start pass absorbs the bootstrap
- Given a store whose tenant has an artifact without
fold_state, and a folded map the write-through publishes (not abstained, no IO error)- When the querier starts with refresh enabled, and a query arrives after the first pass has finished
- Then the full fold ran with
acquire.trigger = refresh- And the query’s lookup is a
hitwithacquire.trigger = query, and runs no fold.
Scenario RFC0060.2 — A query during a background fold joins it
- Given the task holding a tenant’s flight on a gated fold
- When a query for that tenant at the same frontier arrives
- Then exactly one fold runs, and the query records
joinedand receives the task’s map- And the query’s
joinedlookup carriesacquire.trigger = refresh: the attribute names who initiated the shared acquisition, not who recorded the sample.
Scenario RFC0060.3 — One background acquisition at a time
- Given three tenants, each with a gated fold
- When a pass runs
- Then at most one background acquisition is in flight at any instant, and the tenants are visited in listing order.
Scenario RFC0060.4 — Failure is contained
- Given two tenants, the first with an unreadable audit object
- When a pass runs
- Then the first tenant’s
load template_mapspan carries error status anderror.type- And the second tenant is still acquired, under the same pass span
- And the pass span and its
refresh.durationsample carry noerror.type, and the task runs its next pass- And a pass whose tenant listing fails ends early with the same
error.typeon its span and its sample.
Scenario RFC0060.5 — Disabled means today’s behaviour
- Given
querier.template_map.refresh.enabled: false- When the querier starts and idles past
interval- Then no acquisition runs, and no
refresh template_mapspan or refresh sample is emitted.
Scenario RFC0060.6 — Configuration is validated
- Given refresh enabled with
interval: 1s, or a non-duration value- When the configuration is resolved
- Then startup fails with an error naming the key
- And with
enabled: falsethe same values resolve without error.
Scenario RFC0060.7 — Shutdown is prompt
- Given a pass between tenants, or waiting on its interval
- When the querier shuts down
- Then the task exits without starting another acquisition
- And given a pass whose acquisition is on the blocking pool, gated, when the querier shuts down, then the task exits without waiting for the gate, and once the gate is released the flight still completes and publishes (it is not cancelled).
Scenario RFC0060.8 — Telemetry is registry-conformant
- Given RFC0060.1’s run
- When its spans and metrics are exported
- Then the pass is one root
INTERNALspan namedrefresh template_map, with each tenant’sload template_mapspan beneath it, asserted through the in-memory span exporter (span names are not registry entries)- And
acquire.triggerandrefresh.durationpass weaver live-check against the ourios-semconv registry.
Scenario RFC0060.9 — Query-paid full folds drop (Dash0, after deploy)
- Given the node on the release that ships this RFC, and the previous release, each over a 7-day window
- When full folds are counted, normalised per tenant-day
- Then
derive.duration{mode=full, acquire.trigger=query}on the new release is below the previous release’smode=fullcount. A query that joins a background fold records no derive of its own, so the remainingqueryfull folds are those where a query became the acquirer before the pass reached its tenant.
Scenario RFC0060.10 — A tenant that persists nothing is backed off
- Given a tenant whose write-through abstains on every acquisition
- When consecutive passes run
- Then the task acquires it on passes 1, 3 and 7 (skipping 1, then 3), and on no more than one pass in 12 thereafter
- And a query for that tenant still acquires on every request
- And after a hit or a publish the tenant is acquired on the next pass again
- And once a successful listing no longer names the tenant, its counter is dropped; a failed listing drops nothing.
Amendment (2026-10-16, #1008, maintainer-approved). The second And becomes: after a hit or a publish, back-off no longer skips the tenant. The next pass lists it, and acquires it unless §3.7’s skip applies; RFC0060.12 says when it does. After a publish the skip never applies on the next pass, since a publish leaves no verified listing (RFC0060.14), so the existing test’s “a hit, on the next pass” after a publish still holds.
Scenario RFC0060.11 — Passes are spaced from their end, never overlap
- Given paused time,
interval: 5m, and a pass whose fold is gated for longer thaninterval- When the gate is released
- Then no second pass starts while the first is running, and the next pass starts
intervalafter the first pass finished, with no catch-up pass in between.
The scenarios below are added by the 2026-10-16 amendment (§3.7, #1008)
and are drafted.
Scenario RFC0060.12 — An unchanged listing skips the artifact read
- Given a tenant whose last pass acquired a hit, with the artifact read’s tag equal to the listed one
- When the next pass lists the same frontier and the same artifact tag
- Then that pass reads no artifact and starts no flight, and records no
lookupsorderive.durationsample for the tenant- And a query for the tenant afterwards still lists and reads the artifact, and records a
hitwithacquire.trigger = query.
Scenario RFC0060.13 — Any change in the listing reads again
- Given a tenant with a verified listing
- When before the next pass (a) a new audit object appears, (b) an audit object is removed, (c) another writer replaces the artifact with a valid one at the same frontier whose tag differs from the verified one, or (d) the artifact is removed
- Then that pass runs the acquisition: (a) and (b) are
stalelookups and fold, (c) is ahitthat records a new verified listing, and (d) is amissthat folds in full and publishes- And in every case the store afterwards equals what the acquisition would leave without §3.7
- And a replacement with the same bytes that keeps the same
ETag(a single-part S3PUTof identical content) is not a change: the pass skips it, correctly, since a read would return the same bytes.
Scenario RFC0060.14 — Only a verified hit is remembered
- Given a tenant whose pass acquisition publishes (a stale or missing artifact), abstains, fails, or reads an artifact whose tag differs from the listed one
- When the next pass that §3.3’s back-off does not skip for that tenant lists the same frontier (the very next pass after a publish or a mismatched read; a later one after an abstention or failure)
- Then it reads the artifact (no skip), and only after that read is a hit with matching tags does a later pass skip
- And the back-off is unchanged by §3.7: an abstention or failure still skips the passes §3.3 says, and drops the verified listing
- And once a successful tenant listing no longer names a tenant, its verified listing is dropped; a failed listing drops nothing.
Scenario RFC0060.15 — The skip on S3, and its spans
- Given the S3 backend and a tenant with a verified listing
- When a pass skips it
- Then its
load template_mapspan has itsS3.ListObjectsV2children and noS3.GetObjectchild, and carries noourios.template_map.lookup.outcome- And after another writer
PUTs an artifact with different bytes (so a differentETag), the next pass has anS3.GetObjectchild again.
Scenario RFC0060.16 — A deletion after the listing is repaired one pass late
- Given a tenant with a verified listing
- When another process deletes the artifact after a pass’s tenant listing and before the pass decides
- Then that pass skips: it reads nothing and publishes nothing
- And a query between the two passes answers correctly. Its lookup is a
miss, it pays the fold itself, and its usual write-through (RFC 0033 §3.5) may republish the artifact- And if the artifact is still absent at the next pass’s listing, that pass acquires a
miss, folds and publishes- And if a query republished it instead, the next pass reads the artifact the listing shows. Should that artifact restore the verified frontier and tag, the pass may skip under §3.7; a hit is not required.
6. Testing strategy
- RFC0060.1–.4, .7, .10 and .11 are integration tests (RFC0060.11
under tokio paused time) in
ourios-querierover a local store, using themap_flighthooks (before_acquiregating, acquisition and join counters) through thetestingfeature. - RFC0060.5–.6 are
ourios-serverconfig tests (RFC 0020 style) plus one served-role test. - RFC0060.8 asserts span names and nesting through the in-memory span
exporter, and the registry names through the weaver live-check
harness (
live_check). - RFC0060.9 is a Dash0 before/after measurement recorded on the release (the RFC0033.14 precedent), not a CI test.
No proptest or corpus test applies: the task adds scheduling, not
fold semantics, and the fold’s own property tests (RFC0033.10–.13)
already cover what it calls.
Amendment (2026-10-16, #1008, maintainer-approved). Test map for §3.7:
Scenario Test Where RFC0060.12 rfc0060_12_an_unchanged_listing_skips_the_artifact_readourios-queriertests/it/rfc0060_refresh.rs, local backend, acquisition counter + lookup metricsRFC0060.13 rfc0060_13_any_change_in_the_listing_reads_againsame file; (a)–(d) as four cases over one store RFC0060.14 rfc0060_14_only_a_verified_hit_is_rememberedsame file, plus unit tests of the tag comparison in map_refresh/audit_scanRFC0060.15 rfc0060_15_the_skip_on_s3_reads_no_artifactsame file, #[ignore]d, run by thes3-integrationCI job (LocalStack)RFC0060.16 rfc0060_16_a_deletion_after_the_listing_is_repaired_one_pass_latesame file; a test hook deletes the artifact right after the tenant listing The local backend’s tag is exercised by .12–.14; the S3
ETagpath, including the listing that carries it, by .15.
7. Open questions
Decided for this RFC (maintainer, 2026-10-13); each is revisited only on the Dash0 evidence named:
- Pre-emptive full fold near
INCREMENTAL_DEPTH_CAP: deferred. Cap folds come at most once per 256 published incremental steps per tenant (about ten days at the observed rate if every step published, rarer otherwise), so a 5-minute pass almost always reaches them first. Reopen, as an RFC 0033 derive-policy amendment, ifderive.duration{mode=full, acquire.trigger=query}stays above zero after RFC0060.9. - Large tenant counts: not in v1. Every tenant is visited each
pass. The preferred later shape is to refresh only tenants queried
within a window. Reopen when
refresh.durationapproachesinterval. - Replicas: accept duplicated folds. CAS publish keeps them correct (§3.6). A CAS lease object for a single refresher is the later shape, when replicas are deployed.
- Default
interval:5m. Revisit with publish and fold rates after release.
Decided for the 2026-10-16 amendment (#1008, maintainer, 2026-10-16):
- The listing pages (most of a pass’s cost): compact the audit
stream, in a follow-up RFC. Option 1 below, not part of this
amendment. Options 2–5 are not taken. §3.7 is the only skip a
listing can prove. The options considered for fewer
LISTpages: 1. Shrink the listing: compact the audit stream (chosen). Pages grow with audit objects, and the stream writes many small objects (one per audit flush and one per compaction event, RFC 0005 §3.7). Merging a tenant’s closed audit days into a few objects shrinks every listing, the refresher’s and every query’s, and the full folds too. It changes no freshness rule. It is an RFC 0005 §3.7 and RFC 0033 change (merged objects change the frontier, so it must not cost a full fold each time), and so a separate RFC. 2. Probe recent days, list in full everykpasses. A pass lists only the current and previous UTC day of each tenant, and lists in full everyk-th pass or when the probe shows a change. Most passes cost one page per tenant. But it misses a backdated audit object (a WAL replay) or a removed one (a lifecycle rule) for up tok × interval. In that window a query pays theout_of_orderorfiles_removedfull fold that the refresher exists to absorb. That weakens §2.1’s guarantee to “withink × interval” for those two causes. 3. Back off unchanged tenants. Lengthen a tenant’s interval while its listing stays unchanged, as §3.3 does for tenants that persist nothing. This weakens freshness the same way as 2, for every cause. It also saves little where it matters: the tenants with many pages are the active ones, which move every few passes. 4. A per-tenant change marker. Every audit writer rewrites a small marker object after each audit write, and a passHEADs it instead of listing. This is a new object in the store layout (CLAUDE.md §3.5), adds aPUTto each audit write, and cannot see removals by a lifecycle rule or writes by older binaries, so it still needs a periodic full listing. Same weakening as 2. 5. An in-process signal from the local ingester (combined role). It sees only this process’s writes: not another ingester, a lifecycle rule, or a separate-process deployment. Not a proof, so it is rejected as the basis of a skip. - An
unchangedlookup outcome: not now. §3.5’s note records a skip only through the storage counters and the span shape. Registeringunchangedas a value ofourios.template_map.lookup.outcomewould let a dashboard count skips directly. That is a new registry value under the purpose-check rule. Recommended: not now; theGetObjectcount is the quantity #1008 is about.
8. References
- RFC 0033 (cached template map): §3.3 freshness and the incremental fold, §3.4 CAS publish, §3.5 write-through, §3.7 instruments, §4 (the background refresher and ingester write-through alternatives).
- RFC 0038 §3.7 (query-phase spans), RFC 0020 (configuration), RFC 0005 (partition layout), RFC 0058 (query resource limits).
- #853 (cold map / RSS), #897 (incremental fold), #934 (resumable bootstrap), #990 (this RFC’s issue).
- CLAUDE.md §3.3, §3.7, §6.3.
9. Amendment history
- 2026-10-16 — a pass skips the artifact read its listing proves
unnecessary (#1008, maintainer-approved 2026-10-16). Adds
§3.7: a per-tenant verified listing (frontier and artifact tag),
recorded only on a hit whose read tag equals the listed one; a pass
whose listing matches it reads nothing. In-place notes in §3.3 (the
measured per-pass request count), §3.5 (no new name; the skip’s span
shape) and §6 (test map). Amends RFC0060.10: after a hit or a
publish the next pass lists the tenant and acquires it unless §3.7’s
skip applies. States the one interleaving §3.7 does not cover, a
change between the listing and the skipped read, and accepts its
one-pass repair delay. New scenarios RFC0060.12–.16. Two §7
decisions: the listing pages are cut by compacting the audit stream
(a follow-up RFC), and there is no
unchangedlookup outcome for now. RFC0060.1–.9, .11 and the frontmatter status are unchanged. Review (Copilot, #1028): the race qualification and RFC0060.16, the RFC0060.10 amendment, a different tag in RFC0060.13 (c), and the back-off-eligible pass in RFC0060.14.