NestJS — memory persistence

The shared-memory analog of NestJS's Redis WebSocket adapter: field regions mirror to a Redis hash so contract state survives restarts — and optionally replicate across processes over a pub/sub channel, so two app instances see each other's writes.

Wiring

The pool config's persistence option takes the adapter factory — the pool invokes it right after binding the contract and calls its stop() inside pool.terminate() (which AtollModule already runs on module destroy — the final flush lands there).

app.module.tsbackend
// shared-memory persistence — the Redis adapter as a pool option
import { AtollModule } from '@atolljs/nestjs';
import { redisMemoryAdapter, ioRedisSubscriber } from '@atolljs/node/redis';
import { incidentsMemory } from './incidents.contract';

// Static client in scope:
AtollModule.registerPool({
  name: 'incidents',
  worker: () => new Worker(new URL('./incidents.worker.ts', import.meta.url)),
  sharedMemory: incidentsMemory,
  persistence: redisMemoryAdapter(redis),
});

// Or inject the client — registerPoolAsync:
AtollModule.registerPoolAsync({
  name: 'incidents',
  useFactory: (redis: Redis) => ({
    worker: () => new Worker(new URL('./incidents.worker.ts', import.meta.url)),
    sharedMemory: incidentsMemory,
    persistence: redisMemoryAdapter(redis, {
      name: 'incidents',                       // hash key atoll:mem:incidents
      syncIntervalMs: 100,                     // version-diff poll cadence
      // Cross-process replication — a SECOND connection (pub/sub needs its own):
      subscriber: ioRedisSubscriber(redis.duplicate()),
      // fields: ['state.metrics', 'signals.seedProgress'],   // persist a subset
    }),
  }),
  inject: ['REDIS'],
});
main.tsbackend
// attach by hand anywhere a bound contract exists — the pool
// keeps it alive (attach) and stops it (terminate → final flush)
import { persistSharedMemory } from '@atolljs/node/redis';

const persistence = persistSharedMemory(incidentsMemory, {
  client: redis,          // hset/hgetall/publish on base64 strings
  name: 'incidents',
  subscriber: ioRedisSubscriber(redis.duplicate()),
});
await persistence.ready;  // initial HGETALL restored into the buffer

How it works

  • The buffer stays the source of truth. read()/write() remain synchronous memory ops — Redis sits behind the contract, never in front of it.
  • Per-field hash members — atoll:mem:<name> holds path → base64 bytes for every field region (or the fields subset).
  • Version-diff flush — every connector write already bumps an Atomics counter; the adapter polls those counters on syncIntervalMs and hsets only what moved. No instrumentation of the hot path.
  • Restore on attach — ready resolves after the initial hgetall writes bytes back into the buffer and bumps local versions, so observe() watchers fire as if the writes were local.

Replication across instances

With subscriber set, each flush also publishes { src, path, b64 } to atoll:mem:<name>:ops. Subscribers write the bytes into their own buffer and bump the local version counter — same wake semantics as a local write. An instance id guards echoes (an applied remote write doesn't re-publish). Semantics are last-write-wins per field — Redis is coordination, not consensus; it suits dashboards/progress/read-models, not transactional state.

Caveats

  • List fields only flush after commit() — writes are detected via the version counter, same as observers.
  • ready races early worker binds: workers that bound before restore see zeros, then the version bumps land. Await ready before serving traffic that depends on restored state.
  • Pub/sub needs a dedicated connection — ioRedisSubscriber(redis.duplicate()) for ioredis; node-redis's subscribe(ch, cb) shape fits RedisSubscriber directly.

The adapter itself is framework-free — Node.js → Persistence covers createNodePool usage and the client interface.