Skip to content

server2: restore station observations from day products after a restart - #2872

Draft
hwanjjang wants to merge 14 commits into
feat/2614-kma-restart-readsfrom
feat/2614-kma-restart-observe
Draft

hwanjjang wants to merge 14 commits into
feat/2614-kma-restart-readsfrom
feat/2614-kma-restart-observe

Conversation

@hwanjjang

@hwanjjang hwanjjang commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Stacked on #2866 (base feat/2614-kma-restart-reads) so CI builds both restart fixes together for one EC2 comparison. AK chose this design on 2026-11-07 ("A로 진행").

The EC2 restart comparison of #2866 showed that the first weather request after a restart misses the minute observations and the past days, and that S3 admission was not the remaining cause. The observation source's restart read (observe.rs read_s3) restored every stored AWS hourly and city page of the 8-day window one day after another and parsed each page again; the minute pages came last, and every minute page call read the descriptor of every group in the day's minute catalog. Measured parse cost: about 10 ms per AWS page on an M4, so roughly 1.0-1.4 s per day and 12-17 s for 8 days on a t3.small, plus 4-5 s of sequential GETs, against a 2.5 s request wait.

Changes

  • Minute pages (storage/catalog.rs lookup_members, records.rs): a restart lists the day's minute catalog once and restores only the newest acquisition of each minute within the readers' 40 minutes; the newest four before the first answer, the rest after it.
  • Day product (observe_product.rs, kind station-pages, one per KST day): the parsed inputs of the hourly fold (the newest AWS hourly and city page of each hour, with their page identities), written by any process that folds, at most once per ten minutes per day and only when what the day is made of changed. No request waits for it. Excluded from the disk cache like the image forecast product.
  • Restart read: each day's newest product is read and compared with the day catalogs (S3 is truth); only pages the product lacks are restored and parsed. Today and yesterday are compared before the first answer, the other days after it (LoadRest). An unreadable product, an older schema or parse revision, or a grown city set falls back to the day's raw pages and the product is stored again.
  • S16: page identities, legacy answers and the response-first durability set are unchanged. A day stays partial when its product's read had an S06-excluded group (excluded carried in the product) or a newer group is not complete.
  • Documents: server2.md (S15b-2 restart read, S16 note), KMA data design (+ regenerated Archify sequence), infrastructure. Records: intent/, specs/, plans/issue-2614-kma-restart-observe.md; declaration server2/config/tasks/KMA-RESTART-OBSERVE.json.

Verification

  • New tests: targeted_members_read_one_catalog_and_only_their_groups, a_restart_restores_only_the_minute_pages_within_reach (200 stored minute groups: at most 20 descriptor and 20 raw GETs), and six observe_product tests (restart answers 168 past hours and minute rows with providers unreachable and no raw GET for covered pages; a later page restored alone and equal to a cold read; fallbacks; two writers; unsettled while LoadRest runs; an excluded group stays partial; products not cached). Each failed before the change; mutations caught: LoadRest dropped from settled, excluded not carried, made-of check ignored.
  • Full cargo test --locked --workspace on the stack at 9c16049: 1,236 passed, 0 failed, 7 ignored; CI passes. cargo fmt --check, cargo clippy -D warnings, placement gate and artifact policy pass. Archify: validate, deliver, strict check and browser check pass.
  • EC2 restart comparison (2026-11-07, t3.small, bucket after about 55 minutes of master operation, so one day partition only; master / server2: keep S3 permits for requests while warning work runs #2866 / this stack at 9c16049, three restarts each, first request to Seoul):
    • First request: master 3.06 / 3.01 / 3.55 s, server2: keep S3 permits for requests while warning work runs #2866 3.04 / 3.58 / 3.59 s, stack 3.71 / 3.45 / 3.04 s. Past days were missing in all nine first answers; minute observations were present only in the third stack restart.
    • The observation Load ended at 2.17-2.30 s with products (stack r2, r3) against 3.31-4.54 s for master's whole restart read; the remaining days continued in LoadRest.
    • Client validation (AK's definition: every field the client gets today, except slow-provider exclusions such as fine dust): not met by any first answer after a restart, on any binary (missing current observations, yesterday's values, past days, history mapping, freeze index, UV and narrative). kma_client_eval.py reported 0 violations for these bodies; it checks shape, not completeness.
    • Every first request made 1,100-1,300 S3 calls across all sources (forecasts, air, warnings, KASI, observations), so the first answer stays bound by the general restart restore, not by the observation read alone. A bucket with only one day of pages cannot show the 8-day saving this change targets.

Decisions for AK

  • The first answer after a restart does not meet the client validation (AK's definition) with or without this change; this change shortens the observation restart read only. How to meet the criterion is open.

  • Products are stored under kma-web / station-pages and never deleted: estimated 300-400 KB gzipped per day product, about 25 MB a day per writing process at the ten-minute limit (not measured).

  • The first answer after a start never records daily summaries (S16): the days past yesterday are compared after it, so a later request records them.

  • A group older than a day product's pages, published after the product and excluded by S06, is not noticed on a restart from the product (a raw read would mark the day partial).

  • Right after the first deployment, the oldest day of the window has no product and is read from its raw pages after the first answer.

Not in this change: a product for the minute pages, the ASOS station state, the collector's restart probing.

🤖 Generated with Claude Code

hwanjjang and others added 5 commits November 7, 2026 12:02
A restart read the minute pages through latest-first pages, and every
page call read the descriptor of every group of the day's minute catalog
(up to ~720 a day). The day catalog is now listed once (control objects
only), the newest acquisition of each minute within 40 minutes is kept,
and only those groups are restored.

CatalogStore::lookup_members reads one catalog and then only the named
members' groups; lookup_acquisition is its one-member case.
Reader::listed_acquisitions, moved from the image forecast, lists a
catalog's acquisitions for both callers. ordered_candidates and
lookup_page are unchanged for the other cursor readers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A restart restored every stored AWS hourly and city page of the 8-day
window and parsed each again, about 1 s per day on a t3.small, so the
first request answered without past days (#2866). AK chose the day
product design on 2026-11-07.

Any process that folds now stores one product per KST day
(kma-web/station-pages) holding the parsed inputs of the hourly fold:
the newest page per kind and hour with its fetch instant, digest and raw
identity, reduced AWS rows as a facts table, the city set they are whole
for and the S06 exclusions of the read. It is written after a fold that
recomputed the hourly part, never awaited by a request, at most once per
ten minutes per day and only when the day's pages changed. Values keep
undefined, dates, negative zero and NaN.

A restart reads each day's newest product and compares it with the day
catalogs: unlisted product pages are dropped and only newer listed pages
are restored and parsed. An unusable product (unreadable, older schema
or parse revision, a grown city set) or a newer page that is not complete
gives the day's raw read. The load ends after the products, today's and
yesterday's comparisons, the seed and the four newest minute pages;
LoadRest finishes the other days and minutes. settled() counts LoadRest
and the product comparison as busy, and an excluded group keeps its day
partial. Products are kept out of the disk cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #2872 (CHANGES_REQUESTED) found three answers that differed
from a cold raw read.

RO-city-growth: a city page restored after the products could name a
station whose AWS rows the products had reduced to registry facts. A
fold whose city set grew past the set an AWS page was reduced for now
starts Flight::Expand, which reads those pages in full again by their raw
identity (RecordId::from_object_key); the next fold reduces them for the
new set.

RO-minute-fallback: the minute read kept only the newest acquisition of
each minute. Up to three are kept, newest first, and a minute whose
acquisition is not complete or does not parse is read from the next one.

RO-sticky-excluded: a product carried only the count of excluded groups,
so a repaired group kept its day partial. Products (schema 2) now record
knownIds and excludedIds, and each read of a day restores the groups
that are neither held nor known complete: the excluded ones again and
those published since. A repaired group completes the day and the
product is stored again; a group published later and older than the
pages is checked too. S06 excluded members come from CompleteSet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Second review of #2872 (RO-city-growth, RO-expand-progress,
RO-record-key-overflow).

A held AWS page reduced for a smaller city set than the last fold's now
keeps settled() false, so no summary is recorded from its missing rows.
The full read of such pages is started after every fold and request
(not only after an hourly recompute), reads four pages per catalog read
and keeps each read as it completes; only started pages wait for the
retry, now one minute as for a failed ASOS S3 read, and pages not
reached before the 30 s limit stay due for the next run.

RecordId::from_object_key requires the key's four, two and two date
digits before computing the date, so out-of-range numbers are refused
instead of overflowing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hwanjjang

Copy link
Copy Markdown
Collaborator Author

Cross-provider review (Codex gpt-6.1-sol, high): PASS at 9c16049 (third round).

Required (all fixed, each with a regression test seen failing first)

  • RO-city-growth: a station joining the city set after a product left reduced AWS rows; fixed by a full re-read (Expand, 78886d2) that keeps answers unsettled until done and retries independently (9c16049).
  • RO-minute-fallback: an incomplete newest minute acquisition dropped the minute; up to three candidates per minute now (78886d2).
  • RO-sticky-excluded: a repaired excluded group stayed counted; products record knownIds/excludedIds and re-check them (78886d2).
  • RO-expand-progress: a slow full re-read discarded completed batches; batches now apply as they complete (9c16049).

Recommended

  • RO-record-key-overflow: adopted (9c16049).
  • RO-shared-budget (whether this PR slows the mid forecast reads seen failing on CI for 78886d2): not adopted as a code change. The same failure reproduced under CPU load on the stack base 83014bb without this PR, the S3 request counts for that request are identical, and CI passes at 9c16049.

EC2 restart comparison: see the PR description. The first answer after a restart is still bound by the general restart restore (1,100-1,300 S3 calls across all sources).

hwanjjang added a commit that referenced this pull request Nov 7, 2026
AK chose on 2026-11-07 that the first answer after a restart is judged
by the client validation (kma_client_eval.py), with parts that are not
ready within the bound coming on later requests. The EC2 comparisons of
master, this change and this change with #2872 showed 36 of 36 bodies
passing it and first requests of 3.0 to 3.7 s on every binary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hwanjjang and others added 3 commits November 7, 2026 17:20
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…is_read

observe_product::the_newest_of_two_processes_products_is_read failed 9
times in 20 at 0fa360e with one product for a day. Both producing
processes stored every day's product (8 PUTs each), but two processes
started together run in step and make each day's product within 0 to 3
ms of each other. The product's instant (made, product_candidates) is
its record's fetched time and is in its bytes, so two made in the same
millisecond are one record identity and S06 keeps one catalog entry.
That is right for S3; the test assumed two instants.

The second process now runs on a test transport that holds its AWS
hourly LISTs: it lists the (absent) products, waits there while the
first process stores its products, then makes its own, so each day holds
two products of different instants and the third process reads the
newer. 20 consecutive passes, and 10 with the CPU saturated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test double answered the forecast-zone page with Seoul only. When that
list was stored before the request's zone refresh, towns outside Seoul had
no city zone and their mid temperature was not collected, so
a_province_under_its_new_name_finds_its_town_and_mid_forecast failed in
about 60% of runs on this branch (the day product reads at the start of a
request make the race more likely) and occasionally on master. The double
now answers every packaged land zone, as the provider's list does, and the
test runs each town with and without the list stored first.

Same test change as 0c76e77 on fix/2614-kma-test-clock, declared here so
this PR's CI keeps one task declaration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…TC-force-fallback-order)

full_response::a_province_under_its_new_name_finds_its_town_and_mid_forecast
asked each town with the forecast-zone list stored first and "without
it", but nothing kept the request's own zone acquisition from storing
the list before the answer: counting the lists S3 held when the answer
was made, the "without" case had one in 10 of 10 runs, so it did not pin
the release's land-zone fallback.

In that case the double's zone request now waits at a barrier
(HeldZones) until the answer has been checked, and the test asserts the
lists S3 held at the answer (none, or the one stored first) and that the
held request stores its list once released. 10/10 passes. The same test
change as 64541ef on fix/2614-kma-test-clock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI run 42379684133 (52a270c) failed
full_response::empty_weather_store_serves_seven_days_before_raw_publication_and_replays_identically:
one raw publication before the body terminal. Its trace shows a group
published 2 ms into the owned flight that acquired it, while the request
was still running. Writer::acquire counted a waiter only by its watch
receiver, and run() asked receiver_count() after sending Ready: a waiter
that took the batch and returned before that check, or a creating caller
not yet polled again when an instant provider was done (it subscribed
after spawning the job), left the batch looking unwaited, and NoWaiters
released it, so the response's own raw was stored before the response
finished (against provider -> assemble -> respond -> store). The writer
is unchanged from master; the day products do not use it.

The caller now subscribes before the job is spawned (or under the table
lock when it joins), and the waiters are counted before Ready is sent.
The new tests/response_first.rs
a_creating_waiter_holds_its_instant_provider_until_the_body_ends (2,000
scoped instant acquisitions) failed 17/20 runs with 30 busy loops on 10
cores (NoWaiters releases before the body ended) and passes 20/20 under
the same load; both full-cycle tests pass 20/20 under it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hwanjjang

Copy link
Copy Markdown
Collaborator Author

Cross-provider re-review (Codex gpt-6.1-sol, high), round 4 on the commits after the round-3 PASS (b34c001, 52a270c, 06018c2, e708d12): CHANGES_REQUESTED.

Required

Recommended

  • RO2-dormancy-test: the 2,000-acquisition test can hit the normal DormancyCap on a slow runner. Selected.

Both are being fixed once and applied identically here and on #2903.

hwanjjang and others added 2 commits November 8, 2026 01:02
… (RFW-zero-count-join)

run() counted the receivers, then announced Ready and decided NoWaiters,
without the table lock under which callers join entries. A response that
joined after its creator was cancelled (count 0) but before Ready got the
batch while the stored 0 released it as unwaited, so its raw was
published while its body was still being sent.

Who waits is now settled under the table lock together with the Ready
announcement and the NoWaiters decision. Publisher::checkpoint is a test
seam (a default that is ready at once; no setting). The new
a_response_joining_before_ready_holds_a_cancelled_creators_batch forces
that order; with the count taken before the checkpoint it failed 3/3
([NoWaiters]). Same writer and test code as 7d27588 on
fix/2614-response-first-waiters (#2903).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…heduling)

The regression test for the waiter count relied on the scheduler to
preempt at the right step over 2,000 instant acquisitions, and its
"nothing released" check could trip on a slow runner's 10 s dormancy
cap. Two more Publisher::checkpoint points (Created, Announced) let
a_creator_not_yet_waiting_when_its_batch_is_ready_holds_it and
a_waiter_that_takes_the_batch_at_once_holds_it force the orders; each
failed 3/3 with the old order ([NoWaiters]). The mass run is an ignored
stress check that fails only on NoWaiters. The spec's RESPONSE-FIRST
criterion names the forced-order tests. Same writer and test code as
03f7b57 on fix/2614-response-first-waiters (#2903).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hwanjjang

Copy link
Copy Markdown
Collaborator Author

Cross-provider re-review (Codex gpt-6.1-sol, high), round 5: PASS at 76add9e. RO2-ready-join-race and RO2-dormancy-test are resolved. The writer and its tests are byte-identical to #2903.

@hwanjjang

Copy link
Copy Markdown
Collaborator Author

CI passes 14 of 14 at d840e9a, which merges master through #2866. Two earlier attempts were cancelled by the 45-minute job limit of the foundation job; passing runs take about 44 minutes. The cross-provider re-review passed (round 5). The restart race (#2900) builds on this PR; its EC2 results are on #2900.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant