{"id":50470320,"url":"https://github.com/platformatic/coordinator","last_synced_at":"2026-06-01T10:01:49.033Z","repository":{"id":360112864,"uuid":"1227032034","full_name":"platformatic/coordinator","owner":"platformatic","description":null,"archived":false,"fork":false,"pushed_at":"2026-06-01T05:30:18.000Z","size":265,"stargazers_count":0,"open_issues_count":4,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-01T07:18:41.232Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/platformatic.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-02T05:45:46.000Z","updated_at":"2026-06-01T05:29:41.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/platformatic/coordinator","commit_stats":null,"previous_names":["platformatic/coordinator"],"tags_count":6,"template":false,"template_full_name":null,"purl":"pkg:github/platformatic/coordinator","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/platformatic%2Fcoordinator","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/platformatic%2Fcoordinator/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/platformatic%2Fcoordinator/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/platformatic%2Fcoordinator/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/platformatic","download_url":"https://codeload.github.com/platformatic/coordinator/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/platformatic%2Fcoordinator/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33769492,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-01T02:00:06.963Z","response_time":115,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-06-01T10:01:43.867Z","updated_at":"2026-06-01T10:01:49.022Z","avatar_url":"https://github.com/platformatic.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# @platformatic/coordinator\n\nMulti-pod destination routing for stateful tiers. Valkey-backed registry, pod-side `Member` class, allocation strategies, lock routing, TTL cache, and optional Fastify helpers.\n\nSee [`coordinator-pattern.md`](./coordinator-pattern.md) for the architecture this library implements: caller / coordinator / resource pod, failover, fan-out, transactions and locks.\n\n## What it solves\n\nYou have N pods that hold stateful resources (PostgreSQL connection pools, agent processes, sandboxes, simulations). Requests carry a routing key -- a \"destination\" or \"instance\" id. You need every request for that destination to land on a pod that owns it. Pods die; surviving pods should take over. Under sustained load, a single destination may need to live on more than one pod.\n\nThis library handles all of that:\n\n- Destination → pod set, persisted in Valkey\n- Pod self-registration + heartbeat, with `total_connections` published as a metric\n- Atomic first-touch claim, atomic failover (SREM dead + SADD fresh)\n- Pluggable allocation strategies (round-robin, least-loaded by total connections, random)\n- Lock routing for transaction-bound calls (lockId → pod, resolved via Valkey)\n- A short-lived local cache for the hot resolution path\n- Fastify helpers for HTTP coordinators\n\n## Install\n\n```sh\nnpm install @platformatic/coordinator\n```\n\nPeer dependency: `fastify \u003e= 5` when using the Fastify plugin or helpers. `@fastify/reply-from` is a runtime dependency of this package — you don't need to install it separately.\n\n## Quick start (Fastify plugin)\n\n```ts\nimport Fastify from 'fastify'\nimport coordinatorPlugin from '@platformatic/coordinator'\n\nconst app = Fastify()\n\nawait app.register(coordinatorPlugin, {\n  redis: 'redis://valkey:6379',\n  keyPrefix: 'myservice',\n  strategy: 'least-loaded'\n})\n\napp.get('/destinations/:id/work', app.coordinator.lookupAndProxy({\n  destinationFrom: req =\u003e req.params.id,\n  claimOnMiss: true,\n  reassignOrphans: true\n}))\n\napp.post('/destinations', app.coordinator.pickAndRegister({\n  registerIdFrom: res =\u003e res.id\n}))\n\napp.delete('/destinations/:id', app.coordinator.lookupAndDeregister({\n  destinationFrom: req =\u003e req.params.id\n}))\n\napp.post('/transactions/:lockId/work', app.coordinator.lookupLockAndProxy({\n  lockFrom: req =\u003e req.params.lockId\n}))\n\nawait app.listen({ port: 3000 })\n```\n\nThe plugin:\n\n- Registers `@fastify/reply-from` (idempotently — skipped if already registered)\n- Constructs a `Registry` from the passed options (or reuses one you provide via `registry`)\n- Exposes both the registry and the route-handler-factory helpers on `app.coordinator`\n- Closes the registry on `app.close()` (unless you brought your own)\n\nOptions:\n\n```ts\ninterface CoordinatorPluginOptions {\n  // Forwarded to new Registry(...) when no `registry` is supplied:\n  redis?: string                                       // redis/valkey URL\n  keyPrefix?: string                                   // default 'coordinator'\n  strategy?: 'round-robin' | 'least-loaded' | 'random' | AllocationStrategy\n  cache?: { ttl?: number, max?: number } | false\n  requestTimeout?: number\n\n  registry?: Registry                                  // reuse an existing Registry (plugin will not close it)\n  decorateAs?: string                                  // default 'coordinator'\n  replyFrom?: FastifyReplyFromOptions                  // forwarded to @fastify/reply-from\n  registerReplyFrom?: boolean                          // default true; set false if you already registered reply-from\n}\n```\n\nThe legacy standalone helper imports (`import { lookupAndProxy } from '@platformatic/coordinator'`) still work and are documented below. They require manually registering `@fastify/reply-from` and constructing the `Registry`.\n\n## Valkey layout\n\nWith `keyPrefix: 'myservice'`:\n\n| Key | Type | Owner | Purpose |\n|---|---|---|---|\n| `myservice:members` | set | pod | the set of memberIds known to be live |\n| `myservice:member:\u003cmemberId\u003e` | hash with `address`, `load` | pod | live pod registration and load metric, TTL refreshed by heartbeat |\n| `myservice:destination:\u003cid\u003e` | set of memberIds | coordinator + pod | pods currently serving this destination |\n| `myservice:lock:\u003clockId\u003e` | hash with `podId`, `destinationId`, metadata | pod | lockId routing for transaction-bound calls |\n\n## Pod side: `Member`\n\nThe pod-side class owns its own iovalkey connection and writes the keys the pod is responsible for.\n\n```ts\nimport { Member } from '@platformatic/coordinator'\n\nconst member = new Member({\n  redis: 'redis://valkey:6379',\n  memberId: 'pod-1',\n  address: 'http://pod-1.local:3000',\n  keyPrefix: 'myservice',\n  ttl: 30,                          // seconds; default 30\n  getLoad: () =\u003e pool.openCount()   // optional; default () =\u003e 0\n})\n\nawait member.register()                                 // SADD + HSET + EXPIRE\nconst heartbeat = setInterval(() =\u003e member.heartbeat(), 10_000)  // HSET + EXPIRE\nheartbeat.unref()\n\n// When this pod fans itself in to a destination:\nawait member.addToDestination(destId)\nawait member.removeFromDestination(destId)\n\n// When this pod mints / releases a transaction lock:\nawait member.registerLock(lockId, destId, { isolationLevel: 'serializable' })\nawait member.unregisterLock(lockId)\n\n// Peer query for fan-out picks (returns live pods with their load):\nconst peers = await member.listPeerLoad()\n\n// Graceful shutdown:\nclearInterval(heartbeat)\nawait member.deregister()\nawait member.close()\n```\n\n## Coordinator side: `Registry`\n\n```ts\nimport { Registry } from '@platformatic/coordinator'\n\nconst registry = new Registry({\n  redis: 'redis://valkey:6379',\n  keyPrefix: 'myservice',\n  strategy: 'least-loaded',\n  cache: { ttl: 5000, max: 10_000 }  // default; pass `false` to disable\n})\n\n// Hot path: resolve a destination, pick one pod, return its address.\nconst resolved = await registry.resolveDestination(destId, {\n  claimOnMiss: true,      // SADD a fresh pod if the destination's set is empty\n  reassignOrphans: true   // SREM dead + SADD fresh if every pod in the set is dead\n})\nif (resolved) {\n  // { address, memberId, reassigned }\n}\n\n// Lock-bound call: route by lockId, not destination.\nconst lockRouting = await registry.resolveLock(lockId)\nif (lockRouting) {\n  // { address, memberId }\n}\n\n// Other primitives:\nawait registry.listLiveMembers()                  // [{ memberId, address, load }, ...]\nawait registry.pickMember({ destinationId: destId })\nawait registry.addPodToDestination(destId, memberId)\nawait registry.hasBinding(destId)\nawait registry.deregisterDestination(destId)\nawait registry.close()\n```\n\n## Resolution and failover\n\n`resolveDestination` reads the destination's pod set, filters by liveness, and applies the allocation strategy. The four cases:\n\n| Set state | `claimOnMiss` | `reassignOrphans` | Result |\n|---|---|---|---|\n| Empty | false | -- | `null` (404 territory) |\n| Empty | true | -- | Pick a live pod, `SADD` it, return |\n| Has live pods (possibly with dead too) | -- | -- | Pick one of the live pods; dead ones cleaned up in background |\n| All dead, non-empty | -- | false | `null` |\n| All dead, non-empty | -- | true | Pick fresh, `SREM` dead + `SADD` fresh, return with `reassigned: true` |\n\nAll writes use `SADD` / `SREM` (atomic). Concurrent first-touch by two coordinators can produce a destination with two pods from the start. That's a valid steady state, not a corrupted one.\n\n## Allocation strategies\n\nPluggable. Built-in: `round-robin` (default), `least-loaded`, `random`. Custom strategies implement:\n\n```ts\ninterface AllocationStrategy {\n  pick (candidates: MemberInfo[], ctx: { destinationId?: string }): MemberInfo | null\n}\n```\n\n`candidates` is the pool to choose from -- the full live set on first-touch / failover, or the live members of a destination's pod set on the hot path for fanned-out destinations. `ctx.destinationId` is the destination, so custom strategies can branch on it (for example, pin \"dedicated\" tenants to a designated subset of pods and round-robin \"shared\" tenants across the rest).\n\nBuilt-in least-loaded reads `load` from each candidate's member record (`HGET` pipeline). It runs at first touch for single-pod destinations and on every request for fanned-out destinations.\n\n## TTL cache\n\n`resolveDestination` checks a local LRU+TTL cache before reading Valkey. Default 5 s TTL, 10 000 entries. Configure with `cache: { ttl, max }` or disable with `cache: false`. Writes through the registry (`addPodToDestination`, `deregisterDestination`) evict the affected key. Each replica has its own cache.\n\n## Fastify helpers (standalone, advanced)\n\nThe helpers used internally by `app.coordinator.*` are also exported as standalone functions, for users who want to manage their own `Registry` and reply-from registration. Each emits a tagged result via an optional `onResult` callback so presets can hook their own metric counters.\n\nBefore mounting any helper-backed route you must register `@fastify/reply-from` (the `coordinatorPlugin` does this for you):\n\n```ts\nimport Fastify from 'fastify'\nimport replyFrom from '@fastify/reply-from'\nimport { Registry, lookupAndProxy } from '@platformatic/coordinator'\n\nconst app = Fastify()\nawait app.register(replyFrom)\nconst registry = new Registry({ redis, keyPrefix })\napp.get('/x/:id', lookupAndProxy(registry, { destinationFrom: r =\u003e r.params.id }))\n```\n\n### `lookupAndProxy`\n\n```ts\napp.post('/destinations/:id/work', lookupAndProxy(registry, {\n  destinationFrom: req =\u003e req.params.id,\n  reassignOrphans: true,\n  onResult: result =\u003e metrics.inc({ type: 'work', result }) // 'hit' | 'orphan_reassigned' | 'not_found'\n}))\n```\n\nResolves the destination, proxies via `reply.from`, returns 404 if the destination has no live pod.\n\n### `pickAndRegister`\n\n```ts\napp.post('/destinations', pickAndRegister(registry, {\n  registerIdFrom: res =\u003e res.id\n}))\n```\n\nPicks a pod, proxies the create request, and `SADD`s the returned id to the destination set only on a 2xx upstream response. Returns 503 if there are no live pods.\n\n### `lookupAndDeregister`\n\n```ts\napp.delete('/destinations/:id', lookupAndDeregister(registry, {\n  destinationFrom: req =\u003e req.params.id\n}))\n```\n\nResolves, proxies the delete; on `expectedStatus` (204 by default), `DEL`s the destination set. If the destination has only dead pods, skips the proxy and just deletes the set (\"deregistered_dead_pod\").\n\nAll four helpers go through `@fastify/reply-from`. The `coordinatorPlugin` registers it automatically; if you use the standalone helpers, you must register it yourself.\n\n### `lookupLockAndProxy`\n\n```ts\napp.post('/transactions/:lockId/work', lookupLockAndProxy(registry, {\n  lockFrom: req =\u003e req.params.lockId\n}))\n```\n\nResolves the lockId to the pod that owns it (via `Registry.resolveLock`) and proxies through. 404s if the lockId is unknown.\n\n## Testing\n\nUnit tests need a Redis on `127.0.0.1:6390`. E2E tests also need a Postgres on `127.0.0.1:15432` (storage/storage/storage). Both are in the included `docker-compose.yml`.\n\n```sh\npnpm run test:deps:up   # brings up redis + postgres\npnpm test               # unit tests\npnpm run test:e2e       # end-to-end tests (uses the storage-db example)\npnpm run test:deps:down\n```\n\nURLs are read from `REDIS_URL` (default `redis://127.0.0.1:6390`) and `PG_URL` (default `postgresql://storage:storage@127.0.0.1:15432/storage`). Tests isolate keys with a random prefix and clean up after themselves.\n\n## License\n\nApache-2.0\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fplatformatic%2Fcoordinator","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fplatformatic%2Fcoordinator","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fplatformatic%2Fcoordinator/lists"}