Some checks failed
CD / Promote to Int (push) Blocked by required conditions
CD / Build and push images (push) Successful in 2m56s
CI / Lint, typecheck, test (push) Failing after 1m17s
CI / Auth e2e pack (push) Successful in 2m24s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Has been cancelled
Update logs grow with every edit; compaction bounds storage and load time. - prisma: `collab_open_sessions` table (page_id, heartbeat_at) — the live-session registry that lets the compaction job avoid pages being edited, decoupled from collab (no api↔collab network call) and self- healing (a crashed collab's rows age out of the freshness window). - collab: `PostgresSessionRegistry` marks a page open on document load and closed on unload, and refreshes an every-30s heartbeat for all open docs; wired into the server hooks and started/stopped in index.ts. - api: `CompactionService` runs hourly via the shared scheduler (#31). For pages with > 500 log rows and no fresh session it merges `page_updates` into `ydoc_state` and deletes the merged rows in one FOR UPDATE transaction — atomic, so a mid-run crash leaves the page untouched and the next run resumes. Content is unchanged (merged state = base + all updates), so the content cache is left as-is; updated_at is deliberately not bumped. Metric log line with pages compacted / rows / bytes removed. Tests: DB-backed compaction test (content hash unchanged, below-threshold skipped, active session skipped then picked up next run, stale heartbeat ignored, idempotent); session-registry DB test (open/close, heartbeat refresh). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
117 lines
3.9 KiB
TypeScript
117 lines
3.9 KiB
TypeScript
import type { Pool } from 'pg';
|
|
import type { Logger } from 'pino';
|
|
|
|
/**
|
|
* The port the collab hooks use to advertise which pages have a live editing
|
|
* session (issue #40). The api's compaction job reads the backing table to
|
|
* skip pages that are being edited, so it never fights the collab writer.
|
|
*/
|
|
export interface SessionRegistry {
|
|
/** Record that a page now has an open session (called when its doc loads). */
|
|
markOpen(pageId: string): void;
|
|
/** Record that a page's session ended (called when its doc unloads). */
|
|
markClosed(pageId: string): void;
|
|
}
|
|
|
|
export interface SessionRegistryDeps {
|
|
pool: Pool;
|
|
logger: Logger;
|
|
/**
|
|
* How often the heartbeat refreshes the `heartbeat_at` of every open page,
|
|
* so the api can tell a still-open session from one whose collab process
|
|
* crashed without unloading (its row simply ages out). Overridable for tests.
|
|
*/
|
|
heartbeatMs?: number;
|
|
}
|
|
|
|
export interface StartableSessionRegistry extends SessionRegistry {
|
|
/**
|
|
* Begin the periodic heartbeat. `openDocumentNames` returns the page ids the
|
|
* collab server currently holds open; taken here rather than in the
|
|
* constructor so the registry can be created before the server it reads from.
|
|
*/
|
|
start(openDocumentNames: () => string[]): void;
|
|
/** Stop the heartbeat. Idempotent. */
|
|
stop(): void;
|
|
}
|
|
|
|
const DEFAULT_HEARTBEAT_MS = 30_000;
|
|
|
|
/**
|
|
* PostgreSQL-backed {@link SessionRegistry} (issue #40, ADR 0013 §compaction).
|
|
*
|
|
* Each open page keeps a row in `collab_open_sessions` whose `heartbeat_at` is
|
|
* refreshed on a timer. The compaction job treats a page as actively edited
|
|
* while its heartbeat is fresh and ignores it otherwise, so a crashed collab
|
|
* process cannot block compaction forever — the stale rows just age out of the
|
|
* freshness window. Writes are best-effort: a failed heartbeat only risks a
|
|
* page being compacted a run later, never data loss.
|
|
*/
|
|
export class PostgresSessionRegistry implements StartableSessionRegistry {
|
|
private readonly heartbeatMs: number;
|
|
private timer: NodeJS.Timeout | undefined;
|
|
private openDocumentNames: () => string[] = () => [];
|
|
|
|
constructor(private readonly deps: SessionRegistryDeps) {
|
|
this.heartbeatMs = deps.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
|
|
}
|
|
|
|
markOpen(pageId: string): void {
|
|
void this.deps.pool
|
|
.query(
|
|
`INSERT INTO collab_open_sessions (page_id, heartbeat_at)
|
|
VALUES ($1, now())
|
|
ON CONFLICT (page_id) DO UPDATE SET heartbeat_at = now()`,
|
|
[pageId],
|
|
)
|
|
.catch((error: unknown) => {
|
|
this.deps.logger.warn(
|
|
{ event: 'session.mark_open.failed', pageId, err: (error as Error).message },
|
|
'could not record open collab session',
|
|
);
|
|
});
|
|
}
|
|
|
|
markClosed(pageId: string): void {
|
|
void this.deps.pool
|
|
.query('DELETE FROM collab_open_sessions WHERE page_id = $1', [pageId])
|
|
.catch((error: unknown) => {
|
|
this.deps.logger.warn(
|
|
{ event: 'session.mark_closed.failed', pageId, err: (error as Error).message },
|
|
'could not clear open collab session',
|
|
);
|
|
});
|
|
}
|
|
|
|
start(openDocumentNames: () => string[]): void {
|
|
this.openDocumentNames = openDocumentNames;
|
|
if (this.timer) return;
|
|
this.timer = setInterval(() => this.beat(), this.heartbeatMs);
|
|
// Don't keep the process alive just for the heartbeat.
|
|
this.timer.unref?.();
|
|
}
|
|
|
|
stop(): void {
|
|
if (this.timer) {
|
|
clearInterval(this.timer);
|
|
this.timer = undefined;
|
|
}
|
|
}
|
|
|
|
private beat(): void {
|
|
const open = this.openDocumentNames();
|
|
if (open.length === 0) return;
|
|
void this.deps.pool
|
|
.query(
|
|
`UPDATE collab_open_sessions SET heartbeat_at = now() WHERE page_id = ANY($1::text[])`,
|
|
[open],
|
|
)
|
|
.catch((error: unknown) => {
|
|
this.deps.logger.warn(
|
|
{ event: 'session.heartbeat.failed', err: (error as Error).message },
|
|
'collab session heartbeat failed',
|
|
);
|
|
});
|
|
}
|
|
}
|