e2 · lazy initialisation and batching

Open it once, on first use. Write it once, for everyone who asked.

Two primitives, memo and batch, and one mechanism underneath them, shared with the runtime's own construction of services. This page explains the mechanism once, then each primitive on a realistic case: a messaging client whose local store is backed by IndexedDB.

The mechanism

A cell runs an effect at most once and shares the outcome

Both primitives, with timings you can watch, are in the playground.

empty inflight done first demand runs on a fiber with no parent, against the captured service map success the value goes to every joiner, then is kept failure: every joiner receives it, the cell resets, the next demand retries a concurrent demand joins a demand gets the value
The runtime keeps one cell per provider. memo makes anonymous ones. batch uses the same fiber discipline per run.

Three rules follow, and they are the same for a service, a memoised resource and a batch:

  • The run happens on a fiber with no parent, against the service map captured when the cell was made. A demander interrupted while waiting simply stops waiting; it cannot cancel a shared run. An acquire inside registers on the scope the cell was created in, not on whichever request came first.
  • Concurrent demands join the one run. The value is written once.
  • Failure is not cached. Joiners receive it, the cell goes back to empty, and the next demand tries again. Backoff is composition: memo(retry(policy)(open)).
memo

memo(fx): the read is the sync point, not the constructor

The case it exists for: a store backed by IndexedDB. Opening is asynchronous, must happen once, nothing should wait for it at startup, and the first read should. Consumers must not know.

Yielding memo(fx) does not run fx. It records fx's requirements against the body it is yielded in, so they land in the provider's Needs, captures the current service map and scope, and returns a dependency-free handle. Yielding the handle runs fx on first demand. That is why the contract below says Fx<Message | undefined, IdbError> with nothing in the third slot, and why the provider cannot fail at construction: the open's failure belongs to the read that triggered it.

export const LocalStoreLive = LocalStore.make(function* () {
  const config = yield* Config
  // Opened once, on the first read, closed with the runtime.
  const db = yield* memo(
    acquire(
      () => openMessages('messages-' + config.userId),
      (store) => store.close(),
      (reason) => new IdbError(reason)
    )
  )
  return {
    get: (id) => gen(function* () {
      const store = yield* db                      // the read is the sync point
      return yield* fromPromise(() => store.get(id), (reason) => new IdbError(reason))
    }),
  }
})
// Provider<'LocalStore', 'Config', never>
startup reads memo cell Config LocalStore Mps runtime([…]) — LocalStore constructs synchronously; IndexedDB is not touched first get() waits second get() joins get() open already, returns at once open IndexedDB — once done → value shared by every joiner close() rt.close() — finalizer on the runtime scope a caller interrupted while waiting stops waiting; the open continues for the others
memo(fx, { eager: true }) starts the run at the point of creation, in the background: reads arriving before it settles wait, reads after do not.

Outside a provider it captures the request: const user = yield* memo(db.query(id)) is one query however often the handle is yielded, and it is gone with the request. A per-key cache is a Map of handles, not a new primitive.

memo over a key

"I will need Mps, later"

A service key is itself an Fx, so memo applies to it with no API of its own. Yielding memo(Mps) at construction records 'Mps' in the provider's Needs, so the list is still checked as a whole, and returns a handle. Yielding the handle later resolves Mps then: builds it if the cell is empty, joins if it is in flight, returns it if it is done. The contract is untouched.

Take a messaging client's Send: it seals a message, transmits it, then hands it to Mps, the message-processing service, to persist. Mps is needed only for that last step, and at the moment a send happens it may be unbuilt, or halfway through construction because Receive is building it.

export const SendLive = Send.make(function* () {
  const config  = yield* Config
  const crypto  = yield* Crypto
  const network = yield* Network
  const lazyMps = yield* memo(Mps)           // recorded in Needs now; resolved later
  return {
    send: (threadId, payload) => gen(function* () {
      const envelope = yield* crypto.seal(inbound)
      yield* network.send(envelope)
      const mps = yield* lazyMps               // empty → builds · inflight → joins · done → returns
      return yield* mps.ingest(inbound)
    }),
  }
})
// Provider<'Send', 'Config' | 'Crypto' | 'Network' | 'Mps', never>
batch

batch(run): many single calls, one run

batch turns an operation over many items into one over single items whose calls are coalesced. A batch collects until window milliseconds after its first item (default 0, the next turn of the event loop) or until it holds maxSize items. The run happens on a parentless fiber against the captured map, and a finalizer flushes whatever is pending when the owning scope closes, so nothing enqueued is lost at shutdown.

MemberDoesWaits?
add(item)adds an item; the caller receives its own element of the result, by positionyes, for its batch. DataLoader semantics. Needs an async entry point.
enqueue(item)adds an item and returnsno. Write-behind. Works under runSync.
flushruns what is pending now and waits for every batch in flightyes
pendingitems added but not yet handed to a runno
callers batch put put put … ×50, one synchronous run put window fires: 1 write, 50 rows 1 write, 1 row window = 0: the next turn of the event loop, after the burst · maxSize closes a batch early · flush closes one now
Fifty puts in one synchronous run commit as one write of fifty rows.

The store that motivated it uses enqueue: put makes the row visible at once and hands it to the batch, so a hundred puts in a synchronous run are one transaction.

let writes = 0
const writer = yield* batch(
  (messages: readonly Message[]) => sync(() => { writes += 1; return messages }),   // one transaction
  { maxSize: 500 }
)

put: (message) => gen(function* () {
  const rows = yield* db
  rows.set(key(message), message)      // visible now
  yield* writer.enqueue(message)       // durable with its neighbours, shortly
  return message
}),
flush: writer.flush,
writes: sync(() => writes),

Failure of a run reaches every add waiting on it and the next flush; the following batch is independent. A run that produces a different number of results than it was given items is a defect.

A design note

Where coalescing lives is decided by who reads

In the messaging client the write buffer lives in LocalStore, not in Mps, although Mps orchestrates persistence. The reason is the read path. Validation stages and handlers read the store directly, and a reaction that arrives in the same batch as its target has to find that target. A buffer held by MPS would hide the target until flushed, and the reaction would be rejected. Pending rows must be readable before they are durable, and only the store can arrange that.

The corollary is that a batch is processed in order, one envelope at a time. Order is what makes an in-batch reference safe, and the write coalescing does not need concurrency to pay off.