Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Entries are newest-last within a release, matching the order they were written.
- **the live view streamed a finished run as a running one, forever.** `frames()` polls the trace file's size, rebuilds a snapshot when it changes, and stores that snapshot's `size` as "everything up to here has been sent". `build_snapshot` took that number from `path.stat()` *after* reading the events, and the two disagree in the direction that matters: the read stops at the last newline, and — this being a live view — the run is appending while the snapshot is built, so bytes can land between the read and the stat. The cursor then claimed events the snapshot never saw, the next poll found an unchanged file size and rebuilt nothing, and once the writer stopped the file never changed size again. Reproduced rather than argued: with one append landing in that window the cursor came back *equal to the file's final size* while `done` was still false — the exact pair that leaves the stream with no reason to rebuild and nothing left to learn. `TailRecorder.read_tail()` now returns the events and the byte count they came from, `read_events()` keeps its signature over it for every other caller, and only the mtime is still read from the file's current state — safe to be too new, because it delays an idle verdict by one poll rather than stopping the stream. The cursor now falls short of the file, so the next poll rebuilds and the missed event arrives.
- **an interrupt landing inside the deadline guard's own teardown left a timer raising into the thread forever.** `fire` queues its asynchronous exception while holding the guard's lock, so a guard already blocked on that same lock inside `disarm` is handed the exception the moment it acquires it — at the next bytecode, which is before `armed` is cleared and before the re-armed timer is cancelled. `disarm` propagated, and the 50ms timer it was meant to cancel stayed alive still reading `armed` as true: it re-raised `NodeDeadlineExceeded` into that thread every 50ms for the life of the thread, long after the run that armed it had finished. On a pooled thread that is an unattributable crash in whatever ran next — which is precisely what `test_no_interrupt_survives_the_node_that_earned_it` exists to rule out, reached by a path it did not cover. The lock was never the flaw: both sides do take it, and what a lock cannot do is stop an asynchronous exception arriving between two bytecodes inside the section it protects. The teardown is retried rather than abandoned now, and clears `armed` outside the lock as a last resort, that single store being what stops `fire` re-arming; swallowing the interrupt there costs nothing, because the guard still decides the outcome from whether the timer fired and still raises on it. The SIGALRM mechanism is unaffected — CPython runs the Python-level handler at a bytecode boundary, so `setitimer(ITIMER_REAL, 0)` has already completed when the handler raises.
- **the lockfile's copy of the version drifted, twice, unnoticed.** `ci.yml` checks that `pyproject.toml` and `grapharc.__version__` agree, and its comment says why: if they drift, `pip show` and `import` disagree about what is installed. There is a third copy — `uv.lock` carries an entry for this project — and both 0.1.6 and 0.1.7 shipped with it reading `0.1.5`, because nothing re-locked after the bump and no job looked. Milder than the other two, since it misreports the project to a reader of the lockfile and to `uv sync --locked` rather than to an installed import, but the same class of bug as the one that step already guards. The check now parses `uv.lock` as well, names `uv lock` as the remedy, and refuses anything other than exactly one entry for the project so a rename cannot make it silently vacuous.
- **an imported retraction came back as current on the LadybugDB backend.** `superseded_by` was reconstructed from the `SUPERSEDED_BY` edge, and `add()` cannot create an edge towards a claim the store does not have yet — in the natural import order it does not have it, because `all_claims()` returns oldest-first, so a superseded claim arrives before the claim that superseded it. The edge was skipped while `superseded_at` was written, so one row said both things at once: `superseded_at` said retracted and `superseded_by IS NULL` said current, and `Claim.is_current` believed the second. `current("svc")` — which is supposed to answer "what is true now" — returned *both sides of a correction*, and the two other backends disagreed with this one. Every import, replay, backup restore and copy between backends went through that path; `supersede()` never did, which is why every test that exercised it passed. `superseded_by` is a column now and the edge is derived from it: reads project the column, `current()` filters on the column rather than on an `OPTIONAL MATCH`, and `_write` reconciles the edge in both directions — forward when this claim names a superseder that is present, backward when a stored claim names *this* one and could not have an edge until now — so the edge is complete once both ends have arrived, whatever order they arrived in, and it stays walkable for the Cypher path this backend exists for. Two alternatives were costed and rejected: failing closed breaks replaying `all_claims()` in the order it hands you, and a stub target node reads back as a `ValidationError` and has no `seq`. The column needs a schema change, which `CREATE NODE TABLE IF NOT EXISTS` will not perform on an existing database, so opening one adds the column and backfills it from the edges an older database does have — `supersede()` always wrote those. What `add()` dropped is not recoverable; what the edges recorded is.

Tooling, in the same release and not defects in the shipped package: the suite
now runs weekly against dependencies re-resolved from scratch rather than only
Expand Down
Loading