Skip to content

Atlas 0.5.0

Released 2026-10-19 · v0.5.0 on GitHub · install with "atlas": "github:AndreyDAraya/atlas#v0.5.0"

Delivery that keeps up under load and survives the database going away: workers spend a few statements per second when idle, deliver entries in batches, spread over every worker instance, and a lost connection no longer kills the process. Found by the MallFeed backend stress audit (an idle instance with 105 consumers issued ~840 statements/s and saturated a 10-connection pool; backlogs never drained; one instance took every lease; a Postgres restart crashed the process; DB errors signed users out).

  • Express: an identify that throws answers 503 store-failed, not 401. Only unauthenticated() (exported by atlas/express) answers 401; any other throw is an outage, reported to onError, so a client never drops a valid token because the session store failed. Migration: throw unauthenticated() where identify rejects credentials (expired, bad signature, revoked), or pass identifyErrors: 'unauthenticated' to keep the 0.4 answer while migrating. (fdb8147)
  • Conflicts retry 5 times, with a wait. A conflicting command or delivery is retried after a jittered exponential wait (retry: { attempts: 5, baseMs: 10, maxMs: 200 } by default), so conflict now carries { attempts: 5 } and arrives up to ~150 ms later. Migration: createRuntime({ retry: { attempts: 3, baseMs: 0 } }) restores 0.4. (9de7f82)
  • Unchecked record reads. A record read with checked: false loads the record without expecting its version at the commit, so a write to it between the decision and the commit is no conflict. For advisory checks whose authority lives elsewhere (a stock pre-check at checkout while the reservation reaction decides for real): concurrent checkouts of one product no longer conflict with the reservations of the ones before them. In a delivery batch an unchecked load stays out of the batch cache, so a checked read of the same record still expects its version. Found by the MallFeed stress audit (S5: 40–76% 409 with 25–100 concurrent checkouts of one variant). (1e5536e)
  • Delivery batches. Consecutive entries a consumer projects or reacts to commit in one transaction with one cursor advance, exactly once and in order: createRuntime({ delivery: { projections: 100, reactions: 20, subscribers: 10 } }) (the defaults; 1 restores one entry per transaction). Reactions in a batch see the records the ones before them wrote. A batch that conflicts or is refused commits nothing and falls back to one entry at a time. Entries that cannot share a transaction go alone (past maxDepth, not liftable, a sent command nobody owns, a reaction that reads a view). Edge subscribers resolve each fact in turn and advance once per batch. (db332c3)
  • Fair lease spread. With a store that implements the new optional Store.holders() (memory and Postgres do), each worker instance holds at most ceil(consumers / live instances running the same consumers) and hands the excess over within a renewMs; failover is unchanged. Instances hold a presence lease (instance) <hash> <owner> in atlas_leases (no schema change). (21d29bf)
  • createRuntime({ workerStore }): the workers use this store (deliveries, timers, leases), requests use store; with Postgres, a second pool on the same database isolates delivery from request connections. (aa9efde)
  • start({ idleMs }) (default 1000): the outbox check backs off from pollMs to it while nothing changes. (54f5cc0)
  • retryDelay(attempt, policy, random?), and the types RetryPolicy, DeliveryBatch, RuntimeOptions, WorkerOptions from atlas/runtime; unauthenticated() and isUnauthenticated() from atlas/express. (9de7f82, db332c3, fdb8147)
  • One outbox check per instance. Workers no longer poll per consumer, and a commit no longer wakes every consumer: one check per instance releases due timers, reads the head and the types appended since, and wakes only the consumers that take them; the others skip ahead in memory. A consumer holding its lease keeps its cursor in memory; a cursor that only skipped entries is written within 5 s or on stop(), so a lag monitor may see it trail by up to 5 s. Timers fire within idleMs after a quiet spell (was pollMs); a commit in another process is seen within pollMs..idleMs. (54f5cc0)
  • Postgres: a connection lost while the adapter held a client (Postgres restarted, a terminated backend) emitted an unhandled error event and killed the process (node-pg’s pool drops its listener while a client is checked out). Every checkout now listens until release and destroys a broken client; the operation fails (store-failed, or a worker retry with backoff). Apps still attach pool.on('error', ...) for idle clients. (ee35ab5)
  • await: 'views' (and Atlas-Await) answered either in a few ms or at the 2 s timeout with awaited: false under the new outbox check; 0.4.1 answered in ~250 ms. Three causes, each fixed: a lane whose skipped progress found its cursor moved (a drain() in the same process, or another runtime) slept renewMs (10 s) with no cursor, and the outbox check never wakes such a lane, so it now reads the cursor again at once; a local commit still hidden behind an older open transaction (any session on the server) let the check back off toward idleMs, so it now keeps pollMs until the head moves (5 s at most); and the wait required each view’s consumer to pass entries it does not take, which a worker skips without writing its cursor for up to 5 s, so it now waits only for the entries the consumer takes.

MallFeed-shaped micro-benchmark (106 consumers: 50 projections, 50 reactions, 5 edge subscribers, 1 command owner; Postgres 17 in Docker, 2 vCPU, shared with other load; pool of 10; counting every statement):

0.4.1 0.5.0
idle statements/s 1,590 26 (about 11 of them lease renewals)
drain 20,000 facts (7 consumers take them) 96.7 s, 986k statements 24.2 s, 325k statements

Run migrate() as before (nothing new in the schema). Throw unauthenticated() from identify for bad credentials. Optional: give the workers their own pool (workerStore), and run N identical worker instances: they now share the load. Custom stores: implement holders() to get the fair spread (the contract suite covers it); without it the first instance takes every free lease, as in 0.4.


From CHANGELOG.md. All versions: Releases.