Proposed server2 origin: S3 and bounded forecast memory
-
+
D2 forecast memory
Grid / RSS / Mid complete views; other owners still run
+
12 partition lookups read ahead, consumed in order
128 MiB retained; 48 MiB per entry; logical, not RSS
Epoch plus active writer fence; no stale generation install
Local CPU / queue limits; global integration remains open
diff --git a/docs/architecture/diagrams/server2.json b/docs/architecture/diagrams/server2.json
index 1e5180f65..74aa3ffdc 100644
--- a/docs/architecture/diagrams/server2.json
+++ b/docs/architecture/diagrams/server2.json
@@ -8,7 +8,7 @@
"locale": "en",
"repository": {
"url": "https://github.com/WizardFactory/TodayWeather",
- "revision": "f1a0e0940f14341b62138f8bf17f568f1c061cfb",
+ "revision": "8946cecd899228b8585bfc2c16de3fd6f1b20321",
"provider": "github",
"link_mode": "web"
}
@@ -153,13 +153,13 @@
{
"path": "server2/src/domestic/kma/source/forecast_cache.rs",
"line": 21,
- "end_line": 321,
- "label": "Forecast limits and fresh generation lookup"
+ "end_line": 618,
+ "label": "Limits, lookup and ordered partition read-ahead"
},
{
"path": "server2/src/domestic/kma/source/forecast_cache.rs",
- "line": 856,
- "end_line": 901,
+ "line": 874,
+ "end_line": 921,
"label": "Actual writer lifetime invalidates generation"
},
{
@@ -308,6 +308,7 @@
"title": "D2 forecast memory",
"items": [
"Grid / RSS / Mid complete views; other owners still run",
+ "12 partition lookups read ahead, consumed in order",
"128 MiB retained; 48 MiB per entry; logical, not RSS",
"Epoch plus active writer fence; no stale generation install",
"Local CPU / queue limits; global integration remains open"
diff --git a/docs/architecture/server2.md b/docs/architecture/server2.md
index bc0ac6f41..6e02e0aae 100644
--- a/docs/architecture/server2.md
+++ b/docs/architecture/server2.md
@@ -4136,3 +4136,68 @@ per request; no valid-zone recovery is claimed. Cold observation stubs are denie
These are local source/cache results, not complete API parity, host p95 or an
eligible rollback release. See the [operator guide](../operations/server2-domestic-cache.md)
and [selected verification](../evidence/tasks/issue-2819-domestic-warm-cache/verification.md).
+
+### D2 empty-cache reads (#2849)
+
+An empty forecast cache rebuilds a view from every partition in its lookback; the
+Seoul fixture town needs about 700 partition lookups (about 2,220 forecast GETs).
+D2 read them one at a time through a forecast store that admitted two lookups, so
+one request grew by about 2 s per millisecond of S3 latency. The
+[cold-read sequence](diagrams/server2-cold-read.html)
+([editable source](diagrams/server2-cold-read.sequence.json)) shows the change.
+
+- A view build keeps up to 12 partition lookups (`READ_AHEAD`) in flight and
+ consumes them in ascending partition order. Retained acquisitions, the scope
+ caps (1,024 partitions, 2,048 acquisitions, 8,192 pages, 1 MiB pages, 32 MiB raw)
+ and the first error therefore equal the sequential read; an error, a cap or the
+ owner deadline drops the lookups still in flight.
+- The forecast store admits 12 lookups and 12 raw loads (a raw load beyond its
+ permits is refused, not queued) and bounds each lookup to 4 MiB of records and
+ 1 MiB of catalog and descriptor bytes. With one compressed body per load, its
+ in-flight bytes over all views are at most 12 × (4 + 1 + 1.0625) MiB = 72.75 MiB,
+ below D2's 2 × (32 + 8 + 1.0625) MiB = 82.125 MiB; one view may now hold all of
+ it (D2: 41.06 MiB). A partition above 4 MiB is a `Capacity` error; fixture
+ partitions decode to at most about 0.2 MiB. The 12-permit process S3 gate is
+ unchanged and shared, so a cold build can occupy it while it reads.
+- A Doc keeps its exact weight once known (bounded field sets maintain it; other
+ changes and clones start unknown), so a store sums one cached weight per
+ retained Doc instead of walking every Doc's fields: full Doc walks now grow
+ linearly with the stores (768 for 256 stores; D2 67,840). The per-store sum
+ still reads every retained Doc's cached weight. The same data weighs 8 bytes
+ more per Doc and per stored field. Grid row reads compare date and time
+ without copying.
+- A retiring owner returns its 192 MiB workspace before its slot wakes the next
+ owner. D2 released the slot first, and an owner woken on another worker could
+ fail with `forecast cache: raw storage: Capacity` under load.
+
+Owner, queue, waiter, workspace, freshness and lookback limits are unchanged. Local
+evidence (macOS, owned loopback peer with modeled per-operation latency): 130
+partitions at 10 ms take 0.55 s with 12 lookups in flight against a 3.9 s
+sequential estimate; the fold of `short` falls from 1,171 to 687 ms and `shortest`
+from 418 to 297 ms with all 13 golden Docs unchanged. An empty-cache request at 0 ms
+takes 1.10 s instead of 1.78 s in a fresh process and 1.03 s instead of 1.52 s in a
+running server whose views went stale. At a modeled 10 ms the candidate's views are
+ready after 3.1 s in a running server (one of five such requests answered 200, at
+a client-measured 3.003 s) and after 4.7 s in a fresh process, while D2's owners stop at their 8-second
+bound with at most one of seven views; at 20 ms the running-server rebuild needs
+5.4 s. Through
+the 12-permit process gate, a fresh request's 3,628 S3 operations cannot finish in
+under about 3.0 s at 10 ms, and a running-server rebuild's 2,245 in under about
+1.9 s, so the 2.5-second goal needs fewer S3 operations per view or more S3
+concurrency, both separate decisions.
+
+On the Seoul t3.small (2 vCPUs, Unlimited; same loopback peer on the same host, so
+the peer shares the CPUs) no binary answered an empty-cache request inside 3 s at
+0, 10 or 20 ms, in 20 fresh-process and 10 running-server runs per cell. Views
+were ready (owners idle) in a running server after 4.6 / 5.3 / 6.4 s with the
+candidate and 6.7 / 8.2 / 8.3 s with D2, whose owners mapped a median of only 3.5,
+1 and 0.5 of 7 views (two of its 0 ms rebuilds failed early without a logged
+cause); in a fresh process after 5.7 / 6.6 / 8.7 s and 7.7 / 8.2 / 8.4 s. Even at
+0 ms the candidate needs 4.6 s with the server, peer and loopback HTTP on the same
+two vCPUs, which points to CPU rather than S3 concurrency; parse and fold alone were
+not timed on the host. An uncommitted variant with a 24-permit gate and
+read-ahead measured 4.6 / 4.8 / 5.3 s; the largest resident set of any process
+stayed about 88 MiB for all three. These are loopback measurements, not real
+S3/TLS, p95 capacity or production results. See the
+[operator guide](../operations/server2-domestic-cache.md#measure-empty-cache-builds-at-modeled-s3-latency)
+and [selected verification](../evidence/tasks/issue-2849-cold-build-speed/verification.md).
diff --git a/docs/evidence/tasks/issue-2849-cold-build-speed/verification.md b/docs/evidence/tasks/issue-2849-cold-build-speed/verification.md
new file mode 100644
index 000000000..09cd420dc
--- /dev/null
+++ b/docs/evidence/tasks/issue-2849-cold-build-speed/verification.md
@@ -0,0 +1,186 @@
+# Empty-cache forecast build speed: selected local verification
+
+Scope: #2849 on the D2 branch (PR #2841 head `307defeb706886217ab67ea858dc7c7b0435752f`).
+Ordered concurrent partition lookups, forecast store admission and per-lookup bounds,
+an exact cached Doc weight, borrowed grid row reads and the owner hand-off order. Local
+pre-merge evidence with the owned loopback S3 peer and modeled per-operation latency,
+on macOS arm64 (10 cores) and on one Seoul t3.small; it is not a real S3/TLS, p95
+capacity or production result. Raw logs, receipts and copied executables stay in the ignored task
+record.
+
+## Candidate and input identities
+
+| Input | Identity |
+| --- | --- |
+| Implementation commits | `7df3bc30` harness, `62595a92` reads and store limits, `85a1a09b` Doc weight, `de76f37a` owner hand-off, `8946cecd` row reads, `79d798c5` / `885c26be` / `54aad593` harness ready times, aged rebuild and report keeping |
+| Candidate `server2/` tree (`54aad593`) | `21d72d19f5bf2be4ef6eec171c09644299c6aa2d` |
+| Candidate example (final example; product code of `54aad593`) | `cc165e3045bdc6ed844be0a59533722e86baa1bb151f8c8840b4bfc52c1db5e5` |
+| D2 + harness example (`307defeb` product code with the same final example) | `f54744301e13a9567f423d77d124dc7cd79ac817feeb56024b5058ec2e93258c` |
+| D2 smoke runner | `8627e144ae742da1291c101d88559451989c113f2fe19bf5464dcbcc45ec9cc6` |
+| Matrix tool (`54aad593`) | `1da1ac083483d0ce1cd9c407571e54b668b84402c24e1b629351bbb676db46fe` |
+| Loopback peer | `66aa32d8ef2fef5237d6b5824eb745966cc127668f6c58069389c5735415d9de` |
+| Original fixture manifest (730 acquisitions / 851 pages) | `11606b79c26365e4ba8a5a906d5801f717845f853bafc971c2f099d27b7bbe3f` |
+| SM3 static musl examples: D2 + harness / candidate / `gate24` | `dddbc035a5277d08cc330c8e03b37f926e0a619873351317baaca7a5dd805859` / `ca42aa82cb6bdf4371db5ec4d4b9f5b8aef60f2513d7dee153fd42ef31e1c311` / `408a0cf2ed98e438b9219b0afa2a6e74c5a5e2a84aba509cb286cd6a97cdf116` |
+| SM3 host package / retrieved results archive | `1104f185e4e5fc6c2de1890832f6e7d97a8cb152250e238f741b4a3bb1c1109a` / `84c2064a51396c120fba8b16f3e30f9f6fb5e1bee9cedf63738e66ca8272297f` |
+
+## Behavior checks
+
+Each change was test-first where it has observable behavior, and the tests fail on the
+D2 code for the intended reason:
+
+| Check | D2 (Red) | Candidate |
+| --- | --- | --- |
+| 130 hourly partitions at 10 ms per operation, 12-permit gate | 1 lookup in flight | 12 lookups in flight, 12 reads at most, 0.55 s against a 3.9 s sequential estimate |
+| Partition of exactly 4 MiB / 4 MiB + 1 byte | both admitted | 4 MiB admitted, + 1 byte is `Capacity` |
+| Forecast store limits | 2 lookups / 2 raw loads, 32 MiB, 8 MiB | 12 / 12, 4 MiB, 1 MiB |
+| Storing 64 / 256 Docs; 32 bounded field sets | 4,672 / 67,840 Doc walks; 32 | 192 / 768; 1 |
+| Next owner woken by a hand-off | sees 201,326,592 workspace bytes still counted | sees 0 |
+
+Characterization tests pass on D2 and on the candidate: the first failing partition in
+partition order decides the answer; a catalog vanished after LIST still fails the view
+with `Corrupt("incomplete catalog")`; the owner deadline and a never-answering lookup
+leave no lookup in flight and no view mapped; two cold keys share the gate without
+starving another reader; jittered overlapped reads equal one operation at a time;
+scope totals fail at the same partition as the D2 per-acquisition checks in 3,000
+random cases; cached weights equal an independent full walk after random changes; store
+admission equals a full recomputation at limit − 1, limit and limit + 1; rows are
+found exactly as through `Row::get`.
+
+## Output equality and fold time
+
+The isolated smoke on the final candidate example (seed, diagnostic, HTTP) exited 0:
+seed 730 / 851, diagnostic 725 acquisitions / 845 pages with 13/13 golden Docs in every
+phase, warm forecast I/O, parse and fold deltas 0. Fresh native HTTP: cold 200 in
+1,295 ms, warm 200 in 21 / 20 ms, all bodies 42,549 bytes with SHA-256
+`a6462aba8ad9ec34d8efdcab4ccd2f37b00339a301fd17caa7c1e5485d45b283`.
+
+Fold time per view, median of 5 diagnostic runs at 0 ms, with one uncommitted timing
+patch (two lines printing the blocking `build_view` time) applied to D2 + harness
+(`7df3bc30`) and to the final candidate tree (`54aad593`), 13/13 Docs in all 10 runs:
+
+| View | D2 + harness | Candidate | Change |
+| --- | --- | --- | --- |
+| Short | 1,170.9 ms | 687.4 ms | −41.3 % |
+| Shortest | 418.3 ms | 296.9 ms | −29.0 % |
+| RSS | 35.9 ms | 29.0 ms | −19.2 % |
+
+An earlier run on the `8946cecd` tree gave 1,148.9 → 677.4, 410.3 → 287.6 and 35.3 → 28.3 ms.
+
+## Empty-cache requests at modeled S3 latency
+
+Harness: `server2/tools/cold_build_matrix.py` with the owned peer's latency control,
+one seeded corpus per execution, 5 interleaved runs per cell, D2 product code and the
+candidate built with the same final example (`f54744301e13a9567f423d77d124dc7cd79ac817feeb56024b5058ec2e93258c`
+and `cc165e3045bdc6ed844be0a59533722e86baa1bb151f8c8840b4bfc52c1db5e5`). The 0 and 10 ms
+cells come from one execution and the 20 ms cells from another, both without harness
+problems; every 200 body equals the reference. An earlier complete execution lost
+11 runs to local port exhaustion and is kept only in the task record: bursts of up
+to about 24,000 TIME_WAIT sockets came from other activity on the host at times not
+aligned with the runs, while a run of this harness alone stayed near 300. "Running server" is the `aged` request:
+views stale after 30 seconds in a warm process, about 2,245 S3 operations; a fresh
+process makes about 3,628. Times are medians (p95 in parentheses); "ready" is when
+the owners started by the request were idle, measured outside the request timing.
+
+| Modeled delay | Request | D2 + harness | Candidate |
+| --- | --- | --- | --- |
+| 0 ms | fresh process | 200 in 1,784 ms (2,195) | 200 in 1,096 ms (1,413) |
+| 0 ms | running server | 200 in 1,516 ms (1,606) | 200 in 1,026 ms (1,160) |
+| 10 ms | fresh process | 502 at 3.0 s; owners stop at the 8 s bound with 1 of 7 views | 502 at 3.0 s; 7/7 views ready at 4.66 s (4.75) |
+| 10 ms | running server | 502 at 3.0 s; 0 of 7 views | 7/7 views ready at 3.12 s (3.14); 1 of 5 answered 200 (client-measured 3.003 s), 4 answered 502 |
+| 20 ms | fresh process | 502 at 3.0 s; 2 of 7 views | 502 at 3.0 s; 6 of 7 views by 8.02 s |
+| 20 ms | running server | 502 at 3.0 s; 0 of 7 views | 502 at 3.0 s; 7/7 views ready at 5.41 s |
+
+The goal of a 2.5 s p95 at a modeled 10 ms is not met locally; the running-server
+rebuild is 0.6 s above it and 0.1 s above the 3-second deadline. Through the
+unchanged 12-permit process S3 gate, 2,245 operations at 10 ms need at least
+2,245 × 10 ms ÷ 12 ≈ 1.9 s and 3,628 need ≈ 3.0 s before any fold work, so the
+remaining gap needs fewer S3 operations per view or more S3 concurrency.
+
+## Seoul t3.small host (SM3)
+
+One task-owned, role-less Spot t3.small (Unlimited, 2 vCPUs, 1.9 GiB, Amazon Linux
+2023 kernel 6.18) in ap-northeast-2d, SSH from the operator address only, egress
+revoked before the workload, a 4-hour termination timer; 2026-10-22 02:08–03:51 UTC,
+then terminated with its security group and root volume deleted (read back). The
+workload ran under a systemd unit with 1,536 MiB memory and no swap. Static musl
+x86-64 builds of the same three sources: D2 + harness (`307defeb` product code with
+the final example), the candidate (`54aad593`), and an uncommitted measurement
+variant `gate24` (the candidate with the process gate, `READ_AHEAD` and the example
+gate at 24 and an idle connection pool of 32). The packaged peer copy accepted 32
+connections (tracked default 16) so the variant is not refused; the other two keep
+at most 16 open. The matrix tool is the `207b176d` version. Matrix: 20 fresh-process
+`http` runs and 10 `aged` runs per binary and delay, interleaved, no harness problem.
+The Python peer runs on the same two vCPUs as the server, so these times include its
+CPU; real S3 would not.
+
+No request of any binary was answered inside the 3 s deadline at 0, 10 or 20 ms
+(0 of 270 cold requests and 0 of 90 aged rebuilds), so no body could be compared on
+the host. During qualification in an emulated amd64 container the Linux candidate and
+`gate24` builds returned the reference body; the D2 build answered no request with
+200 there either, so its Linux body is not verified. Views ready (median, p95 in
+parentheses) is when the owners started by the request went idle; mapped views of 7
+where fewer:
+
+| Delay | Request | D2 + harness | Candidate | gate24 variant |
+| --- | --- | --- | --- | --- |
+| 0 ms | running server | 6.73 s (7.02), 3.5 of 7 | 4.63 s (4.73) | 4.58 s (4.71) |
+| 10 ms | running server | 8.15 s (9.46), 1 of 7 | 5.32 s (5.45) | 4.77 s (4.93) |
+| 20 ms | running server | 8.28 s (8.29), 0.5 of 7 | 6.44 s (6.68) | 5.28 s (5.43) |
+| 0 ms | fresh process | 7.65 s (7.93), 3 of 7 | 5.73 s (5.88) | 5.55 s (5.67) |
+| 10 ms | fresh process | 8.24 s (9.93), 3 of 7 | 6.58 s (6.70) | 5.75 s (5.93) |
+| 20 ms | fresh process | 8.36 s (8.39), 2 of 7 | 8.71 s (8.93), 6 of 7 | 6.28 s (6.39) |
+
+D2's owners went idle with only the views shown mapped and read fewer objects:
+1,875 / 1,402 / 739 forecast operations per fresh-process request and 1,876 / 1,471 /
+773 per running-server rebuild, against the candidate's 2,227. At 10 and 20 ms they
+went idle at or after the 8-second owner bound (two 10 ms rebuilds only at 9.46 s);
+at 0 ms they went idle earlier, and two
+of D2's ten 0 ms running-server rebuilds failed early (502 after 2.21 and 2.37 s,
+with 2 and 4 views mapped). The info-level log records no cause for these early
+stops: they are consistent with the owner hand-off defect fixed here, but that is
+not confirmed. The candidate and `gate24` had no early failure. The candidate's
+0 ms time, 4.6 s in a running server (about 4.5 times the local 1.03 s), is a floor
+of this setup, in which the server, the Python peer and loopback HTTP share the two
+vCPUs; it points to CPU rather than S3 concurrency, but parse and fold time alone
+was not separated on the host. The 2.5-second goal is not met on t3.small at any
+modeled delay. A 24-permit gate removes most of the latency-dependent part (10 ms:
+5.32 → 4.77 s; 20 ms: 6.44 → 5.28 s) and does not lower the 0 ms time. The largest resident
+set of any process (server, peer or tool) was 78.8 MiB for D2, 87.5 MiB for the
+candidate and 88.1 MiB for `gate24` in the per-binary memory runs; the unit's
+`memory.peak` (1.46 GiB) includes the page cache of 2.4 GiB of written run output
+and is not a process size. CloudWatch showed at most 74 % average CPU per 5 minutes
+and no surplus credits charged at the final snapshot; the host cost is estimated at
+about USD 0.10 (Spot, surplus credits, root volume, public IPv4 and the result
+download), not an invoice.
+
+## Tests and gates on the candidate
+
+On the candidate `server2/` tree: `cargo fmt --check` and all-target Clippy with
+warnings denied pass; the placement gate passes for `ColdBuildSpeed2849.json`; the
+peer self-check (with its latency control), 6 matrix-tool tests and 6 D2 runner tests
+pass; the 8 cold-read integration tests and 63 source unit tests pass. The D2 test
+`runtime_services::inventory::factory_national_prose_reader_survives_factory_reconstruction`
+fails on the D2 head `307defeb` and passes on its merge base `c1ae9082` (two owned
+flights panic during `/v000903/nation/KR`); its fixture is ported unchanged from
+PR #2851 (`0c163912`, test only), after which `runtime_services` passes 48/0. At
+`7eadefb6` the whole workspace with 4 test threads and `--no-fail-fast` passed
+1,111 tests with 6 ignored and failed 2 `domestic::source_s3` tests with
+`address_unavailable`: these tests alone leave about 33,500 loopback TIME_WAIT
+sockets on macOS, the D2 base peaks at the same count and fails the same way when
+run back to back, and the `domestic` binary rerun alone passes 181/0. PR CI at
+`7eadefb6` (Linux) passes every check. `tools/test_placement.py` did not run here:
+its self-tests call `rustc +1.99.0`, which needs the rustup proxy this host lacks.
+
+Base observations: D2 CI run `39243883911` failed 4 `domestic` tests with
+`forecast cache: raw storage: Capacity`, the owner hand-off defect fixed here. With
+the default 10 local test threads the base fails 30 `domestic` tests while seeding
+the loopback peers (host contention), so base and candidate are compared with 4
+threads: base 173 passed, candidate 181 passed (8 new), none failed.
+
+## Limits
+
+The latency is modeled on loopback: no TLS, connection setup or S3 variance, and on
+the Seoul host the peer shares the server's two vCPUs. The process S3 gate (12) is unchanged and shared,
+so one cold build can hold all its permits while it reads. Logical weights of the same
+data grow by 8 bytes per Doc and per stored field. Real-partition sizes are taken from
+the fixture (at most about 0.2 MiB decoded against the new 4 MiB bound).
diff --git a/docs/operations/server2-domestic-cache.md b/docs/operations/server2-domestic-cache.md
index dcb153873..fc0b7558a 100644
--- a/docs/operations/server2-domestic-cache.md
+++ b/docs/operations/server2-domestic-cache.md
@@ -15,8 +15,10 @@ Freshness is at most 30 seconds and clips at the relevant minute, day, month, pr
|2 active owners + 8 queued keys|Ten admitted keys, 192 MiB logical workspace per owner ; 8-second owner deadline starts at admission, including queue time|
|128 waiting callers / local CPU 2|Bounded per-Source demand; each caller keeps its own deadline|
|4995 comparison indices|At most 39,960 requested payload bytes on 64-bit plus charged allocation allowance; fallible growth and actual Vec capacity check|
+|12 partition lookups per build (#2849)|Consumed in partition order, so acquisitions, scope caps and the first error equal a sequential read; an error, cap or owner deadline drops the rest|
+|Forecast store 12 lookups / 12 raw loads|4 MiB of records and 1 MiB of catalog/descriptor bytes per lookup: at most 72.75 MiB in flight over all views (D2: 82.125 MiB). A larger partition is a `Capacity` error. The 12-permit process S3 gate is unchanged and shared|
-A caller or owner timeout does not forcibly stop an already running blocking closure. Its workspace and permits remain owned until the actual closure retires.
+A caller or owner timeout does not forcibly stop an already running blocking closure. Its workspace and permits remain owned until the actual closure retires. A retiring owner returns its workspace before its slot wakes the next owner (#2849); D2 released the slot first, and the woken owner could fail with `forecast cache: raw storage: Capacity` under load.
These are logical admission limits, not an allocator/RSS or total process CPU guarantee. Existing catalog/raw gates remain. D2/F2 must be reconciled with the shared process resource owner in the integrated candidate; this branch alone does not establish that global claim.
@@ -33,12 +35,31 @@ python3 -B tools/domestic_warm_cache_smoke.py \
--build-variant release --modes seed diagnostic http
```
-Use a new output directory. The runner owns a persistent IPv4 loopback S3 peer and separate seed, diagnostic and native HTTP children. It verifies their input identities, captures complete counters and reaps its children. No live endpoint or provider credentials are required. The Off provider reports denied observation stub calls separately from external network calls. A diagnostic Source run uses 60 seconds to inspect all 13 golden datasets; the separate unprimed Gateway -> DirectRoutes -> KmaBackend HTTP flow retains its normal 3-second deadline.
+Use a new output directory. The runner owns a persistent IPv4 loopback S3 peer and separate seed, diagnostic and native HTTP children; the example's S3 transport admits 12 requests at once, the server's process gate (it admitted 4 before #2849). It verifies their input identities, captures complete counters and reaps its children. No live endpoint or provider credentials are required. The Off provider reports denied observation stub calls separately from external network calls. A diagnostic Source run uses 60 seconds to inspect all 13 golden datasets; the separate unprimed Gateway -> DirectRoutes -> KmaBackend HTTP flow retains its normal 3-second deadline.
A passing run requires the original seed of 730 acquisitions / 851 pages (including one zone-support acquisition) and the diagnostic selection of 725 forecast acquisitions / 845 pages and all 13 expected Doc views. Every warm forecast LIST/catalog/descriptor/raw GET/returned byte, parse and fold delta must be 0. The complete classified label universe is exactly those 13 plus zone-support; missing or unknown labels fail. Cold HTTP must be 200; both warm responses must have identical bytes. Provider/geocoder counters, final owner/queue/workspace/waiter state and process retirement are checked. Never accept 502 as a successful partial response or prime the HTTP Source with the diagnostic Source.
+## Measure empty-cache builds at modeled S3 latency
+
+This compares two builds of the same example, for example the D2 product code and a candidate, on one seeded corpus. Build each at its own commit in its own clean target (a target shared between worktrees of the same package can report the second build fresh and reuse the first one's binary), copy and hash both executables, then run:
+
+```sh
+cd server2
+python3 -B tools/cold_build_matrix.py \
+ --binary base=/absolute/path/to/copied/base_smoke \
+ --binary candidate=/absolute/path/to/copied/candidate_smoke \
+ --output /absolute/path/to/new/ignored/matrix-run \
+ --delays 0 10 20 --runs 5
+```
+
+The tool starts one persistent loopback peer, seeds the original corpus without delay, then sets the peer's latency control so every GET, HEAD and LIST waits the modeled milliseconds (PUT does not). For each delay and run it starts every binary in turn in `http` mode (fresh process: cold, warm1, warm2) and in `aged` mode (cold, then a rebuild after the 30-second view freshness, i.e. an empty forecast cache in a warm process). It keeps each run's bodies, compares every 200 body with the reference SHA-256 and reports status, median and p95 time, forecast LIST/catalog/descriptor/raw counts and, for cold and aged requests, when the views were ready, in `matrix.json`. A 502 after about 3 s is the unchanged native deadline and is recorded as a result; the owners of a cut answer keep building up to their 8-second bound, so the ready time and the number of mapped views show how far the build really was from the deadline. An `http` run then stops; an `aged` run still makes its rebuild request after the cold one was cut, because the forecast views of a running server go stale after 30 seconds and that rebuild (with about 20 other S3 operations instead of about 1,400 in a fresh process) is the common empty-cache case. Exit 1 means a harness problem (missing phases, an `aged` run without its rebuild, a timed-out or killed child, a failed child after successful answers, a wrong body), never a slow answer.
+
+The delay is modeled on loopback: it has no TLS, connection setup or real S3 variance, and the host CPU decides the fold time. It is not a host p95 or capacity qualification. The selected local and Seoul t3.small results of #2849 are in its [verification record](../evidence/tasks/issue-2849-cold-build-speed/verification.md).
+
## Read the actual retained result
+These are the figures of the D2 base run (#2819), kept as recorded. Since #2849 the same data weighs 8 bytes more per Doc and per stored field (each Doc carries its cached weight), and the example's S3 admission is 12.
+
The selected optimized executable is SHA-256 `f131044392694657919864caee1a95539359c736d4f0e6225127235a8069d68b`, built on the task's exact runtime source above base `c1ae9082`. The current runner is `a9b1e1a194188e3fb31c325b0fdd128f5b566cd58d2bfe6b0c3cdc1282272a84`. The retained original fixture manifest is `11606b79c26365e4ba8a5a906d5801f717845f853bafc971c2f099d27b7bbe3f`.
- Actual whole runner and all 3 children exited 0. Diagnostic 13/13 Docs match; cold 1958.88 ms and warm 3.75 / 3.79 ms.
diff --git a/docs/operations/server2-native-verification.md b/docs/operations/server2-native-verification.md
index d7ae98814..a4ca1b171 100644
--- a/docs/operations/server2-native-verification.md
+++ b/docs/operations/server2-native-verification.md
@@ -596,3 +596,5 @@ optional counter visible. Never accept a 502 or weaken the original corpus.
The [selected receipt](../evidence/tasks/issue-2819-domestic-warm-cache/verification.md)
records actual current results and original failures. This does not qualify host
latency, global resource integration, full API parity or production recovery.
+Empty-cache build time at modeled S3 latency (#2849) has its own base-versus-candidate
+procedure in the operator guide, [measure empty-cache builds](server2-domestic-cache.md#measure-empty-cache-builds-at-modeled-s3-latency).
diff --git a/intent/issue-2849-cold-build-speed.md b/intent/issue-2849-cold-build-speed.md
new file mode 100644
index 000000000..bc7154cfe
--- /dev/null
+++ b/intent/issue-2849-cold-build-speed.md
@@ -0,0 +1,59 @@
+# Intent: speed up one empty-cache domestic forecast build (issue #2849 follow-through)
+
+| Field | Recorded value |
+| --- | --- |
+| Task / stage / owner | `issue-2849-cold-build-speed` / intent / main (Claude Sonnet 5.5, Anthropic) |
+| Date / revision | 2026-10-22 KST, revision 3 (amendments below) |
+| Source / authority | AK, 2026-10-22: implement per SDLC after the #2849 investigation; keep the EC2 host time limit until implementation completes. Issues #2849 (revised body), #2850. |
+| Base | `307defeb706886217ab67ea858dc7c7b0435752f` (PR #2841 head, branch `fix/domestic-warm-cache-2819`); this task's branch `fix/cold-build-speed-2849` |
+| Endpoint | `pre-merge` as the intended constraint; delegation of external writes is **pending** (see Authority) |
+| Consumers | investigation, spec, plan, builder, reviewers |
+
+## Problem and supporting facts
+
+server2 builds the forecast views of one town from raw S3 acquisitions when its memory cache is empty. D2 (PR #2841) reads the partitions of a view one at a time, each as a chain of 3–4 GETs, through a forecast catalog store limited to 2 concurrent lookups. One empty-cache request therefore performs about 2,220 forecast GETs in about 700 sequential lookups.
+
+- On the Seoul t3.small / 2 GiB / CPU Unlimited host with a loopback S3 fixture the request needs 8.0–9.1 s (p95 8.28 s over 20 fresh-process runs) against a 3 s native deadline and the #2705 gate "assembly-only cold p95 ≤ 2.5 s"; with the product limits it returns HTTP 502 (2026-10-21 record).
+- The loopback run hides S3 round trips. A local sweep with a delay injected into every GET/HEAD/LIST gives 1.4 s (0 ms), 10.1 s (5 ms), 19.8 s (10 ms); the sequential chain costs about 2 s per millisecond of S3 delay.
+- A throwaway prototype (never committed) that keeps results in partition order but looks up 12 partitions at once and raises the store limit to 12 reached 3.2 s at 10 ms and 6.2 s at 20 ms; with a 32-permit process gate 1.8 s / 2.5 s. Raising only the lookup concurrency without the store limit changed nothing; more owners changed nothing.
+- Fold CPU is next: `short` 1.16 s and `shortest` 0.43 s locally, about 4× slower on t3.small. The fold re-weighs the whole Doc collection on every store (quadratic), copies `String` values on every `Row::get`, and allocates heavily. A prototype that skipped the quadratic re-weigh cut fold by about 17%.
+- All outputs were identical to the goldens (13 view Docs; HTTP body sha256 `a6462aba8ad9ec34…`, 42,549 bytes).
+
+Affected: every first request for a town whose forecast views are not cached (restart, eviction, expiry), the 502 rate of the native route, and the #2705 cold-p95 cutover gate. Retention (how long views are kept) is #2850 and out of scope.
+
+## Desired outcome and acceptance criteria
+
+An empty-cache forecast request completes inside the unchanged 3 s native deadline with identical output, bounded memory and no new runtime mode. Goal on the measured host class: empty-cache p95 ≤ 2.5 s at a modeled S3 delay of 10 ms. If the bounded iterations cannot reach that, report the measured remaining gap; never relax the deadline or the gate.
+
+- **AC1 — ordered concurrent reads.** A view build looks up its partitions with bounded concurrency while consuming results in partition order. Acquisitions, order, exclusion counts and the first error equal the sequential read. With a delayed transport double the build time follows operations × delay ÷ concurrency (at least 4× faster than the sequential read for 120+ partitions at the same delay). Timeout, corruption, missing catalog and capacity paths return the same typed errors; a cancelled build leaves no installed partial view and no running lookup beyond the S06 blocking closures that already outlive their caller.
+- **AC2 — identical output and limits.** All 13 view Docs equal their goldens; the HTTP bodies of the cold, warm and empty-cache-in-warm-process requests equal the reference; every capacity limit keeps its exact boundary behavior (existing ±1 tests stay green); the forecast store's in-flight read memory, summed over all views of the process, stays below D2's bound; a build that obtains an owner slot always obtains its workspace (no `Capacity` left behind by the previous owner's hand-off).
+- **AC3 — linear fold accounting.** Retained-Doc accounting no longer re-walks the whole collection per store or the whole Doc per field set, and the grid collector's row lookup no longer copies strings per visited row; a deterministic operation-count test shows linear growth and the local fold time of `short` and `shortest` falls against the D2 baseline on the same corpus with unchanged output.
+- **AC4 — Seoul t3.small evidence.** The candidate and D2-as-published are measured with the same harness on the Seoul t3.small (Unlimited) host at modeled S3 delays 0, 10 and 20 ms (empty-cache request, fresh process and warm process), bodies identical, and the gap to 2.5 s is stated.
+- **AC5 — contract, documentation and placement.** No new runtime flag or mode; all added paths are under the declared `server2/` subdirectories plus the declared documents; the D2 architecture section, the operator guide and the diagram describe the new read concurrency and limits; the placement gate passes; unrelated files are untouched.
+
+## In scope, out of scope, constraints
+
+- In scope: `server2/src/domestic/kma/source/{forecast_cache,records,mod,grid,doc,items,rss,mid}.rs` as needed for AC1–AC3, tests under `server2/tests/` and unit tests in those modules, the loopback-peer latency test aid under `server2/tests/storage/`, measurement tooling under `server2/examples/` or `server2/tools/`, and the declared documents.
+- Out of scope: retention, SSD tier and warm-up (#2850); packs/rollups (#2707); resizing the process S3 gate `S3_IN_FLIGHT` (a separate decision; evidence recorded); host class; legacy `server/`; provider calls; production actions.
+- Constraints: a request waits at most 3 s and omits late optional parts; S3 is authoritative; responses are byte-identical; no verification-only mode; no legacy dependency; fallible/charged allocation discipline of D2 stays; reviewer provider must differ from the builder.
+
+## Authority and endpoint
+
+- Delegated now: local implementation, tests, local smoke, the Seoul EC2 host described below, local commits on the task branch.
+- **Not yet delegated (needs AK's explicit authority record `pre-merge-authority.json`):** pushing the branch, creating or updating a PR (stacked on #2841), issue comments or edits, sending code to another provider's reviewer, CI reads that need a PR. Until then pr-review and completion cannot run; the work stops at a verified local candidate.
+- EC2: one role-less Spot t3.small in Seoul (ap-northeast-2d), SSH from the operator address only, egress revoked, auto-shutdown timer (initially 8 h, renewed while work continues), budget envelope USD 0.30 with a stop-and-ask above it. A safety classifier blocked the first operator-script run; no AWS call has been made. Launch waits for AK's explicit confirmation.
+- Always excluded: merge, auto-merge, merge queue, production deployment, secrets, new accounts, permission changes.
+
+## Assumptions, risks and open questions
+
+- Assumption: AK's "implementation" selects options A and B of #2849; C (packs) and gate resizing stay decisions.
+- Risks: ordering/error-precedence regressions in the concurrent read; sharing the 12-permit process S3 gate with other families during a cold build; unaccounted in-flight bytes; timing-only tests being flaky; a stacked PR on an unmerged, conflicting base.
+- Open questions for AK: (1) delegate `pre-merge` (push, stacked PR on #2841, OpenAI Codex as required reviewer, issue comments)? (2) confirm the EC2 launch now. (3) raise `S3_IN_FLIGHT` later (evidence: 12 → 3.2 s, 32 → 1.8 s at 10 ms)?
+
+## Intent gate
+
+PROCEED for local stages (investigation → build → local self-verification). NEEDS_HUMAN for external writes and the AWS launch; unaffected work continues.
+
+Amendment 2026-10-22 (revision 2, before any code): AC2's memory clause read "in-flight read memory stays within the previous per-view bound". The forecast catalog store is one instance shared by every view, and D2 already let two owners read at once, so its bound is process-wide; with overlapped lookups one view may now use more than one sequential D2 lookup did. The clause now compares the store's in-flight bytes over all views with D2's. Found while planning, when the raw store's refuse-rather-than-wait admission and the per-load compressed buffer were added to the arithmetic. No other criterion changes.
+
+Amendment 2026-10-22 (revision 3, during build): the D2 owner `Workspace` releases its owner slot before its workspace bytes. A waiting owner woken on another worker can reserve before the bytes return and fails with `Capacity` (`forecast cache: raw storage: Capacity`): the 4 failures of base CI run 39243883911 and 1 in this task's first full local run carry exactly that error. The fix (bytes before slot) and a deterministic regression test join the scope in `forecast_cache.rs`, which is already declared; AC2 gains the hand-off clause. Reason: the defect fails cold requests under load and makes every CI result of this change unreadable.
diff --git a/plans/issue-2849-cold-build-speed.md b/plans/issue-2849-cold-build-speed.md
new file mode 100644
index 000000000..36c4518e7
--- /dev/null
+++ b/plans/issue-2849-cold-build-speed.md
@@ -0,0 +1,96 @@
+# Plan: empty-cache forecast build speed (issue #2849 follow-through)
+
+Consumes [intent](../intent/issue-2849-cold-build-speed.md) revision 3, [spec](../specs/issue-2849-cold-build-speed.md) revision 5 and the design of 2026-10-22 (iteration 5, diagram bound to the implementation: [sequence](../docs/architecture/diagrams/server2-cold-read.html), [editable source](../docs/architecture/diagrams/server2-cold-read.sequence.json); the written design is in the ignored task record). Base `307defeb706886217ab67ea858dc7c7b0435752f` (PR #2841 head), branch `fix/cold-build-speed-2849`, endpoint `pre-merge` as the intended constraint with external writes pending AK's authority record. Owner: main (Claude Opus 5.5; earlier stages by Claude Sonnet 5.5, both Anthropic). Execution notebook: `.planning/2026-10-22-issue-2849-uncached-forecast-build-speed/` of the coordinating workspace.
+
+## Placement declaration (server2)
+
+The new task declaration `server2/config/tasks/ColdBuildSpeed2849.json` enumerates the paths below and is the only declaration this PR changes. No path outside `server2/` holds implementation; no server2 code reads, includes or links legacy `server/`; `Cargo.toml` and `Cargo.lock` stay unchanged (`futures-util` with `std` is already a dependency).
+
+Intended `server2/` paths:
+
+| Path | Why |
+| --- | --- |
+| `server2/config/tasks/ColdBuildSpeed2849.json` (new) | task placement declaration |
+| `server2/src/domestic/kma/source/forecast_cache.rs` | `READ_AHEAD`, ordered concurrent `read_partitions`, owner hand-off order (R5), unit tests |
+| `server2/src/domestic/kma/source/records.rs` | `forecast_lookup` (`Looked`, `Counted`) replaces `forecast_acquisitions`; `ForecastScope::admit` |
+| `server2/src/domestic/kma/source/mod.rs` | forecast store admission 12/12 and per-lookup output 4 MiB / controls 1 MiB |
+| `server2/src/domestic/kma/source/doc.rs` | cached exact `Doc` weight (`Weight` newtype), `Row::str_is`, accounting tests |
+| `server2/src/domestic/kma/source/grid.rs` | `find_or_create` through `Row::str_is`; hot-path copies removed where a profile shows them |
+| `server2/src/domestic/kma/source/items.rs`, `rss.rs` (conditional) | only if the profile of the new baseline shows a remaining shared per-read copy; unchanged otherwise |
+| `server2/tests/domestic.rs`, `server2/tests/domestic/forecast_cold_read.rs` (new) | delayed/counting transport double, ordered-concurrency, fault, cap and cancellation tests |
+| `server2/tests/storage/catalog_peer.py` | runtime `latency` control of the loopback peer for the measurement harness, with its own loopback self-check |
+| `server2/tests/runtime_services/inventory.rs` | correction 2026-10-22: D2 factory prose reconstruction fixture ported unchanged from PR #2851 (`0c163912`, test only) so stacked CI reaches a verdict |
+| `server2/examples/domestic_warm_cache_smoke.rs` | transport gate 4 → 12 (the product's `S3_IN_FLIGHT`), `aged` mode (cold, then a request after the 30 s freshness bound) |
+| `server2/tools/domestic_warm_cache_smoke.py` (+ its test only if affected) | the runner records the example's S3 transport admission (`s3_transport_admission`), which becomes 12 |
+| `server2/tools/cold_build_matrix.py`, `server2/tools/test_cold_build_matrix.py` (new) | repeatable base-versus-candidate matrix at modeled S3 delays, with pure report-check tests |
+
+Exact outside paths (documentation and task records only):
+
+| Path | Reason |
+| --- | --- |
+| `intent/issue-2849-cold-build-speed.md`, `specs/issue-2849-cold-build-speed.md`, `plans/issue-2849-cold-build-speed.md` | SDLC intent, spec and plan |
+| `docs/architecture/server2.md` | D2 section: read concurrency, admission, in-flight bound, linear accounting |
+| `docs/architecture/diagrams/server2.json`, `server2.html` | existing Archify source and delivered HTML (card "D2 forecast memory"), regenerated by Archify |
+| `docs/architecture/diagrams/server2-cold-read.sequence.json`, `server2-cold-read.html` (new) | cold-read sequence, rebound to the implementation commit |
+| `docs/operations/server2-domestic-cache.md` (PDF only if a statement in it becomes untrue) | operator limits and the measurement procedure |
+| `docs/operations/server2-native-verification.md` (conditional) | link to the matrix procedure |
+| `docs/evidence/tasks/issue-2849-cold-build-speed/verification.md` (+ small selected result files) | candidate-bound selected evidence |
+
+Excluded and untouched: legacy `server/`, the process S3 gate (`gateway/runtime.rs`), owner and waiter counts, lookback and retention windows, view freshness, provider paths, `.github/`, root runtime files.
+
+## User scenarios (machine-readable copy: `reports/sdlc/issue-2849-cold-build-speed/user-scenarios.json`)
+
+| ID | User and goal | Steps (summary) | Expected | Failure path | AC | Checks |
+| --- | --- | --- | --- | --- | --- | --- |
+| S1 | App user opens the app right after a server restart; the first request for a town needs forecast views that are not in memory | native KMA address request with an empty forecast cache while S3 answers in 10 ms | HTTP 200 inside 3 s, body identical to the reference (sha256 `a6462aba…`, 42,549 bytes) | at higher delay the waiter times out as today, the owner finishes within its 8 s bound and the next request is warm | AC1 AC2 AC4 | T-R1.1, T-R1.2, matrix, smoke |
+| S2 | Same user asks again | repeat request after S1 | warm hit, zero forecast LIST/GET, parse and fold | n/a | AC2 | existing D2 tests, smoke |
+| S3 | Maintainer verifies equality locally | seed, diagnostic and http modes of the isolated smoke on the candidate | 13/13 Docs equal their goldens, hashes equal, warm deltas 0 | any Doc or hash difference fails the run | AC2 | smoke |
+| S4 | Operator compares D2 and the candidate | run the matrix at 0, 10, 20 ms for both binaries, fresh process and aged cache, locally and on the Seoul host | table of status, time, hash and S3 operation counts; the gap to 2.5 s is stated | host launch not confirmed: the host column is recorded as not run, never inferred | AC4 | matrix, host run |
+| S5 | A different town builds at the same time (2 owners) and other S3 users run | two cold keys plus a foreign S3 operation through the same gate | all complete; the foreign operation waits at most one gate round; an owner hand-off never fails for workspace capacity; no deadline relaxation | queue overflow keeps the existing typed refusal | AC1 AC2 | T-R1.7 |
+| S6 | Maintainer reads the D2 documents | open architecture section, operator guide, diagram | read concurrency, admission (lookups = raw loads = 12), the process-wide in-flight bound (72.75 MiB, D2 82.125 MiB) and the evidence limits are stated; links resolve | stale statement found: fix before completion | AC5 | doc review, link check, Archify |
+| S7 | Maintainer checks that the fold no longer re-weighs everything per store | operation-count tests; fold timing of `short`/`shortest` on base and candidate at 0 ms; goldens | walks grow linearly; store results equal the old recomputation; fold time down ≥ 25 %; Docs identical | stale weight, accept/reject difference or a gain below target is reported with numbers, never relaxed silently | AC2 AC3 | T-R3.1–T-R3.4, fold timing, smoke |
+
+## Ordered implementation steps (single builder, no parallel lanes)
+
+Each cycle follows the `tdd` skill (Red, Green, post-refactor check) and ends in a local commit; evidence of each cycle is kept in the ignored task record.
+
+0. **Baseline.** Record the base's behavior with the selected suites (see Verification) and the base binary's matrix numbers with the same harness once it exists. Recorded so far: base CI run `39243883911` failed 4 of 173 `domestic` tests with `forecast cache: raw storage: Capacity` (overflow under load, not diagnosed); locally the 4 pass in isolation, the whole binary passes with `--test-threads=4` (173/0, 2 ignored) and fails 30 tests in fixture publication with the default 10 threads (host contention). Base and candidate are compared with `--test-threads=4`; the CI result of a later PR is read against this baseline, not claimed as fixed.
+1. **Harness and test aids** (`catalog_peer.py` latency control + self-check, example gate/`aged` mode, `cold_build_matrix.py` + pure tests). Red: the pure tests and the peer self-check fail before the code exists. This step must precede R1 so that D2-as-published and the candidate use one harness.
+2. **R1 and R2.** Red tests in `forecast_cold_read.rs` first: peak concurrent catalog lookups ≥ 8, elapsed ≤ ⅓ of the sequential estimate for ≥ 120 partitions, 4 MiB+1 partition returns `Capacity`; characterization tests for first-error precedence, scope-cap partition, jitter equality and cancellation (these pass on the base and must stay green). Green: `forecast_lookup`/`Looked`/`Counted`, `ForecastScope::admit`, `read_partitions` with `buffered(READ_AHEAD)`, store limits.
+3. **R3a cached weight.** Red: a `#[cfg(test)]` counter of full weight walks shows quadratic growth on the base; cached-equals-recomputed and store-decision-versus-oracle tests are written first and pass on the base. Green: `Weight` cache in `Doc`, exact delta maintenance in `try_set`, `checked_store_with_limit` over cached weights.
+3b. **R5 owner hand-off.** Red: a waker registered by the next owner observes the workspace budget at its wake-up and sees the previous 192 MiB lease still counted. Green: declare the lease before the owner permit. Then rerun the whole `domestic` binary with 4 threads.
+4. **R3b borrowed row lookup.** Red: `Row::str_is` is absent; equality tests against `Row::get` (own, template, missing, non-string, `0xF4` canonicalization). Green: `str_is` and `find_or_create`. Then re-profile and remove further per-read copies only where the profile and equality tests justify it (R3.3, R3.4).
+5. **Documentation and diagrams.** D2 architecture section, operator guide, `server2.json` card, rebind `server2-cold-read` to the implementation commit; Archify `finalize` and `visual-check` for both sources; declaration file; documentation assessment.
+6. **Verification and evidence.** Full self-verification (below), selected evidence under `docs/evidence/tasks/issue-2849-cold-build-speed/`, then the Seoul host matrix when AK confirms the launch.
+
+Dependencies: step 1 before 2; steps 2–4 in order (measured separately); step 5 after code is final; step 6 last. A change to R1–R3 behavior after step 2 starts is recorded as a dated design amendment.
+
+## Verification plan
+
+Reuse (fresh on the candidate, not inherited): existing `doc.rs`, `grid.rs`, `rss.rs`, `forecast_cache.rs` unit tests including the exact-boundary tests; `tests/domestic/forecast_cache.rs` (warm reuse, rewind, owner continuation, 128 waiters, 2/8 owners); the whole `domestic` integration binary; the 13 view goldens through the smoke `diagnostic` mode. New tests are listed in the test plan (test-design stage). Additional local functional smoke: the isolated release smoke (seed, diagnostic, http) and the local matrix at 0/10/20 ms for base and candidate with body hashes and S3 operation counts.
+
+Gates: `cargo test --locked --test domestic -- --test-threads=4` for base/candidate comparison; `cargo fmt --check`; `cargo clippy --locked --workspace --all-targets -- -D warnings`; `cargo test --locked --workspace` once on the final candidate; `python3.11 tools/check_placement.py --root .. --declaration config/tasks/ColdBuildSpeed2849.json --base ` and `tools/test_placement.py`; `python3 scripts/check-artifact-policy.py --staged` before each commit and on outgoing commits before any push; Archify finalize and visual check for the two diagram sources; link check of the changed documents; grep proving no new environment variable or mode flag in `server2/src`. Cargo runs use the shared ignored target `/private/tmp/tw-2849-target` with process-scoped `CARGO_INCREMENTAL=0`, `CARGO_PROFILE_DEV_DEBUG=0`, `CARGO_PROFILE_TEST_DEBUG=0`, after checking free space (137 GiB at planning) and keeping the 3 GiB reserve.
+
+Not-run is never pass: anything needing credentials, the Seoul host, a PR or CI is recorded as not run until it ran.
+
+## Documentation and manual
+
+Operator limits and the matrix procedure go into the editable guide; the existing three-page PDF documents the D2 base run (executable `f131044…`, weights 37,412,992 bytes). The change is a non-UX performance change, so no new screenshot manual is produced. The PDF is regenerated through the `screenshot-manual` skill only if the documentation assessment finds a statement in it that is untrue for the candidate; otherwise the guide labels those figures as the D2 base run and the assessment says so. Logical weights quoted from the D2 run are labeled "D2 base"; the candidate's new figures come from its own diagnostic run (a `Doc` and each `Val` grow by 8 bytes).
+
+## Migration, rollback and blast radius
+
+No stored-data, wire or configuration change; rollback is reverting the PR. The blast radius is the forecast read path of the domestic KMA source (all Grid, RSS and Mid keys) and every consumer of `Doc` (accounting only; equality, cloning results and `Debug` are unchanged). The process S3 gate is shared: a cold build may hold all 12 permits while it reads (recorded in the spec; resizing is a separate decision for AK).
+
+## What could break, riskiest part, rejected alternatives
+
+- Riskiest: the cached `Doc` weight returning a stale value (wrong accept/reject at a cap) and the ordered-consumption equivalence of the concurrent read. Proof: cached-equals-recomputed under random operation sequences, store decisions equal to the old recomputation at exact boundaries, jitter-versus-serialized equality, fault precedence in partition order, goldens, and the unchanged owner/epoch tests.
+- Also: lookups beyond a failing partition may have started (documented, results discarded); timing assertions (use peak concurrency and operation counts first, wide ratios second); the stacked base is unmerged, `CONFLICTING` and under review, so the branch may need a rebase; the base CI is red on 4 tests (see step 0).
+- Rejected: `buffer_unordered` plus sort, spawned per-lookup tasks, charging in-flight bytes to the workspace, sampled weight checks, per-collection weight vectors and resizing the S3 gate (reasons in the design record).
+
+## Cleanup, retention and checker wiring
+
+Maintained: code and tests under `server2/`, the documents above, the selected evidence file(s) with candidate hash. Ignored: `reports/`, `.planning/`, `.archify/`, copies of executables and raw run output (kept in the task record with hashes). Selected evidence cites tracked inputs only. Scratch: `/private/tmp/tw-2849-*` and `/private/tmp/server2-s09-docker/issue-2849-*` are removed at completion after copying required executables and recording hashes; the shared Cargo target is cleaned only after all consumers finish, with the cleaned paths and space recorded. The shared read-only checker is `scripts/check-artifact-policy.py` (`--staged`, and outgoing commits before any push); CI runs `server2/tools/ci.py` (placement, tests, build). Missing wiring: the new pure tool tests are not listed in `ci.py`; wiring them is out of scope and recorded.
+
+## Remaining decisions and gate
+
+Open for AK (do not block local stages): pre-merge authority (push, stacked PR on #2841, OpenAI Codex as required reviewer, issue comments); explicit launch confirmation for the Seoul host; `S3_IN_FLIGHT` resizing (evidence: 12 → 3.2 s, 32 → 1.8 s at 10 ms). Gate: PROCEED to test-design for local work; NEEDS_HUMAN only for the external writes and the AWS launch.
diff --git a/server2/config/tasks/ColdBuildSpeed2849.json b/server2/config/tasks/ColdBuildSpeed2849.json
new file mode 100644
index 000000000..c7987597f
--- /dev/null
+++ b/server2/config/tasks/ColdBuildSpeed2849.json
@@ -0,0 +1,89 @@
+{
+ "task": "ColdBuildSpeed2849",
+ "stable_task": "issue-2849-cold-build-speed",
+ "issue": "https://github.com/WizardFactory/TodayWeather/issues/2849",
+ "status": "pre-merge under the canonical SDLC stages (AK delegation 2026-10-22): PR #2852 stacked on #2841; no merge or deployment",
+ "base": "307defeb706886217ab67ea858dc7c7b0435752f",
+ "scope": "Empty-cache D2 forecast view build: ordered concurrent partition lookups (READ_AHEAD 12) with forecast store admission 12/12, 4 MiB output and 1 MiB controls per lookup; exact cached Doc weight and borrowed row lookup in the fold; latency control of the owned loopback peer, aged smoke mode and a base-versus-candidate matrix tool. No process S3 gate, owner/waiter, lookback, retention, freshness, provider, mode, timer or production/merge change. Correction: the factory prose reconstruction test fixture of D2 is ported unchanged from PR #2851 (test only) so the stacked CI reaches a verdict.",
+ "server2_paths": [
+ "server2/config/tasks/ColdBuildSpeed2849.json",
+ "server2/src/domestic/kma/source/forecast_cache.rs",
+ "server2/src/domestic/kma/source/records.rs",
+ "server2/src/domestic/kma/source/mod.rs",
+ "server2/src/domestic/kma/source/doc.rs",
+ "server2/src/domestic/kma/source/grid.rs",
+ "server2/src/domestic/kma/source/items.rs",
+ "server2/src/domestic/kma/source/rss.rs",
+ "server2/tests/domestic.rs",
+ "server2/tests/domestic/forecast_cold_read.rs",
+ "server2/tests/storage/catalog_peer.py",
+ "server2/examples/domestic_warm_cache_smoke.rs",
+ "server2/tools/domestic_warm_cache_smoke.py",
+ "server2/tools/test_domestic_warm_cache_smoke.py",
+ "server2/tools/cold_build_matrix.py",
+ "server2/tools/test_cold_build_matrix.py",
+ "server2/tests/runtime_services/inventory.rs"
+ ],
+ "outside": [
+ {
+ "path": "intent/issue-2849-cold-build-speed.md",
+ "category": "documentation",
+ "reason": "Task intent and acceptance criteria"
+ },
+ {
+ "path": "specs/issue-2849-cold-build-speed.md",
+ "category": "documentation",
+ "reason": "Read concurrency, admission, in-flight bound and accounting contract"
+ },
+ {
+ "path": "plans/issue-2849-cold-build-speed.md",
+ "category": "documentation",
+ "reason": "Placement declaration, user scenarios, ordered steps and verification plan"
+ },
+ {
+ "path": "docs/architecture/server2.md",
+ "category": "documentation",
+ "reason": "D2 section states the new read concurrency, store limits, in-flight bound and linear accounting"
+ },
+ {
+ "path": "docs/architecture/diagrams/server2.json",
+ "category": "documentation",
+ "reason": "Existing Archify source: D2 forecast memory card and source lines"
+ },
+ {
+ "path": "docs/architecture/diagrams/server2.html",
+ "category": "documentation",
+ "reason": "Regenerated and checked through Archify from the updated source"
+ },
+ {
+ "path": "docs/architecture/diagrams/server2-cold-read.sequence.json",
+ "category": "documentation",
+ "reason": "New cold-read sequence source bound to the implementation commit"
+ },
+ {
+ "path": "docs/architecture/diagrams/server2-cold-read.html",
+ "category": "documentation",
+ "reason": "Delivered and checked cold-read sequence"
+ },
+ {
+ "path": "docs/operations/server2-domestic-cache.md",
+ "category": "documentation",
+ "reason": "Operator limits and the latency matrix procedure"
+ },
+ {
+ "path": "docs/operations/server2-domestic-cache.pdf",
+ "category": "documentation",
+ "reason": "Regenerated only if a statement in the existing manual becomes untrue for the candidate"
+ },
+ {
+ "path": "docs/operations/server2-native-verification.md",
+ "category": "documentation",
+ "reason": "Link to the latency matrix procedure, if the native verification guide needs it"
+ },
+ {
+ "path": "docs/evidence/tasks/issue-2849-cold-build-speed/verification.md",
+ "category": "documentation",
+ "reason": "Selected candidate-bound verification evidence"
+ }
+ ]
+}
diff --git a/server2/examples/domestic_warm_cache_smoke.rs b/server2/examples/domestic_warm_cache_smoke.rs
index 53c6ff085..28776ceca 100644
--- a/server2/examples/domestic_warm_cache_smoke.rs
+++ b/server2/examples/domestic_warm_cache_smoke.rs
@@ -1,6 +1,8 @@
//! Full-window synthetic S06 baseline. Loopback only; no dotenv or live providers.
//! seed publishes exact raw formats; diagnostic uses public source answers with60s,
//! native uses KmaBackend's unchanged3s ceiling; http serves the normal direct route.
+//! aged repeats the cold request once the 30-second view freshness has passed, so the
+//! forecast views are rebuilt in an already warm process.
//! Neither diagnostic nor this owned HTTP harness is a capacity qualification.
use rusty_s3::Credentials;
use serde_json::{Value, json};
@@ -40,6 +42,8 @@ use tokio::time::Instant;
const NOW: i64 = 1_790_214_600_000; // 2026-09-24 10:50 KST.
const HOUR: i64 = 3_600_000;
type Transport = BoundedS3;
+/// The process S3 admission of the server (`S3_IN_FLIGHT` in `gateway/runtime.rs`).
+const S3_PERMITS: usize = 12;
fn transport(endpoint: &str) -> Transport {
BoundedS3::new(
HttpS3Transport::new(
@@ -49,7 +53,7 @@ fn transport(endpoint: &str) -> Transport {
Credentials::new("server2-local", "server2-local-secret"),
)
.unwrap(),
- 4,
+ S3_PERMITS,
)
}
#[derive(Default)]
@@ -645,7 +649,7 @@ impl server2::gateway::Geocoder for NoGeo {
Box::pin(async { Err(server2::gateway::GatewayError::Provider) })
}
}
-async fn http(endpoint: &str, manifest: &Path) {
+async fn http(endpoint: &str, manifest: &Path, phases: &[&str]) {
use server2::{
api::{DirectFamilies, DirectRoutes},
gateway::{BackendDispatcher, Gateway},
@@ -704,7 +708,12 @@ async fn http(endpoint: &str, manifest: &Path) {
]);
let mut complete = true;
let mut first_warm_success: Option> = None;
- for phase in ["cold", "warm1", "warm2"] {
+ for &phase in phases {
+ if phase == "aged" {
+ // Outside every request timing: wait out the 30-second view freshness so this
+ // request rebuilds the forecast views in a warm process.
+ tokio::time::sleep(Duration::from_secs(31)).await;
+ }
let before = status(endpoint).await;
let cache_before = source.forecast_cache_stats();
let denied_before = [
@@ -739,6 +748,24 @@ async fn http(endpoint: &str, manifest: &Path) {
}
.await;
let elapsed = start.elapsed().as_secs_f64() * 1000.;
+ // Outside the request timing: an answer cut by the 3-second deadline leaves its owners
+ // building (until their 8-second bound). Wait until they are idle, so the record shows
+ // when the views were ready and the counters hold their whole reads.
+ let views_ready_ms = if phase == "cold" || phase == "aged" {
+ let until = start + Duration::from_secs(10);
+ loop {
+ let state = source.forecast_cache_stats();
+ if state.active_owners == 0 && state.queued_keys == 0 && state.admitted_keys == 0 {
+ break Some(start.elapsed().as_secs_f64() * 1000.);
+ }
+ if Instant::now() >= until {
+ break None;
+ }
+ tokio::time::sleep(Duration::from_millis(5)).await;
+ }
+ } else {
+ None
+ };
let after = status(endpoint).await;
let cache_after = source.forecast_cache_stats();
let outcome = match result {
@@ -778,16 +805,20 @@ async fn http(endpoint: &str, manifest: &Path) {
denied_after[1] - denied_before[1],
denied_after[2] - denied_before[2],
];
- if phase != "cold" {
+ // An aged request rebuilds after the freshness bound; its denied stub calls are
+ // recorded, not treated as warm-path work.
+ if phase.starts_with("warm") {
complete &= denied_delta[0] == 0;
}
println!(
"{}",
- json!({"mode":"normal-native-direct-HTTP","phase":phase,"deadline_seconds":3,"client_timeout_seconds":10,"elapsed_ms":elapsed,"outcome":outcome,"status_before":before,"status_after":after,"forecast_cache_before":cache_before,"forecast_cache_after":cache_after,"provider_stub_invocations":denied_after[0],"denied_observation_web_total":denied_after[1],"denied_observation_asos_total":denied_after[2],"denied_observation_delta":{"aggregate":denied_delta[0],"web":denied_delta[1],"asos":denied_delta[2]},"provider_external_calls":0,"external_provider_guard":"injected Off methods return Transport without an HTTP client","geocoder_stub_invocations":geo.0.load(Ordering::SeqCst),"source_prewarmed":false,"native_latency_pass":false,"baseline_context":"original native baseline aggregate cold196/warm additional0; family split previously not measured"})
+ json!({"mode":"normal-native-direct-HTTP","phase":phase,"deadline_seconds":3,"client_timeout_seconds":10,"elapsed_ms":elapsed,"views_ready_ms":views_ready_ms,"outcome":outcome,"status_before":before,"status_after":after,"forecast_cache_before":cache_before,"forecast_cache_after":cache_after,"provider_stub_invocations":denied_after[0],"denied_observation_web_total":denied_after[1],"denied_observation_asos_total":denied_after[2],"denied_observation_delta":{"aggregate":denied_delta[0],"web":denied_delta[1],"asos":denied_delta[2]},"provider_external_calls":0,"external_provider_guard":"injected Off methods return Transport without an HTTP client","geocoder_stub_invocations":geo.0.load(Ordering::SeqCst),"source_prewarmed":false,"native_latency_pass":false,"baseline_context":"original native baseline aggregate cold196/warm additional0; family split previously not measured"})
);
// Stop after the first actual failure; preserve it and retire owned work before root
// diagnosis. No later warm success can turn a failed cold request into acceptance.
- if !complete {
+ // The aged rebuild still runs after a cut cold request: the cold owners finish in the
+ // background, and the run stays failed.
+ if !complete && !(phase == "cold" && phases.contains(&"aged")) {
break;
}
}
@@ -837,7 +868,7 @@ async fn main() {
assert_eq!(
args.len(),
3,
- "ENDPOINT seed|diagnostic|native|http MANIFEST"
+ "ENDPOINT seed|diagnostic|native|http|aged MANIFEST"
);
assert!(args[0].starts_with("http://127.0.0.1:"));
match args[1].as_str() {
@@ -850,7 +881,8 @@ async fn main() {
.await
}
"native" => native(&args[0]).await,
- "http" => http(&args[0], Path::new(&args[2])).await,
+ "http" => http(&args[0], Path::new(&args[2]), &["cold", "warm1", "warm2"]).await,
+ "aged" => http(&args[0], Path::new(&args[2]), &["cold", "aged"]).await,
_ => panic!("unknown mode"),
}
}
diff --git a/server2/src/domestic/kma/source/doc.rs b/server2/src/domestic/kma/source/doc.rs
index af016c4c3..2762d86f6 100644
--- a/server2/src/domestic/kma/source/doc.rs
+++ b/server2/src/domestic/kma/source/doc.rs
@@ -4,6 +4,7 @@
//! `{"$date": ms}` like every `KmaStore` answer. Key order never reaches a response (the readers
//! project fixed fields) but answers keep it, as the S15 store contract requires.
use crate::gateway::OrderedJson;
+use std::sync::atomic::{AtomicUsize, Ordering};
/// The scalar parseInt semantics used by bounded grid/RSS builders, without allocating a
/// bit vector proportional to an untrusted hexadecimal string. Significant hex values beyond
@@ -148,9 +149,52 @@ impl From for Val {
}
}
+#[cfg(test)]
+thread_local! {
+ /// Docs whose fields `heap_weight` walked on this thread (tests only).
+ static WALKS: std::cell::Cell = const { std::cell::Cell::new(0) };
+}
+
+/// The backing allocations of one stored value (a Doc's cached weight is reused).
+fn value_weight(v: &Val) -> Result {
+ use super::forecast_cache::{add, vector};
+ Ok(match v {
+ Val::Str(s) => super::forecast_cache::allocation(s.capacity())?,
+ Val::Doc(d) => d.heap_weight()?,
+ Val::Arr(a) => {
+ let mut n = vector(a)?;
+ for v in a {
+ n = add(n, value_weight(v)?)?;
+ }
+ n
+ }
+ _ => 0,
+ })
+}
+/// The exact `heap_weight` of a Doc once known; 0 is unknown (a real weight is at least 64).
+/// A clone owns other capacities, so it starts unknown; the cache never affects equality.
+/// Retained views share their Docs across threads, hence the atomic.
+#[derive(Default)]
+struct Weight(AtomicUsize);
+impl Clone for Weight {
+ fn clone(&self) -> Self {
+ Self::default()
+ }
+}
+impl PartialEq for Weight {
+ fn eq(&self, _: &Self) -> bool {
+ true
+ }
+}
+
/// An insertion-ordered document.
-#[derive(Debug, Clone, Default, PartialEq)]
-pub struct Doc(Vec<(String, Val)>);
+#[derive(Clone, Default, PartialEq)]
+pub struct Doc(Vec<(String, Val)>, Weight);
+impl std::fmt::Debug for Doc {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_tuple("Doc").field(&self.0).finish()
+ }
+}
impl Doc {
/// Fallible cache-path copy; callers own an admitted parse/fold workspace.
pub(super) fn checked_clone(&self) -> Result {
@@ -206,47 +250,54 @@ impl Doc {
.checked_mul(std::mem::size_of::<(String, Val)>())
.ok_or(Error::Capacity)?,
)?;
+ let current = self.heap_weight()?;
let projected = add(
- add(self.heap_weight()? - old, extra)?,
+ add(current - old, extra)?,
add(root - before, if is_new { allocation(k.len())? } else { 0 })?,
)?;
if projected > 8 * 1024 * 1024 {
return Err(Error::Capacity);
}
let value = clone_val(v, 0)?;
- if let Some((_, slot)) = self.0.iter_mut().find(|(key, _)| key == k) {
+ let stored = value_weight(&value)?;
+ // From the first mutation until the exact delta is stored, the weight is unknown: a
+ // failed push can leave a grown capacity behind.
+ *self.1.0.get_mut() = 0;
+ let weight = if let Some((_, slot)) = self.0.iter_mut().find(|(key, _)| key == k) {
+ let replaced = value_weight(slot)?;
*slot = value;
+ add(current - replaced, stored)?
} else {
reserve(&mut self.0, 1)?;
self.0.push((copied(k)?, value));
- }
+ let key = allocation(self.0.last().map_or(0, |(key, _)| key.capacity()))?;
+ add(
+ add(current, stored)?,
+ add(key, super::forecast_cache::vector(&self.0)? - before)?,
+ )?
+ };
+ *self.1.0.get_mut() = weight;
Ok(())
}
- /// Backing allocations only: the containing Vec/Val already charges inline headers.
+ /// Backing allocations only: the containing Vec/Val already charges inline headers. Known
+ /// after the first walk or an exact `try_set`, until an unchecked change.
pub(super) fn heap_weight(&self) -> Result {
use super::forecast_cache::{add, allocation, vector};
- fn value(v: &Val) -> Result {
- Ok(match v {
- Val::Str(s) => allocation(s.capacity())?,
- Val::Doc(d) => d.heap_weight()?,
- Val::Arr(a) => {
- let mut n = vector(a)?;
- for v in a {
- n = add(n, value(v)?)?;
- }
- n
- }
- _ => 0,
- })
+ let known = self.1.0.load(Ordering::Relaxed);
+ if known != 0 {
+ return Ok(known);
}
+ #[cfg(test)]
+ WALKS.with(|w| w.set(w.get() + 1));
let mut n = vector(&self.0)?;
for (k, v) in &self.0 {
- n = add(n, add(allocation(k.capacity())?, value(v)?)?)?;
+ n = add(n, add(allocation(k.capacity())?, value_weight(v)?)?)?;
}
+ self.1.0.store(n, Ordering::Relaxed);
Ok(n)
}
pub fn new() -> Self {
- Self(Vec::new())
+ Self::default()
}
pub fn with(mut self, k: &str, v: Val) -> Self {
self.set(k, v);
@@ -260,6 +311,7 @@ impl Doc {
}
/// `$set` of one field: replaced in place, or appended.
pub fn set(&mut self, k: &str, v: Val) {
+ *self.1.0.get_mut() = 0;
match self.0.iter_mut().find(|(key, _)| key == k) {
Some((_, slot)) => *slot = v,
None => self.0.push((k.to_owned(), v)),
@@ -267,6 +319,7 @@ impl Doc {
}
/// `$unset` / `delete`.
pub fn remove(&mut self, k: &str) {
+ *self.1.0.get_mut() = 0;
self.0.retain(|(key, _)| key != k);
}
pub fn keys(&self) -> impl Iterator {
@@ -315,7 +368,7 @@ impl Row {
super::forecast_cache::reserve(&mut fields, 32)?;
Ok(Self {
template,
- own: Doc(fields),
+ own: Doc(fields, Weight::default()),
})
}
pub(super) fn heap_weight(&self) -> Result {
@@ -357,6 +410,22 @@ impl Row {
_ => String::new(),
}
}
+ /// The string [`Self::get`] reads, without copying it: a template string is borrowed unless
+ /// it needs the value layer's canonical form ([`Val::str`]).
+ pub(super) fn str_ref(&self, k: &str) -> Option> {
+ use std::borrow::Cow;
+ match self.own.get(k) {
+ Some(Val::Str(s)) => Some(Cow::Borrowed(s)),
+ Some(_) => None,
+ None => match self.template.iter().find(|(name, _)| *name == k) {
+ Some((_, Tmpl::Str(s))) if s.as_bytes().contains(&0xF4) => {
+ Some(Cow::Owned(crate::domestic::kma::js::canon_str(s)))
+ }
+ Some((_, Tmpl::Str(s))) => Some(Cow::Borrowed(s)),
+ _ => None,
+ },
+ }
+ }
pub fn set(&mut self, k: &str, v: Val) {
self.own.set(k, v);
}
@@ -537,4 +606,171 @@ mod tests {
f64::INFINITY
);
}
+ struct Rng(u64);
+ impl Rng {
+ fn next(&mut self, bound: u64) -> u64 {
+ self.0 ^= self.0 << 13;
+ self.0 ^= self.0 >> 7;
+ self.0 ^= self.0 << 17;
+ self.0 % bound
+ }
+ }
+ /// Any value, with varied string capacities, nested Docs and (rejected by `try_set`) arrays.
+ fn any_value(rng: &mut Rng, depth: usize) -> Val {
+ match rng.next(if depth < 2 { 7 } else { 5 }) {
+ 0 => Val::Num(rng.next(1000) as f64),
+ 1 => {
+ let mut s = String::with_capacity(rng.next(200) as usize);
+ for _ in 0..rng.next(40) {
+ s.push('x');
+ }
+ Val::Str(s)
+ }
+ 2 => Val::Date(rng.next(1_000_000) as f64),
+ 3 => Val::Null,
+ 4 => Val::Bool(rng.next(2) == 0),
+ 5 => {
+ let mut d = Doc::new();
+ for i in 0..rng.next(5) {
+ d.set(&format!("n{i}"), any_value(rng, depth + 1));
+ }
+ Val::Doc(d)
+ }
+ _ => Val::Arr(
+ (0..rng.next(4))
+ .map(|_| any_value(rng, depth + 1))
+ .collect(),
+ ),
+ }
+ }
+ /// An independent full walk of the backing allocations: the oracle of every Doc weight.
+ fn fresh(doc: &Doc) -> usize {
+ use super::super::forecast_cache::{allocation, vector};
+ fn value(v: &Val) -> usize {
+ match v {
+ Val::Str(s) => allocation(s.capacity()).unwrap(),
+ Val::Doc(d) => fresh(d),
+ Val::Arr(a) => vector(a).unwrap() + a.iter().map(value).sum::(),
+ _ => 0,
+ }
+ }
+ vector(&doc.0).unwrap()
+ + doc
+ .0
+ .iter()
+ .map(|(k, v)| allocation(k.capacity()).unwrap() + value(v))
+ .sum::()
+ }
+ #[test]
+ fn doc_weights_equal_a_full_walk_after_every_change() {
+ let mut rng = Rng(0x9E37_79B9_7F4A_7C15);
+ for _ in 0..300 {
+ let mut doc = Doc::new();
+ for _ in 0..60 {
+ let key = format!("k{}", rng.next(40));
+ match rng.next(7) {
+ // Refused sets (arrays, a 33rd field) must leave a correct weight too.
+ 0..=2 => {
+ let _ = doc.try_set(&key, &any_value(&mut rng, 0));
+ }
+ 3 => doc.set(&key, any_value(&mut rng, 0)),
+ 4 => doc.remove(&key),
+ 5 => doc = doc.clone(),
+ _ => doc = doc.checked_clone().unwrap_or_else(|_| doc.clone()),
+ }
+ assert_eq!(doc.heap_weight().unwrap(), fresh(&doc));
+ }
+ }
+ }
+ #[test]
+ fn collection_admission_equals_a_full_recomputation_at_every_limit() {
+ use super::super::forecast_cache::allocation;
+ fn scalar(rng: &mut Rng) -> Val {
+ loop {
+ let v = any_value(rng, 1);
+ if !matches!(v, Val::Arr(_)) {
+ break v;
+ }
+ }
+ }
+ let mut rng = Rng(0x2545_F491_4F6C_DD1D);
+ for _ in 0..300 {
+ let mut docs = Vec::new();
+ for _ in 0..rng.next(12) {
+ let mut d = Doc::new();
+ for i in 0..rng.next(6) {
+ d.try_set(&format!("f{i}"), &scalar(&mut rng)).unwrap();
+ }
+ docs.push(d);
+ }
+ let mut doc = Doc::new();
+ for i in 0..rng.next(6) {
+ doc.try_set(&format!("g{i}"), &scalar(&mut rng)).unwrap();
+ }
+ let index = (!docs.is_empty() && rng.next(2) == 0)
+ .then(|| rng.next(docs.len() as u64) as usize);
+ for (delta, admitted) in [(-1_isize, false), (0, true), (1, true)] {
+ let mut target = docs.clone();
+ let n = target.len() + usize::from(index.is_none());
+ let exact = allocation(target.capacity().max(n) * std::mem::size_of::())
+ .unwrap()
+ + target
+ .iter()
+ .enumerate()
+ .filter(|(i, _)| Some(*i) != index)
+ .map(|(_, d)| fresh(d))
+ .sum::()
+ + fresh(&doc);
+ let before = target.clone();
+ let result = checked_store_with_limit(
+ &mut target,
+ index,
+ doc.clone(),
+ exact.saturating_add_signed(delta),
+ );
+ assert_eq!(result.is_ok(), admitted, "limit {delta:+} of {exact}");
+ if !admitted {
+ assert_eq!(target, before);
+ } else if let Some(i) = index {
+ assert_eq!(target[i], doc);
+ } else {
+ assert_eq!(target.last(), Some(&doc));
+ }
+ }
+ }
+ }
+ #[test]
+ fn stores_and_field_sets_walk_each_doc_a_bounded_number_of_times() {
+ fn walks() -> usize {
+ WALKS.with(std::cell::Cell::get)
+ }
+ fn store(n: usize) -> usize {
+ let start = walks();
+ let mut docs = Vec::new();
+ for i in 0..n {
+ let coord = Doc::new()
+ .with("mx", Val::Num(60.))
+ .with("my", Val::Num(127.));
+ let mut d = Doc::new();
+ d.try_set("mCoord", &Val::Doc(coord)).unwrap();
+ d.try_set("fcsDate", &Val::Date(i as f64)).unwrap();
+ d.try_set("data", &Val::str(format!("value {i}"))).unwrap();
+ checked_store(&mut docs, None, d).unwrap();
+ }
+ walks() - start
+ }
+ let (small, large) = (store(64), store(256));
+ assert!(
+ large <= 8 * 256,
+ "storing 256 Docs walked {large} Docs (64 Docs: {small})"
+ );
+ let start = walks();
+ let mut wide = Doc::new();
+ for i in 0..32 {
+ wide.try_set(&format!("f{i}"), &Val::Num(i as f64)).unwrap();
+ }
+ let sets = walks() - start;
+ eprintln!("Doc walks: 64 stores {small}, 256 stores {large}, 32 field sets {sets}");
+ assert!(sets <= 2, "32 field sets walked the Doc {sets} times");
+ }
}
diff --git a/server2/src/domestic/kma/source/forecast_cache.rs b/server2/src/domestic/kma/source/forecast_cache.rs
index 3805e7fcf..14dba3737 100644
--- a/server2/src/domestic/kma/source/forecast_cache.rs
+++ b/server2/src/domestic/kma/source/forecast_cache.rs
@@ -28,6 +28,9 @@ const ID_BYTES: usize = 4 * MIB;
const ENTRY_BYTES: usize = 48 * MIB;
const WORKSPACE: usize = 192 * MIB;
const OWNER: Duration = Duration::from_secs(8);
+/// Partition lookups in flight per view build: the forecast store's lookup and raw-load
+/// admission, not above the process S3 gate (`S3_IN_FLIGHT` in `gateway/runtime.rs`, 12).
+pub(super) const READ_AHEAD: usize = 12;
pub(super) fn add(a: usize, b: usize) -> Result {
a.checked_add(b).ok_or(Error::Capacity)
@@ -238,9 +241,11 @@ impl Clone for Cache {
Self(self.0.clone())
}
}
+/// Fields drop in declaration order: the bytes return before the owner slot wakes the next
+/// owner, whose reservation refuses rather than waits.
pub(super) struct Workspace {
- _slot: OwnedSemaphorePermit,
_bytes: ByteLease,
+ _slot: OwnedSemaphorePermit,
}
impl Cache {
pub(super) fn new(reader: Reader) -> Result {
@@ -510,21 +515,12 @@ impl Cache {
deadline,
)
.await?;
- for part in parts.into_iter().filter(|p| *p >= oldest) {
- let (mut found, e) = reader
- .forecast_acquisitions(
- key.source(),
- key.kind(),
- &provider,
- &part,
- &mut scope,
- deadline,
- )
- .await?;
- excluded = add(excluded, e)?;
- reserve(&mut raw, found.len())?;
- raw.append(&mut found);
- }
+ let parts = parts.into_iter().filter(|p| *p >= oldest);
+ let (mut found, e) =
+ read_partitions(reader, key, &provider, parts, &mut scope, deadline).await?;
+ excluded = add(excluded, e)?;
+ reserve(&mut raw, found.len())?;
+ raw.append(&mut found);
}
Key::Mid(..) => {
for month in checked_months(now)?.into_iter().rev() {
@@ -538,21 +534,12 @@ impl Cache {
deadline,
)
.await?;
- for part in parts {
- let (mut found, e) = reader
- .forecast_acquisitions(
- key.source(),
- key.kind(),
- &provider,
- &part,
- &mut scope,
- deadline,
- )
+ let (mut found, e) =
+ read_partitions(reader, key, &provider, parts, &mut scope, deadline)
.await?;
- excluded = add(excluded, e)?;
- reserve(&mut raw, found.len())?;
- raw.append(&mut found);
- }
+ excluded = add(excluded, e)?;
+ reserve(&mut raw, found.len())?;
+ raw.append(&mut found);
// The older fallback must retain exclusions from every searched newer month.
if let Key::Mid(model, code) = key {
let mut inputs = Vec::new();
@@ -597,6 +584,37 @@ impl Cache {
}
}
+/// The acquisitions and excluded groups of `parts` (ascending). Up to [`READ_AHEAD`] lookups
+/// overlap, but results are consumed in partition order: the retained acquisitions, the
+/// partition whose scope totals fail and the first error equal a sequential read. Returning
+/// early drops the lookups still in flight.
+async fn read_partitions(
+ reader: &Reader,
+ key: &Key,
+ provider: &ProviderKey,
+ parts: impl IntoIterator,
+ scope: &mut super::records::ForecastScope,
+ deadline: Instant,
+) -> Result<(Vec, usize), Error> {
+ use futures_util::{StreamExt, stream};
+ let mut lookups = stream::iter(parts)
+ .map(|part| async move {
+ reader
+ .forecast_lookup(key.source(), key.kind(), provider, &part, deadline)
+ .await
+ })
+ .buffered(READ_AHEAD);
+ let (mut raw, mut excluded) = (Vec::new(), 0usize);
+ while let Some(looked) = lookups.next().await {
+ let mut looked = looked?;
+ scope.admit(&looked.counted)?;
+ excluded = add(excluded, looked.excluded)?;
+ reserve(&mut raw, looked.found.len())?;
+ raw.append(&mut looked.found);
+ }
+ Ok((raw, excluded))
+}
+
// Keep canonical set/completeness, wall+monotonic expiry, writer epoch, overlay
// parent/original identity and counters explicit at the owned blocking boundary.
#[allow(clippy::too_many_arguments)]
@@ -923,6 +941,41 @@ mod tests {
}
}
#[test]
+ fn an_owner_hand_off_returns_the_workspace_before_waking_the_next_owner() {
+ use std::future::Future;
+ use std::task::{Context, Wake, Waker};
+ /// The workspace in use when the next owner is woken, inside the slot release.
+ struct Observe(Arc, Mutex