Skip to content

native: split the tic into two resident statements - #521

Merged
MarcusKainth merged 7 commits into
mainfrom
native/two-resident-statements
Sep 7, 2026
Merged

native: split the tic into two resident statements#521
MarcusKainth merged 7 commits into
mainfrom
native/two-resident-statements

Conversation

@MarcusKainth

@MarcusKainth MarcusKainth commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Rebased twice since review. First onto main after #511 and #509 both
landed (head 033a780): git cherry-pick of the first four commits applied
clean, the fifth conflicted because main independently gained a
FIRST_TIC_TIMEOUT/TIC_TIMEOUT share (a different PR did roughly the same
consolidation before this branch started) and a later PR raised
FIRST_TIC_TIMEOUT to 600 s under real multi-lane CI load; resolved by
keeping main's own 600 s rather than the 300 s this branch had been
carrying, with the reasoning in that commit's own message rather than the
code. A sixth commit migrated five files #505 and #509 had added after this
branch's own split, which never went through the mechanical
.push-to-.extend fix every other caller needed.

Then onto main again after #513 landed (head bb8bfbf): the same five
commits applied clean this time, since main already carried the 600 s
FIRST_TIC_TIMEOUT from the first rebase's own resolution. The sixth
commit picked up two more files #513 added
(sim_missile_same_target_live.rs, sim_missile_wall_live.rs) needing the
same treatment, folded into that same commit along with a retitle (the
original named the two PRs rather than saying what the commit does) and
trimming a few "own"s the body didn't need.

What this changes, and why

The tic's own resident statement analysed at 58.4 s on a quiet run and past a
minute under load, and most of that cost was not any routine's own
arithmetic: a statement nested this deep re-lists every still-needed binding
at every level, and that repetition, not the stages themselves, is what
QueryAnalysisMicroseconds was paying for. Removing hud::tickers and the
melt, or narrowing the outer projection to five columns, left the cost
essentially where it was; only cutting the tic in two, at the boundary
between the player/thinkers and the specials/G_Ticker, moved it.

The simulation is now two resident statements chained through a new table,
native_stage: the first writes one row per tic there, through the player
and the thinkers; the second reads that row back and writes native_state,
through the specials and G_Ticker. Both statements are sent at session open
so their analyses overlap rather than add up, and the driver feeds the
second the moment the first's own row is visible, so a caller that only
calls feed_sim/wait_sim sees no difference from one statement. The
protocol both statements run on (the chunked body, the padding row, the
settings) moves into native's own resident module, since the wire
protocol is part of native mode's own contract and not something the driver
should carry alone. Every multi-tic test that drove the simulation through a
batched, non-interleaved call is migrated to a harness that feeds it the way
a session does, because a batch that runs every tic through the first
statement and then every tic through the second reads native_state before
the second statement has written any of it past the first tic — confirmed
live against sim_door_live's own door crossing, which lost its thinker the
tic after it spawned one under the old batched call.

Evidence

Analysis, each statement and their sum, against a fresh measurement of
main's own single statement, back to back on the same server so the
comparison is fair to whatever the machine was doing at the time (this
machine runs several other sessions; absolute numbers move with load, the
ratio does not):

main (single statement):    sim analysis   81.37 s
this branch (two statements): sim analysis 32.75 s
                               sim2 analysis 1.10 s
                               sum           33.86 s

First tic wall time, same back-to-back run, native demo --from sim --stop-at-frame 5: main 84.8 s, this branch 35.0 s. Both statements (and
the renderer) open at once, so the branch's own first tic pays for the
larger analysis rather than the sum; the ratio here (2.4x) tracks the
analysis ratio above rather than adding the renderer's own ~1.1 s on top.

State-row hash against main, same demo, run to the first shared refusal
(both stop at tic 177, PSP_STUCK, since neither branch's own thinker order
changed): sum(cityHash64(*)) over every column of every row native_state
holds for tic 0 through 176.

main:         177 rows, max tic 176, hash 14984976478853026052
this branch:  177 rows, max tic 176, hash 14984976478853026052

Session timing, this branch, live:

$ cargo test -p clickdoom-driver --test native_stream_live --features clickhouse-tests -- --nocapture
one_statement_takes_a_hundred_tics_and_chains_them: send-to-visible over 100 tics: p50 1.13 ms, p99 1.96 ms, max 2.04 ms

$ cargo test -p clickdoom-driver --test native_session_live --features clickhouse-tests -- --nocapture
a_session_runs_tics_and_their_frames_in_order: over 20 tics: wait_sim p50 2.90 ms p99 4.60 ms, frame (320768 bytes) p50 1.60 ms p99 2.79 ms

wait_sim's own p50/p99 covers the full two-statement hop (feed the first,
wait for native_stage, feed the second, wait for native_state), against
TIC_TIMEOUT's own 5 s budget.

native demo --from sim, this branch, a real paced run against demo3:
tics/s tracks what the level is doing rather than holding at 35, the same as
main; both branches reach the same first refusal (tic 177, PSP_STUCK) at
the same tics/s profile.

Four close-timeout reproduction attempts, before the real cause was found:
sequential background load, eight concurrent native diff processes
sharing the server (analysis times up to ~51,000,000 µs), the debug binary
in place of release (matching what cargo test's own subprocess runs), and
a quiet baseline. All four completed cleanly: system.query_log showed
type: QueryFinish, an empty exception, and query_duration_ms matching
wall clock exactly for the statement each attempt watched. None reproduced
the close hang.

The real cause was not a hang. native_demo_live's own failing test runs
clickdoom native demo twice against one database: once to frame 41 for a
hash check, then again with --stop-at-frame 500 expecting the refusal at
exit 3. The second run's own restart truncated native_state and rewrote
the level's first row, but never touched native_stage, so the table still
held the first run's own staged rows. Session::wait_sim's presence check
reads native_stage directly, with no notion of which run wrote a row, so
for every tic the second run asked about it found the first run's leftover
row already there and fed the second statement immediately, without ever
waiting on the second run's own first statement to write it. Feeding raced
ahead of real computation for as long as the leftover rows lasted; the
moment they ran out, wait_sim had to wait for real production for the
first time, the first statement had whatever backlog the race left it with,
and the run missed TIC_TIMEOUT — the same symptom on a different call
path from the close-after-refusal timeout the four attempts above could not
catch, since none of them reused a database across two runs of the
simulation. restart_sim (demo) and restart (diff) now truncate
native_stage alongside native_state; Session::open and
Session::recover also truncate it unconditionally, since the table never
carries anything past the tic in flight and native play resumes across
process runs without ever calling either restart path.

CLOSE_TIMEOUT widens from 10 s to 60 s regardless: it bounds detecting a
statement that has died after its own body closed, not the cost of a normal
close, and a close happens once per run. SessionError::SimClose and
SessionError::RenderClose name which statement failed to close and, on a
timeout specifically, read system.processes and system.query_log for
that statement's own query id so the next occurrence carries its own
diagnosis.

One more finding, orthogonal to the split itself: sim_thrust_live's own
"crossed" arm gives every line special 88 (a WR plat trigger) so the arm
does not have to know which line the seeded thrust crosses, and left every
other line at its own tag, 0. P_FindSectorFromLineTag compares tags as
plain numbers, so tag 0 matches every untagged sector in the map, not "no
sector": once the tic before the arm was computed correctly for the first
time (by the interleaved harness, rather than the batch bug's own defaults),
some other sector genuinely already running a thinker fell inside the tag-0
blast radius, and the arm's own crossing spawned a plat on it too, which is
exactly what PLANE_CLIP_STUCK exists to flag. The arm now gives every line
and one chosen sector the same otherwise-unused tag, isolating the spawn,
and reads that sector's own thinker directly rather than only inferring it
from the absence of a stuck bit. The same investigation found the arm's own
assertion was already stale before this: special 88 is in
specials::PLAT_TRIGGER_SPECIALS, which unhandled_crossable filters out
of the unresolved set, so a clean crossing of it leaves nothing unresolved,
not TX_CROSSED as the arm's own comment had said.

Full live pass before the rebase, one lock hold, --test-threads=1 within
each nextest group:

$ scripts/test-group.sh native-sim-a   7 tests run: 7 passed
$ scripts/test-group.sh native-sim-b  28 tests run: 28 passed
$ scripts/test-group.sh native-sim-c   5 tests run: 5 passed
$ scripts/test-group.sh native-sim-d   2 tests run: 2 passed
$ scripts/test-group.sh native-sim-e   1 test run: 1 passed
$ scripts/test-group.sh native-rest  324 tests run: 324 passed
$ scripts/test-group.sh driver-native 19 tests run: 19 passed

After the first rebase (onto main with #511/#509), against a throwaway
database on the shared server:

$ clickdoom native load --fresh --database use_lines_final
  loaded in 3.14 s

$ scripts/test-group.sh driver-native
Summary [ 253.411s] 19 tests run: 19 passed, 0 skipped

$ scripts/test-group.sh native-sim-a
Summary [ 262.985s] 7 tests run: 7 passed, 0 skipped

After the second rebase (onto main with #513), the two files it added:

$ clickdoom native load --fresh --database use_lines_v3
  loaded in 5.25 s

$ cargo test -p clickdoom-native --test sim_missile_same_target_live \
    --test sim_missile_wall_live --features clickhouse-tests -- --test-threads=1
test result: ok. 2 passed; 0 failed (sim_missile_same_target_live)
test result: ok. 2 passed; 0 failed (sim_missile_wall_live)

Unit suites, clippy and fmt, on the final rebased head (bb8bfbf):

$ cargo test -p clickdoom-native --lib
test result: ok. 296 passed; 0 failed

$ cargo test -p clickdoom-driver --lib
test result: ok. 112 passed; 0 failed

$ cargo clippy -p clickdoom-native -p clickdoom-driver --all-targets --features clickhouse-tests -- -D warnings
$ cargo fmt --check
$ ./scripts/check_purity.sh
Purity check passed.

Invariants

None of PURITY.md's numbered rules changes shape. native_stage is a
plain Join table like native_state, populated the same way; the two new
files in native/src/resident/ carry the same Instant::now() timing the
driver's own resident code already carried, each annotated purity-ok for
the same reason (pacing and timeouts in the caller, never a value a
statement reads).

Spec impact

  • None. No contract in SPEC.md is touched. NATIVE.md's own resident
    statements section changes in the same commit as the generator that
    implements it.

Checks

  • make gates — not run directly; the suites above cover it
  • make native-smoke
  • No AI attribution trailers in the commits

Anything else

Six commits, each verified to build on its own in an isolated worktree
before the next was written on top:

  1. native: split the tic into two resident statements — the generator,
    native_stage's schema, NATIVE.md, the golden plan listing (shifted by
    the new table), and the mechanical .push.extend every caller of
    run_statement/demo_statement needs for the new two-statement return
    shape.
  2. native: own the resident protocol as native mode's own contract — the
    move from driver/src/native/{stream,url,rowbinary,settings}.rs to
    native/src/resident/.
  3. driver: drive the simulation as two resident statementsSession's
    own two-statement handoff, the native_stage truncate-on-open/recover,
    the widened CLOSE_TIMEOUT and the self-diagnosing close error.
  4. native: drive multi-tic tests through the two resident statements
    the harness and every suite it replaces a batched call in, plus the
    sim_thrust_live seeding fix the harness surfaced.
  5. native: state each resident timeout and the statement pairing once
    FIRST_TIC_TIMEOUT, TIC_TIMEOUT and CLOSE_TIMEOUT all live in
    native::resident; tick::resident_statements pairs the two statements
    in one call, and the individual functions drop to pub(crate).
  6. native: drive the remaining live suites through the harness — the
    same mechanical and harness fix applied to seven files that landed on
    main after this branch's split existed.

Written mostly by Claude Sonnet 5.

@github-actions github-actions Bot added area: driver The client loop that ticks the batch statement and blits frames area: ci Workflows, the Makefile, and the scripts they run area: docs The prose: READMEs, ADRs, and the contributor documents area: native Native mode: the tic simulation and renderer as SQL, and the WAD loader labels Sep 7, 2026
@MarcusKainth
MarcusKainth marked this pull request as ready for review September 7, 2026 15:49
@MarcusKainth
MarcusKainth force-pushed the native/two-resident-statements branch from bb78e82 to 033a780 Compare September 7, 2026 18:04
Analysis on the tic's own resident statement cost 58.4 s, and most of it
was not accounted for by any routine's own arithmetic: the final
projection re-lists every still-needed binding at every nesting level a
statement this deep has, and that repetition is what QueryAnalysisMicroseconds
was paying for, not the stages themselves. A copy of the generator with
hud::tickers and the melt removed still cost close to the same; narrowing
the outer projection to five columns did not either. Splitting the tic in
two at the thinker/specials boundary cut analysis to 26.2 s combined, and
the two analyse concurrently at session start, so a session's first tic
also drops by more than half.

The first statement carries the player and the thinkers and writes one
row per tic into a new table, native_stage, shaped like native_state plus
two scratch columns the second statement reads by their own bare name
across the boundary: px_crossed_line and tx_crossed_line, neither a
native_state column. The second statement reads that row back through
joinGet the way the whole tic reads the tic before, and writes
native_state as before. Everything else scratch-named at the boundary
resolves through state or is a map constant both stages reload fresh from
the same reference tables, so native_stage does not need to carry it.

native_stage's own column list duplicates native_state's: a Join engine
table refuses ALTER TABLE ... ADD COLUMN, so the two cannot share a
declaration, and a change to one's contract is a change to both. The
schema check covers the new table the same way it covers every other one.

Unit tests: the two statements' own column sets meet exactly at the
boundary, and no bare alias but the two staged ones crosses it. Every test
that drives run_statement or demo_statement over more than one row now
gets both statements back and issues them with .extend rather than
.push, which is the minimum a caller needs to keep compiling against the
new return shape; native/tests/support/resident.rs, in a later commit,
is what actually feeds them the way a session does.

NATIVE.md's resident statements section describes both statements and
native_stage in the same commit as the generator that implements them.
A resident statement's wire protocol, the padding row, the chunked body,
the settings a statement needs before the server parses it, is part of
native mode's own contract, not something the driver invents on its own
behalf. It moves from driver/src/native/{stream,url,rowbinary,settings}.rs
to native/src/resident/, verbatim apart from Endpoint replacing the
driver's own ConnArgs at the open boundary, since the native crate cannot
depend on the driver's clap-derived connection arguments. The driver's
own Session converts ConnArgs to Endpoint at the one place it opens a
statement.

native/src/lib.rs's own doc comment carves out this exception: everything
else in the crate builds SQL text and executes nothing, and resident is
the one part that does, because the wire protocol it runs on is itself
part of the contract NATIVE.md states.

driver/tests/native_stream_live.rs stays in the driver crate: it is
native::stream's own live proof rather than a session-level one, and
moving it would mean rewriting its Db-based read-back onto native's own
Fixture for no behavioural reason.

The driver's own cli/native.rs and native/session.rs pick up the Endpoint
conversion at their own open call sites. session.rs's own two-statement
handoff is a separate commit.
Session::open sends both simulation statements at once, alongside the
renderer, so a session's first tic pays for the larger of their analyses
rather than the sum. wait_sim feeds the second statement the moment
native_stage holds the first's own row for a tic, then goes on polling
native_state the way it always did, so a caller that only calls feed_sim
and wait_sim sees no difference from one statement. recover ends and
reopens both, and reports the error either one gave.

Session::open and Session::recover both truncate native_stage before
either simulation statement opens. The table never carries anything past
the tic in flight when its own writer last ran, so a session pays for one
recomputed tic rather than risk a row a prior run left behind answering
the first statement's own presence check for a tic the reopened statement
has not written itself. native play resumes straight from whatever
native_state and native_stage hold across process runs and never
truncates on its own, so it depended on this. demo's own restart_sim and
diff's own restart already truncate native_state for a full reset and now
truncate native_stage alongside it, for the same reason at the point a
run chooses to start over rather than resume.

Ending a session's two simulation statements is sequential, not
concurrent: the second statement's own body includes rows that read the
first's, so ending them at once risked the second waiting on a commit the
first's own close had not yet forced. SessionError::SimClose names which
of the two failed to close, since a caller debugging a hang needs to know
which; SessionError::RenderClose carries the same for the renderer.
CLOSE_TIMEOUT widens from 10 s to 60 s: it bounds detecting a statement
that has died after its own body closed, not the cost of a normal close,
and a close happens once per run. When a close does time out, the error
reads system.processes for the statement's own query id, and
system.query_log for it once the process is gone, and carries what it
finds rather than leaving the next person to reproduce it by hand.
A batch that runs every tic through the first statement and then every
tic through the second is wrong past the first tic: the first statement's
own previous(db) reads the tic before out of native_state, which the
second statement writes, so a tic beyond the first reads a row the second
statement has not written yet. native/tests/support/resident.rs opens
both statements once, the way a session does, feeds one tic to the first,
waits for its own row in native_stage, feeds the second from it, and
waits for its own row in native_state before feeding the next tic.
Confirmed live against sim_door_live's own door crossing before this
existed: a door spawned correctly on the crossing tic and then lost every
thinker the very next tic, because the first statement's own batch, still
computing later tics, read native_state before the second statement's
batch had ever written any of it.

Every sim_*_live.rs suite that ran more than one tic through run_statement
or demo_statement switches to it. A suite that seeds one row and runs
exactly one tic from it keeps the batched call, since a batch of one row
has no later tic to read a stale block for.

sim_thrust_live's own "crossed" arm surfaced a real, if unrelated, gap in
its own seeding once the tics it copies from ran correctly for the first
time: it gives every line special 88 so the arm does not depend on the
map putting one where the thrust happens to go, and every untouched line
keeps tag 0. P_FindSectorFromLineTag compares tags as plain numbers, so
tag 0 matches every untagged sector in the map, not "no sector", and the
crossing spawned a plat on every one of them at once rather than the one
sector the arm means to check. The arm now gives every line and one
sector the same otherwise-unused tag, isolating the spawn to that sector,
and reads its own thinker directly to say so. The same fix corrected the
arm's own stale assertion: 88 is in specials::PLAT_TRIGGER_SPECIALS,
which unhandled_crossable filters out of the unresolved set, so a clean
crossing leaves nothing unresolved, not TX_CROSSED as the arm's own
comment and assertion had said.
FIRST_TIC_TIMEOUT and TIC_TIMEOUT moved into native::resident, beside
CLOSE_TIMEOUT: all three bound how long a caller waits on a resident
statement, which is a fact about the protocol rather than about the
driver's own session or a test harness. session.rs no longer defines
them; its callers (demo, diff, play, and their own live test) read them
from native::resident, and wait_sim's own doc comment points there too.
The test harness keeps its own TIC_TIMEOUT at 30 s rather than the
driver's 5 s, since a test has no 35 Hz budget to pace against and a wide
bound costs nothing on a shared machine; it reuses FIRST_TIC_TIMEOUT
unchanged, since that one is sized for a slow CI runner either way.

FIRST_TIC_TIMEOUT carries main's own current value, 600 s, not the 300 s
this branch had been carrying: #506 raised it after three lanes' own
containers running together measured about 59 s for the tic's own
statement on main, well past 300 s on a CI runner four times slower. The
bound still catches a dead statement either way; the cost itself is
tracked separately from what this constant is for.

tick::resident_statements(db) pairs the tic's own two statements in one
call, and resident_statement_stage1/stage2 drop to pub(crate): the second
statement's own input depends on rows only the first writes, so nothing
outside the crate can open one without the other, which is the property
NATIVE.md states. Every caller that built the pair by hand (the three CLI
subcommands and their own live tests, the harness) now calls it instead.
sim_player_damage_live.rs and sim_refire_live.rs, sim_gunshot_kill_drop_
live.rs, sim_kills_and_drops_live.rs, sim_missile_kill_drop_live.rs, and
sim_missile_same_target_live.rs and sim_missile_wall_live.rs all landed
on main after this branch's split and never went through the mechanical
push-to-extend fix every other caller of run_statement and demo_statement
needed for the new two-statement return shape, since the two-statement
generator did not exist yet where they were written. sim_thrust_live.rs's
later "crowd" arm carried the same gap in its single-tic run_statement
call.

sim_refire_live.rs, sim_gunshot_kill_drop_live.rs and sim_missile_wall_
live.rs seed every arm straight from tic 0 and run exactly one tic from
it, so the mechanical fix is all either needed. sim_player_damage_live.rs's,
sim_kills_and_drops_live.rs's, sim_missile_kill_drop_live.rs's and
sim_missile_same_target_live.rs's BEFORE-walk is the same arms-style
multi-tic pattern the previous commit already moved seven other suites
through, so it gets the same treatment here.
@MarcusKainth
MarcusKainth force-pushed the native/two-resident-statements branch from 033a780 to bb8bfbf Compare September 7, 2026 19:03
The crossing arm's own tag isolation (a shared tag on every line and one
sector, so a plat special's tag-0 search would land on one sector rather
than every untagged one) and #499's own fix for the identical problem
(switching the arm to a special native does not dispatch at all, so no
search runs) both solved the same tag-0 storm this arm hit once type 22
made 88 a dispatched special, on separate branches, at separate times.
Rebasing onto main picked up #499's own rename to MONSTER_UNHANDLED at the
one line both patches touched, and kept the tag isolation and the plat-
spawn assertions this branch had added around it: git's own merge saw no
overlapping line to conflict on, so the result carried a special that
never dispatches anything, an ALTER TABLE pair tagging sectors nothing
ever reads, and an assertion still expecting the dispatch to run and
leave nothing unresolved. CI caught it: `unresolved` came back as
`TX_CROSSED`, since the crossing genuinely is not run.

MONSTER_UNHANDLED is undispatched by design, so nothing needs isolating
from it. The tag mutations, `CROSSING_TAG`, `CROSSING_SECTOR` and the
`plat_at_crossing_sector` column drop; the arm's own final assertion goes
back to what #499 already established, that the crossing leaves the tic
unresolved with `TX_CROSSED` and nothing else.
@MarcusKainth
MarcusKainth merged commit 46dcd9b into main Sep 7, 2026
20 checks passed
@MarcusKainth
MarcusKainth deleted the native/two-resident-statements branch September 7, 2026 20:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: ci Workflows, the Makefile, and the scripts they run area: docs The prose: READMEs, ADRs, and the contributor documents area: driver The client loop that ticks the batch statement and blits frames area: native Native mode: the tic simulation and renderer as SQL, and the WAD loader

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant