Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Watching & Hooks

config.watch({ debounceMs: 250 })

Every file this configuration reads is watched. An edit reloads it — on the watcher's own thread, so the program is not structured around watching — and the new document is installed only if the schema accepts it.

The delivery semantics — edge-triggered, latest-wins, what a refused reload does and does not wake — are one contract across Rust, Python and Node, written once in the engine book's Change Notification page. What follows is the Node surface of it, including the latest | serial | every backpressure vocabulary that page uses.

A rejected edit changes nothing

This is the property the whole design is for, and it holds identically for a watcher-driven reload and an explicit one:

config.onReload((document) => console.log("installed", document))

// A file edited into something the schema refuses:
//   - installs nothing
//   - fires no hook
//   - leaves `current()` answering the last good document
//   - moves `status().consecutiveFailures`

Anything can re-read a file. What makes hot reload safe to leave running in production is that a bad edit is a failed attempt rather than a half-configured process.

Three kinds of hook

const token = config.onReload((document) => …)       // every install
config.onChange("pool.maxSize", (now, before) => …)  // one path, when it moves
config.onReloadAsync(async (document) => …)          // and one that awaits
config.removeHook(token)

onReload fires once per install, on the event loop — the reload happened on another thread, and the hook is queued to the loop the way any Node callback is. onChange is the same subscription with a comparison in front of it: it fires when the value at that path differs, and hands over both values.

After await config.reload(), your hooks have run. The install happens on a worker thread and the hooks are queued for the loop, so the explicit paths wait one turn of the loop before returning — otherwise await reload() would mean the document is installed but not your hook has seen it, which are two things a caller has every right to think are one.

A watcher-driven reload has no await to hang that on: its hooks fire whenever the loop next breathes, which is what a watcher is.

A hook that awaits

onReload takes a function and ignores what it returns, so an async one handed to it leaves a promise nobody awaits: two installs in quick succession run their bodies interleaved, and a rejection becomes an unhandled one. onReloadAsync is the same subscription with those two things handled:

config.onReloadAsync(async (document, { signal }) => {
  await pool.resize(document.pool.maxSize, { signal })
})
backpressureWhen an install lands while the hook is still running
"latest"Keep the newest and drop what it overtook. The default.
"serial"Queue every one and run them in order, dropping nothing.
"every"Start each as it arrives — onReload, with the rejection still caught.

"latest" is the default because it is what configuration usually means: resizing a pool to a size nobody is asking for any more is work done for nothing. "serial" is for a hook that is an audit trail rather than a reconciliation, where a gap is a broken audit trail.

Nothing can cancel a promise in JavaScript, so what a superseded call gets under "latest" is the signal — aborted the moment a newer install takes its place, and passable straight to fetch or to a query that knows what to do with one.

A rejection is reported rather than thrown: console.error by default, or onError if a program has somewhere better to put it. A configuration hook is not the place to end a process.

The diagnostic stream

changes() is the document stream a service loop wants; events() is the one a log line, a metric or an alert is built from:

for await (const event of config.events()) {
  if (event.type === "reloadFailed" && event.consecutive > 3) {
    alert(`configuration refused at ${event.path}: ${event.kind}`)
  } else if (event.type === "reloaded") {
    log.info("config %d: %s", event.generation, event.changed.join(", "))
  }
}

No event carries a value. Paths, kinds, counts and timestamps only — the same rule explain() and check() follow, and for the same reason: a value in an event is a secret in a log. changedPaths(before, after) is the same comparison, exported for the code that wants to make it itself.

A refusal wakes the stream natively: the engine's failure hook reaches the event loop the same way an install's does, so reloadFailed arrives when the refusal happens — no timer, no polling. Delivery is latest-wins: refusals with nothing awake in between arrive as one event carrying the current consecutive count, and a refusal followed by an install arrives as both events, refusal first. (failurePollMs, the interval refusals were polled at before they could wake anything, is accepted, ignored, and warns once.) onReloadFailed(hook) is the callback twin, handed nothing — read status() for what happened.

Lifetimes are signals

Everything here that runs "until told otherwise" takes an AbortSignal, because that is how Node spells lifetimes:

const controller = new AbortController()

config.watch({ signal: controller.signal })

for await (const document of config.changes({ signal: controller.signal })) {
  await pool.resize(document.pool.maxSize)
}

// Later — one call ends the watcher and the stream together.
controller.abort()

An abort ends a changes() or events() iteration the way a break would — a return, not an error — and stops a watcher the way stopWatching() does. A server's shutdown signal, a test's cleanup, a request's timeout: whatever owns the lifetime owns the streams tied to it, with nothing to forget.

Polling, for filesystems that do not notify

config.watch({ debounceMs: 250, pollMs: 1_000 })

A container bind mount, an NFS share and a few overlay filesystems deliver no change events. pollMs re-stats on an interval instead, at the cost of that interval's latency — the same choice WatchMode::Poll is in Rust.

Debounce

An editor writing a file is several syscalls, and a naive watcher reloads in the middle of one. The debounce is how long to wait for the writes to stop; 250 ms is the default and is generous enough for every editor and every kubectl apply this has been pointed at.

Stopping

config.stopWatching()

Idempotent, and not required for a process to exit: the watcher holds no reference that keeps the event loop alive. A script that loads a configuration, starts a watcher and finishes still exits — which is the first thing anybody would notice and the last thing they would guess.

The whole lifetime, as one call

await config.running(async (document) => {
  await serve(document)
})

Load, watch, run, stop. JavaScript has no with, so the block is a function; what it buys is the same thing a with would — the watcher cannot be left running by an exception on the way out, and there is no handle to forget. { watch: false } for the load-and-serve half alone.

Several configurations at once

const group = new ConfigGroup(database, cache, queue)

await group.running(async () => {
  await serve()
})

await group.reloadAtomic()   // every member validates, or none installs

A group is lifecycle, not storage: database.current() is still the read, and it still has the member's own type. status() and generations() answer per key, which is one call for a health endpoint.

reloadAtomic() is the one that is not just a loop. The mixed state it prevents: a deployment moves three files, two parse and one does not, and the process runs on two new documents and one old one — with nothing in any of them saying so. Every member loads and validates first, and only when all of them have does any of them install; a refusal leaves every document exactly as it was, generation included. It is the engine's own ReloadGroup — prepare, then commit — driven from JavaScript.

group.reload() is the other half of the contract, and does not do this: each member reloads independently, one refusing leaves the others on their new documents, and the first failure is thrown after every member has had its turn.