Skip to content

Commit 835b299

Browse files
mizchiclaude
andcommitted
refactor: persist only identity (keys/nonces) in DO, not messages
Split snapshot into identity-only persistence to stay within the Durable Object 128 KiB storage limit. Messages, acks, presence, and reviews are now ephemeral (in-memory only). Keys and nonces are the only state that requires persistence across DO restarts. Includes migration from legacy relay_snapshot_v1 key to relay_identity_v1. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2a24a1c commit 835b299

2 files changed

Lines changed: 111 additions & 13 deletions

File tree

src/cloudflare_worker.ts

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ import {
1313
DEFAULT_WS_PING_INTERVAL_MS,
1414
healthResponse,
1515
isValidRoomName,
16+
type IdentitySnapshot,
1617
type MemoryRelayOptions,
1718
type MemoryRelayService,
18-
type RelaySnapshot,
1919
} from './memory_handler.ts';
2020
import { GitServeSession } from './git_serve_session.ts';
2121

@@ -31,6 +31,7 @@ interface DurableObjectNamespaceLike {
3131
interface DurableObjectStorageLike {
3232
get(key: string): Promise<unknown>;
3333
put(key: string, value: unknown): Promise<void>;
34+
delete(key: string): Promise<boolean>;
3435
}
3536

3637
interface DurableObjectStateLike {
@@ -60,7 +61,7 @@ export interface RelayWorkerEnv {
6061
GIT_SERVE_SESSION_TTL_SEC?: string;
6162
}
6263

63-
const SNAPSHOT_KEY = 'relay_snapshot_v1';
64+
const IDENTITY_KEY = 'relay_identity_v1';
6465
let fallbackService: MemoryRelayService | null = null;
6566

6667
function parsePositiveInt(raw: string | undefined, fallback: number): number {
@@ -334,13 +335,24 @@ export class RelayRoom {
334335
this.service = createMemoryRelayService(buildOptions(env));
335336
const restore = async () => {
336337
try {
337-
const snapshot = await this.state.storage.get(SNAPSHOT_KEY);
338-
if (!snapshot || typeof snapshot !== 'object') {
338+
// Try identity-only snapshot first (v1), fall back to legacy full snapshot
339+
const identity = await this.state.storage.get(IDENTITY_KEY);
340+
if (identity && typeof identity === 'object') {
341+
this.service.restoreIdentity(identity as IdentitySnapshot);
339342
return;
340343
}
341-
this.service.restore(snapshot as RelaySnapshot);
344+
// Legacy: migrate from full snapshot if present
345+
const legacy = await this.state.storage.get('relay_snapshot_v1');
346+
if (legacy && typeof legacy === 'object') {
347+
const legacyData = legacy as Record<string, unknown>;
348+
if (legacyData.keys_by_sender || legacyData.nonces_by_sender) {
349+
this.service.restoreIdentity(legacyData as IdentitySnapshot);
350+
}
351+
// Clean up legacy key
352+
await this.state.storage.delete('relay_snapshot_v1');
353+
}
342354
} catch {
343-
console.error('Failed to restore relay snapshot; starting fresh');
355+
console.error('Failed to restore relay identity; starting fresh');
344356
}
345357
};
346358
if (typeof this.state.blockConcurrencyWhile === 'function') {
@@ -354,22 +366,19 @@ export class RelayRoom {
354366
await this.ready;
355367
const response = await this.service.fetch(request);
356368

369+
// Persist identity (keys + nonces) on operations that modify them.
370+
// Messages, acks, and presence are ephemeral — not persisted.
357371
const pathname = new URL(request.url).pathname;
358372
if (
359373
pathname === '/api/v1/publish' ||
360-
pathname === '/api/v1/inbox/ack' ||
361-
pathname === '/api/v1/presence/heartbeat' ||
362374
pathname === '/api/v1/key/rotate' ||
363375
pathname === '/api/v1/key/verify-github' ||
364-
(pathname === '/api/v1/presence' && request.method === 'DELETE') ||
365376
(pathname === '/api/v1/review' && request.method === 'POST')
366377
) {
367378
try {
368-
await this.state.storage.put(SNAPSHOT_KEY, this.service.snapshot());
379+
await this.state.storage.put(IDENTITY_KEY, this.service.identitySnapshot());
369380
} catch {
370-
// Snapshot may exceed Durable Object storage limit (128 KiB).
371-
// The in-memory state is still valid; persistence is best-effort.
372-
console.error('Failed to persist relay snapshot (likely size limit exceeded)');
381+
console.error('Failed to persist relay identity');
373382
}
374383
}
375384
return response;

src/memory_handler.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,11 @@ export interface RelaySnapshot {
126126
nonces_by_sender: Record<string, Record<string, number>>;
127127
}
128128

129+
export interface IdentitySnapshot {
130+
keys_by_sender: Record<string, SnapshotKeyRecord>;
131+
nonces_by_sender: Record<string, Record<string, number>>;
132+
}
133+
129134
export interface MemoryRelayOptions {
130135
authToken?: string;
131136
maxMessagesPerRoom?: number;
@@ -150,6 +155,8 @@ export interface MemoryRelayService {
150155
fetch(request: Request): Promise<Response>;
151156
snapshot(): RelaySnapshot;
152157
restore(snapshot: RelaySnapshot): void;
158+
identitySnapshot(): IdentitySnapshot;
159+
restoreIdentity(snapshot: IdentitySnapshot): void;
153160
close(): void;
154161
}
155162

@@ -1725,6 +1732,86 @@ export function createMemoryRelayService(options: MemoryRelayOptions = {}): Memo
17251732
};
17261733
}
17271734

1735+
function identitySnapshot(): IdentitySnapshot {
1736+
const keysBySender: Record<string, SnapshotKeyRecord> = {};
1737+
for (const [sender, key] of keyRegistry.entries()) {
1738+
keysBySender[sender] = {
1739+
public_key: key.publicKey,
1740+
status: key.status,
1741+
first_seen_at: key.firstSeenAt,
1742+
last_seen_at: key.lastSeenAt,
1743+
rotated_at: key.rotatedAt,
1744+
revoked_at: key.revokedAt,
1745+
github_username: key.githubUsername,
1746+
github_verified_at: key.githubVerifiedAt,
1747+
};
1748+
}
1749+
1750+
const noncesData: Record<string, Record<string, number>> = {};
1751+
for (const [sender, nonces] of noncesBySender.entries()) {
1752+
const senderNonces: Record<string, number> = {};
1753+
for (const [nonce, ts] of nonces.entries()) {
1754+
senderNonces[nonce] = ts;
1755+
}
1756+
noncesData[sender] = senderNonces;
1757+
}
1758+
1759+
return {
1760+
keys_by_sender: keysBySender,
1761+
nonces_by_sender: noncesData,
1762+
};
1763+
}
1764+
1765+
function restoreIdentity(snapshotData: IdentitySnapshot): void {
1766+
keyRegistry.clear();
1767+
noncesBySender.clear();
1768+
1769+
if (!isObjectRecord(snapshotData)) {
1770+
return;
1771+
}
1772+
1773+
if (isObjectRecord(snapshotData.keys_by_sender)) {
1774+
for (const [sender, value] of Object.entries(snapshotData.keys_by_sender)) {
1775+
if (!isObjectRecord(value)) continue;
1776+
const publicKey = (typeof value.public_key === 'string' ? value.public_key : '').trim();
1777+
if (sender.trim().length === 0 || publicKey.length === 0) continue;
1778+
const status: KeyStatus = value.status === 'revoked' ? 'revoked' : 'active';
1779+
keyRegistry.set(sender, {
1780+
publicKey,
1781+
status,
1782+
firstSeenAt: typeof value.first_seen_at === 'number'
1783+
? Math.trunc(value.first_seen_at)
1784+
: 0,
1785+
lastSeenAt: typeof value.last_seen_at === 'number' ? Math.trunc(value.last_seen_at) : 0,
1786+
rotatedAt: typeof value.rotated_at === 'number' ? Math.trunc(value.rotated_at) : null,
1787+
revokedAt: typeof value.revoked_at === 'number' ? Math.trunc(value.revoked_at) : null,
1788+
githubUsername: typeof value.github_username === 'string' ? value.github_username : null,
1789+
githubVerifiedAt: typeof value.github_verified_at === 'number'
1790+
? Math.trunc(value.github_verified_at)
1791+
: null,
1792+
});
1793+
}
1794+
}
1795+
1796+
if (isObjectRecord(snapshotData.nonces_by_sender)) {
1797+
const nowSec = nowEpochSec();
1798+
for (const [sender, value] of Object.entries(snapshotData.nonces_by_sender)) {
1799+
if (!isObjectRecord(value)) continue;
1800+
const map = new Map<string, number>();
1801+
for (const [nonce, tsRaw] of Object.entries(value)) {
1802+
if (nonce.trim().length === 0) continue;
1803+
if (typeof tsRaw !== 'number' || !Number.isFinite(tsRaw)) continue;
1804+
const ts = Math.trunc(tsRaw);
1805+
if (nowSec - ts > nonceTtlSec) continue;
1806+
map.set(nonce, ts);
1807+
}
1808+
if (map.size > 0) {
1809+
noncesBySender.set(sender, map);
1810+
}
1811+
}
1812+
}
1813+
}
1814+
17281815
function restore(snapshotData: RelaySnapshot): void {
17291816
rooms.clear();
17301817
keyRegistry.clear();
@@ -1883,6 +1970,8 @@ export function createMemoryRelayService(options: MemoryRelayOptions = {}): Memo
18831970
fetch,
18841971
snapshot,
18851972
restore,
1973+
identitySnapshot,
1974+
restoreIdentity,
18861975
close,
18871976
};
18881977
}

0 commit comments

Comments
 (0)