Skip to content

fix(act): grant a zero-length lease a holder, bound reads before emitting - #1613

Open
Rotorsoft wants to merge 1 commit into
masterfrom
act-1600-inmemory-degenerate-inputs
Open

fix(act): grant a zero-length lease a holder, bound reads before emitting#1613
Rotorsoft wants to merge 1 commit into
masterfrom
act-1600-inmemory-degenerate-inputs

Conversation

@Rotorsoft

Copy link
Copy Markdown
Owner

Closes #1600. Branched from master, so it can merge independently of #1611 / #1612 — it touches store-tck.ts and ports.ts in different regions than #1612 does.

Three places where the in-memory store disagreed with both SQL adapters, all the same mistake: a bound applied after the thing it was meant to bound. The framework never passes these arguments, which is why they drifted — and why the compatibility kit, written from the framework's habits, never caught it.

The one that actually bites

Leasing a stream takes a duration, and the in-memory store recorded the lease holder only when that duration was positive. Both SQL adapters record the holder unconditionally and let the duration decide only the expiry — which is the reading that matches how acknowledging works, since ack is gated on being the holder. Grant a lease with no holder recorded and the caller still gets a lease, processes the events, acknowledges them, and the acknowledgement is dropped because nobody holds the stream. The watermark never moves and the next drain redelivers the same events. Forever, with no error anywhere.

It is reachable from fully type-checked source: leaseMillis accepts 0, and the drain passes it through with ??, which does not coalesce zero. At app level that reads as first_ran: 1, total_ran: 3 with the watermark stuck at -1. Severity stays low because the in-memory store is dev/test-only by design — which is also exactly where a person meets this without a production adapter to compare against.

The two smaller ones

query_streams and query_stats counted a row and then checked the limit, so limit: 0 returned exactly one row where both SQL adapters return none.

What we checked and left alone

query({limit: 0}) looked like the obvious fourth member of the family. It isn't — Postgres and the in-memory store both treat a zero limit as unbounded and returned the same two rows in a differential probe, so there is nothing to reconcile.

limit: -1 is a genuine three-way disagreement (in-memory returns 1, SQLite returns all, Postgres throws a raw driver error), and this PR deliberately leaves it. It belongs to the nonsense-input family already recorded in #1199; writing a contract for it would be specifying behavior for a call nobody should make. The distinction we're drawing: a boundary value that legal configuration can produce earns a rule, an input that is simply wrong does not.

Cost

No behavior change for any caller that passes ordinary arguments, and no change at all to Postgres or SQLite. A caller relying on the in-memory store returning one row for limit: 0 would see zero — that is the divergence being removed, not a new risk.

Tests

Three TCK cases, each with a control that runs the same path with an ordinary argument so the boundary is the only variable: a zero-length lease whose ack lands and advances the watermark, and limit: 0 on both enumerating reads. All three were red against the unmodified InMemoryStore and green against PostgresStore, SqliteStore, and act-notify's withBroker(PostgresStore) with no adapter changes — the outlier is named by the test.

Docs: the millis parameter on Store.claim now says it bounds the expiry only and that the holder is recorded regardless; both limit doc-comments say the bound is applied before a row is emitted. Two new rows in behavior-contracts.md. Narrative in book/1600-the-bound-goes-before-the-effect.md.

Test plan

  • pnpm test — 238 files, 3643 passed, 54 skipped
  • Coverage: 100% statements / 100% branches / 100% functions / 100% lines.
  • Store TCK green on InMemory, Postgres, SQLite, and withBroker(PostgresStore)
  • New cases verified red before the fix
  • pnpm typecheck
  • pnpm lint
  • CI green
  • Review

Stability charter impact

None functionally: no signature, return shape, or method changed. The in-memory adapter now matches the contract the SQL adapters already implemented, so this narrows an implementation divergence rather than moving the contract. The two doc-comments in libs/act/src/types/ports.ts write down what the contract already required.

rfc-gate: exempt — the snapshot grew from doc-comment and implementation text in ports.ts, in-memory-store.ts, and store-tck.ts. No public export, builder method, port method, lifecycle event, or exported-type field was added.

Follow-ups

limit: -1 stays open as awareness-only under #1199, per the reasoning above.

🤖 Generated with Claude Code

https://claude.ai/code/session_015xAdM431gFeFBZTW5kRrjg

…ting

Three InMemoryStore divergences from both SQL adapters, all a bound
applied after the effect instead of before it.

`lease` recorded the holder only for a positive duration, so a
zero-length lease came back to the caller with nobody holding the
stream — and `ack` is gated on the holder, so every ack was dropped, the
watermark never advanced, and every event was redelivered on every
drain. `leaseMillis: 0` is legal config and the drain passes it straight
through (`??` does not coalesce 0), so this is reachable from
type-checked source. The holder is now recorded whatever the duration;
`millis` governs only the expiry, matching PG and SQLite.

`query_streams` and `query_stats` counted a row and then checked the
limit, so `limit: 0` returned exactly one row where both SQL adapters
return none. The bound now runs before the row is emitted.

Three TCK cases pin all of it, each with a control that exercises the
same path with an ordinary argument. Red against the unmodified
InMemoryStore, green against PostgresStore and SqliteStore unchanged.

Closes #1600

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xAdM431gFeFBZTW5kRrjg
@Rotorsoft Rotorsoft added bug Something isn't working priority:low Minor tweaks area:database Database/Models labels Sep 4, 2026
@Rotorsoft Rotorsoft self-assigned this Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:database Database/Models bug Something isn't working priority:low Minor tweaks

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

InMemoryStore degenerate-input divergences: claim(millis=0) records no holder, limit:0 emits one row

1 participant