Files
fusion/packages/core/src/shared-mesh-state.ts
gsxdsm 85f8b1f909 feat: shared Postgres multi-node — retire mesh data-plane replication (#2130)
## Summary

- Treat **shared PostgreSQL** (`DATABASE_URL`) as the multi-node durable
data plane; mesh HTTP is membership + optional auth, not task/settings
replication.
- **Peer exchange**: under Postgres backend mode, write queue is
**topology/auth-only**; non-topology pending rows fail rather than
replaying multi-leader task/settings payloads.
- **Mesh routes**: task-ID reserve/commit/abort always hit local shared
allocator rows (ignore remote `coordinatorNodeId`); mesh sync ignores
settings and only exchanges `authMaterial`.
- **Docs**: rewrite multi-project runbook, shared cluster protocol, and
architecture mesh sections for shared-Postgres + claims/leases.

## Context

Follows the SQLite→Postgres cutover. Multiple Fusion nodes can share one
external Postgres while keeping **per-node execution** (worktrees,
processes, claims via `central.task_claims`). Explicit non-goals remain:
scheduler failover and live process migration.

Plan:
`docs/plans/2026-07-15-001-refactor-mesh-shared-postgres-multinode-plan.md`

## Test plan

- [x] `pnpm --filter @fusion/engine exec vitest run
src/__tests__/peer-exchange-service.test.ts`
- [x] `pnpm --filter @fusion/dashboard exec vitest run
src/__tests__/mesh-routes.test.ts`
- [x] `pnpm --filter @fusion/core exec vitest run
src/__tests__/shared-mesh-state.test.ts`
- [ ] CI gate (lint/typecheck/build/gate)
- [ ] Manual (optional): two processes, same `DATABASE_URL`, create task
on A visible on B; settings change without mesh settings sync; claim
exclusivity

## Operator note

Multi-node shared board requires **external** `DATABASE_URL` on every
node. Default embedded Postgres is still single-host.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Improved multi-node deployments using shared PostgreSQL as the durable
source of execution state.
* Task ID reservation/commit/abort now run locally (no remote
coordinator forwarding).
* Mesh syncing now prioritizes topology visibility and authentication
material; settings replication is disabled in shared-Postgres mode.
* **Bug Fixes**
* Prevented task/settings replication over mesh HTTP in shared-Postgres
deployments.
* Refined lease ownership, recovery, and reconciliation to converge via
shared-database primitives.
* **Documentation**
* Updated architecture and shared-mesh protocol guidance, including
multi-node setup and lease/task-ID allocation behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-15 13:32:33 -07:00

72 lines
2.6 KiB
TypeScript

import { createHash } from "node:crypto";
export { SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
import { SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
import type {
GlobalSettings,
ProjectSettings,
ProviderAuthEntry,
} from "./types.js";
/*
FNXC:PostgresCutover 2026-07-12:
FNXC:SharedPostgresMultiNode 2026-07-14-23:45:
Task/state mesh replication is REMOVED — shared PostgreSQL is the SoT.
createProjectSettingsSnapshot remains for legacy helpers/tests only; live mesh
routes no longer apply or emit projectSettings. authMaterial stays on the wire
because auth.json is per-machine file state, plus the shared envelope/checksum
plumbing.
*/
export const SHARED_STATE_DEFAULT_LIMIT = 10_000;
export interface SharedSnapshotEnvelope<TPayload> {
version: number;
exportedAt: string;
checksum: string;
payload: TPayload;
}
export type ProjectSettingsSnapshot = SharedSnapshotEnvelope<{
global: GlobalSettings;
projects?: Record<string, ProjectSettings>;
}>;
export type AuthMaterialSnapshot = SharedSnapshotEnvelope<{
providerAuth?: Record<string, ProviderAuthEntry>;
}>;
function withChecksum<TPayload>(payload: TPayload, exportedAt?: string): SharedSnapshotEnvelope<TPayload> {
const envelope = {
version: SHARED_STATE_SNAPSHOT_VERSION,
exportedAt: exportedAt ?? new Date().toISOString(),
payload,
};
return { ...envelope, checksum: computeSnapshotChecksum(envelope) };
}
export function computeSnapshotChecksum(snapshotWithoutChecksum: Omit<SharedSnapshotEnvelope<unknown>, "checksum">): string {
return createHash("sha256").update(JSON.stringify(snapshotWithoutChecksum)).digest("hex");
}
export function validateSnapshotEnvelope(snapshot: SharedSnapshotEnvelope<unknown>, expectedVersion = SHARED_STATE_SNAPSHOT_VERSION): void {
if (snapshot.version !== expectedVersion) {
throw new Error(`Unsupported shared-state snapshot version ${snapshot.version} (expected ${expectedVersion})`);
}
const expectedChecksum = computeSnapshotChecksum({
version: snapshot.version,
exportedAt: snapshot.exportedAt,
payload: snapshot.payload,
});
if (snapshot.checksum !== expectedChecksum) {
throw new Error("Shared-state snapshot checksum mismatch");
}
}
export function createProjectSettingsSnapshot(payload: ProjectSettingsSnapshot["payload"], exportedAt?: string): ProjectSettingsSnapshot {
return withChecksum(payload, exportedAt);
}
export function createAuthMaterialSnapshot(providerAuth: Record<string, ProviderAuthEntry> | undefined, exportedAt?: string): AuthMaterialSnapshot {
return withChecksum({ providerAuth }, exportedAt);
}