Skip to content

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

Description

@Rotorsoft

Defect

Two degenerate-input divergences where InMemoryStore disagrees with both SQL adapters. Both come from a bound being applied after the effect rather than before.

1. claim(millis = 0) records no lease holder — every subsequent ack is dropped.

libs/act/src/adapters/in-memory-store.ts:172

lease(lease: Lease, millis: number): Lease {
  if (millis > 0) {
    this._leased_by = lease.by;
    this._leased_until = new Date(Date.now() + millis);
  }
  this._retry = this._retry + 1;
  ...

PG (postgres-store.ts:1289-1291) and SQLite (sqlite-store.ts:975-978) set leased_by/leased_until unconditionally. On InMemory the lease is granted but no holder is recorded, so ack — gated on leased_by === lease.by — is silently dropped forever and the watermark never advances.

2. limit: 0 emits one row on query_streams and query_stats.

libs/act/src/adapters/in-memory-store.ts:1098 (query_streams) — callback(...); count++; if (count >= limit) break; — and :1208 (query_stats) — out.set(...); if (limit !== undefined && out.size >= limit) break;. The bound is checked after emitting, so limit: 0 yields exactly one. PG (:1812, :1988) and SQLite (:1288, :1446) return zero rows.

Failure scenario

claim millis = 1000 (control)   InMemory / PG / SQLite   all agree, watermark advances
claim millis = 0     (red)      InMemory  { leased_by_set: false, acked_count: 0, watermark_advanced: false }
                                PG        { leased_by_set: true,  acked_count: 1, watermark_advanced: true  }
                                SQLite    { leased_by_set: true,  acked_count: 1, watermark_advanced: true  }

query_streams/query_stats limit = 2 (control)   all three agree
query_streams/query_stats limit = 0 (red)       InMemory { emitted: ["A"], count: 1 } / stats ["A"]
                                                PG, SQLite  { emitted: [], count: 0 } / stats []

At app level the first one compounds: with leaseMillis: 0, first_ran: 1, total_ran: 3 and the watermark stuck at -1every past event redelivered on every future drain, forever.

Reachability of #1: leaseMillis: z.number().min(0) (libs/act/src/internal/config.ts:134,156) makes 0 legal config, and drain-cycle.ts:667 (d.leaseMillis ?? options.leaseMillis ?? 10_000) passes it straight through — ?? does not coalesce 0. So an operator can configure this from fully type-checked source. It is still dev/test-only in practice, since InMemoryStore is documented dev/test-only.

Reachability of #2: no framework caller passes limit: 0; this is a raw-port divergence.

Proof (red)

Control/red verdicts above are the wave-21 TCK hunter's, from probes it ran (differential InMemory-vs-PG and InMemory-vs-SQLite) and archived before hitting its session limit. I verified both root-cause mechanisms directly in source — the if (millis > 0) guard and the emit-then-check ordering in both query_streams and query_stats — but did not re-run the probes in the main loop.

Severity

Low. Both need a degenerate input, and InMemoryStore is dev/test-only by design (the standing invariant that "some InMemory shortcuts are legitimate" applies to the dev/test-only framing, not to these two — neither is a declared best-effort behavior). #1 is the more interesting of the pair because leaseMillis: 0 is documented-legal config and the failure is a silent infinite redelivery rather than an error.

Fix direction

  1. Set _leased_by unconditionally in lease(), matching both SQL adapters; millis should govern only _leased_until. (Alternatively, if a zero-length lease is meant to mean "no lease", that has to be the contract on all three adapters — it currently is not.)
  2. Check the bound before emitting in both query_streams and query_stats.

Both need matching TCK cases — claim with millis: 0 and limit: 0 on the two enumerating methods are exactly the boundary arguments the store TCK never exercises, which is why these drifted.

Context

Found by debug wave 21 (TCK blind-spot lens). The same sweep closed the long-open store-differential-tck.ts:816-841 lead: a full mutator-return-value and StreamPosition-row differential (including deferred_at's exact value and leased_until presence, across defer / due-ack / block / unblock / reset) found no divergence among the three adapters. Also swept clean: a 27-case query option matrix and a 112-case query_stats matrix, both identical on all three. The one further divergence found was limit: -1 (InMemory returns 1, SQLite returns all, PG throws a raw unwrapped pg error) — recorded as awareness-only, same family as the already-filed #1199 edge-input divergences.

Activity

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

Metadata

Metadata

Assignees

Labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions