move-codex-session write-locks each destination database for a time that grows with the database, not the session #144

Open
opened 2026-10-10 07:10:10 +00:00 by jercik · 0 comments
Owner

At 1a85c77, each copy into a destination database holds that database's write lock while the script hashes every unrelated row in it. A live Codex writing there waits, and a write that waits past Codex's 5 s busy timeout fails.

copyState, copyHistory and copyThreadDatabase run BEGIN IMMEDIATE on the destination, insert the moved rows, and then call take() (lines 1184, 1217 and 1272) to confirm that no trigger changed other data. take() is snapshotUnmovedRows, which reads and hashes every row of every table except the moved ones. Its cost follows the size of the destination database, not the size of the session.

snapshotBeforeLock already runs the first such pass before the lock in WAL mode. Even so, the lock covers one full pass, and two in some cases:

  • The pass after the inserts always runs under the lock, because it must see the inserted rows.
  • The pass before the lock is thrown away and repeated under the lock when another connection commits while it runs, because data_version changed.

I measured this at 1a85c77 with a synthetic destination thread_history_1.sqlite in WAL mode, filled with thread_items rows of a thread that isn't moving, and moved one session with a child. A poller tried BEGIN IMMEDIATE on that database every 10 ms with a 1 ms timeout. The window is how long it could not get the lock, to within the 20 ms poll step. The right column adds one small commit from another process while the first pass runs. Three runs per size up to 642 MB, two at 1284 MB:

Destination history database No concurrent commit One commit during the first pass
117 MB 0.26-0.29 s 0.53-0.57 s
321 MB 0.72-0.78 s 1.47-1.59 s
642 MB 1.45-1.50 s 2.93-3.02 s
1284 MB 3.00-3.15 s 5.96-6.29 s

A commit that landed before the first pass began left the window unchanged. The rate is about 2.4 ms per MB, so the quiet window passes 5 s near 2 GB and the doubled one near 1 GB. An earlier run on APFS clones of a real 1.66 GB thread_history_1.sqlite gave 4.7 s quiet and 9.6 s with a writer committing every 1.5 s, which is within 20% of that rate.

Codex opens its state pools with a 5 s busy timeout (codex-rs/state/src/sqlite.rs:315 in rust-v0.160.1). A connection using that timeout and committing a small write every 200 ms, standing in for the invoking Codex, gave:

  • At 1284 MB, one of 25 writes failed with database is locked after 5.2 s, in both runs. The move still exited 0 with empty stderr.
  • At 642 MB, the slowest write waited 2.8-3.3 s and succeeded, in all three runs.

SKILL.md line 12 already says the destination is locked "for seconds on a large home" and that the invoking session can see database is locked. What it doesn't say is that the time is unbounded and starts costing a write at about 1 GB. Nothing is lost: the move completes and the destination stays consistent. The cost is one failed write in the invoking Codex, and how that shows up depends on what the write was.

A possible fix takes the whole-database pass out of the lock. A node:sqlite session (database.createSession()) opened before the inserts records the changes triggers make as well, so its changeset lists every row the inserts touched and can be compared with the moved rows in time proportional to the move. I checked on Node 26.10.0 that a trigger's update to a second table appears in the changeset. A table without a primary key doesn't, so such a table would keep the full pass or be refused. The alternative reads the triggers from sqlite_master and runs the pass only over tables a trigger on an inserted table can write to, which means parsing trigger bodies and is the weaker option. The rollback paths deleteThreadDatabaseRows and deleteDestinationDatabases use the same helpers and would benefit alike.

Evidence: reproduced at 1a85c77 on macOS with Node 26.10.0 on the synthetic databases above, with machine load varying between runs and the windows agreeing within about 7% per size. The 1.66 GB figures come from an earlier run on a real backup and were not repeated. The 5 s timeout comes from reading Codex 0.160.1.

At `1a85c77`, each copy into a destination database holds that database's write lock while the script hashes every unrelated row in it. A live Codex writing there waits, and a write that waits past Codex's 5 s busy timeout fails. [`copyState`](https://code.j4k.dev/j4k-oss/agent-skills/src/commit/1a85c77277b4f8924278686aa2c2969477c4eece/skills/move-codex-session/scripts/move-codex-session.ts#L1131-L1194), [`copyHistory`](https://code.j4k.dev/j4k-oss/agent-skills/src/commit/1a85c77277b4f8924278686aa2c2969477c4eece/skills/move-codex-session/scripts/move-codex-session.ts#L1196-L1227) and [`copyThreadDatabase`](https://code.j4k.dev/j4k-oss/agent-skills/src/commit/1a85c77277b4f8924278686aa2c2969477c4eece/skills/move-codex-session/scripts/move-codex-session.ts#L1229-L1282) run `BEGIN IMMEDIATE` on the destination, insert the moved rows, and then call `take()` (lines 1184, 1217 and 1272) to confirm that no trigger changed other data. `take()` is [`snapshotUnmovedRows`](https://code.j4k.dev/j4k-oss/agent-skills/src/commit/1a85c77277b4f8924278686aa2c2969477c4eece/skills/move-codex-session/scripts/move-codex-session.ts#L1063-L1088), which reads and hashes every row of every table except the moved ones. Its cost follows the size of the destination database, not the size of the session. [`snapshotBeforeLock`](https://code.j4k.dev/j4k-oss/agent-skills/src/commit/1a85c77277b4f8924278686aa2c2969477c4eece/skills/move-codex-session/scripts/move-codex-session.ts#L1100-L1129) already runs the first such pass before the lock in WAL mode. Even so, the lock covers one full pass, and two in some cases: - The pass after the inserts always runs under the lock, because it must see the inserted rows. - The pass before the lock is thrown away and repeated under the lock when another connection commits while it runs, because `data_version` changed. I measured this at `1a85c77` with a synthetic destination `thread_history_1.sqlite` in WAL mode, filled with `thread_items` rows of a thread that isn't moving, and moved one session with a child. A poller tried `BEGIN IMMEDIATE` on that database every 10 ms with a 1 ms timeout. The window is how long it could not get the lock, to within the 20 ms poll step. The right column adds one small commit from another process while the first pass runs. Three runs per size up to 642 MB, two at 1284 MB: | Destination history database | No concurrent commit | One commit during the first pass | |---|---|---| | 117 MB | 0.26-0.29 s | 0.53-0.57 s | | 321 MB | 0.72-0.78 s | 1.47-1.59 s | | 642 MB | 1.45-1.50 s | 2.93-3.02 s | | 1284 MB | 3.00-3.15 s | 5.96-6.29 s | A commit that landed before the first pass began left the window unchanged. The rate is about 2.4 ms per MB, so the quiet window passes 5 s near 2 GB and the doubled one near 1 GB. An earlier run on APFS clones of a real 1.66 GB `thread_history_1.sqlite` gave 4.7 s quiet and 9.6 s with a writer committing every 1.5 s, which is within 20% of that rate. Codex opens its state pools with a 5 s busy timeout (`codex-rs/state/src/sqlite.rs:315` in `rust-v0.160.1`). A connection using that timeout and committing a small write every 200 ms, standing in for the invoking Codex, gave: - At 1284 MB, one of 25 writes failed with `database is locked` after 5.2 s, in both runs. The move still exited 0 with empty stderr. - At 642 MB, the slowest write waited 2.8-3.3 s and succeeded, in all three runs. [SKILL.md line 12](https://code.j4k.dev/j4k-oss/agent-skills/src/commit/1a85c77277b4f8924278686aa2c2969477c4eece/skills/move-codex-session/SKILL.md#L12) already says the destination is locked "for seconds on a large home" and that the invoking session can see `database is locked`. What it doesn't say is that the time is unbounded and starts costing a write at about 1 GB. Nothing is lost: the move completes and the destination stays consistent. The cost is one failed write in the invoking Codex, and how that shows up depends on what the write was. A possible fix takes the whole-database pass out of the lock. A `node:sqlite` session (`database.createSession()`) opened before the inserts records the changes triggers make as well, so its changeset lists every row the inserts touched and can be compared with the moved rows in time proportional to the move. I checked on Node 26.10.0 that a trigger's update to a second table appears in the changeset. A table without a primary key doesn't, so such a table would keep the full pass or be refused. The alternative reads the triggers from `sqlite_master` and runs the pass only over tables a trigger on an inserted table can write to, which means parsing trigger bodies and is the weaker option. The rollback paths `deleteThreadDatabaseRows` and `deleteDestinationDatabases` use the same helpers and would benefit alike. Evidence: reproduced at `1a85c77` on macOS with Node 26.10.0 on the synthetic databases above, with machine load varying between runs and the windows agreeing within about 7% per size. The 1.66 GB figures come from an earlier run on a real backup and were not repeated. The 5 s timeout comes from reading Codex 0.160.1.
Sign in to join this conversation.
No labels
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
j4k-oss/agent-skills#144
No description provided.