From b88b1f77948abe0b73eaa7af208c77b37f126447 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Wed, 14 Oct 2026 06:28:01 -0700 Subject: [PATCH 1/3] Bound hosted library growth on celld Add covering indexes so artifact and script history listings stop reading every revision's source, reuse the latest serve event when a version reopens with unchanged state, and enable celld epoch retention for the local host and Docker image so idle-wake snapshots are deleted. --- Dockerfile | 2 +- cloudflare/library.test.ts | 16 ++++++++++++++++ cloudflare/library.ts | 8 ++++++++ cloudflare/scripts.ts | 3 +++ docs/celld-deployment.md | 11 +++++++++-- docs/daemon.md | 7 +++++++ docs/docker.md | 4 +++- src/local/server.ts | 5 ++++- 8 files changed, 51 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index afbb3da..a2721c3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,7 +32,7 @@ COPY --from=build /src/dist/container/bin/ /usr/local/bin/ COPY --from=build /src/dist/worker-app/ /app/dist/worker-app/ COPY --from=build /src/dist/cloudflare/assets/ /app/dist/cloudflare/assets/ COPY --from=build /src/dist/container/wrangler.jsonc /app/wrangler.jsonc -ENV CELLD_ESBUILD=/usr/local/bin/esbuild CELLD_IDLE_EVICT_S=60 +ENV CELLD_ESBUILD=/usr/local/bin/esbuild CELLD_IDLE_EVICT_S=60 CELLD_LTX_RETENTION_SECS=3600 WORKDIR /app USER 65532:65532 VOLUME ["/app/.celld"] diff --git a/cloudflare/library.test.ts b/cloudflare/library.test.ts index 72d23d2..c96f6a7 100644 --- a/cloudflare/library.test.ts +++ b/cloudflare/library.test.ts @@ -214,6 +214,22 @@ test("successful serve snapshots deduplicate versions while retaining UUID event await fails("events", { workspace: "elsewhere", version_id: first.version.id }, "not found in this workspace"); }); +test("reopening a version with unchanged state reuses its latest event", async () => { + const source = "export default function Artifact() { return null; }\n"; + const serve = (initial_state: Record, extra: Record = {}) => call("recordServe", { + workspace: "repeat-serves", name: "overview", source, runtime: "sdk", initial_state, mode: "preview", ...extra, + }); + const first = await serve({ count: 1 }); + expect((await serve({ count: 1 }, { version_id: first.version.id })).event.id).toBe(first.event.id); + const changed = await serve({ count: 2 }); + expect(changed.event.id).not.toBe(first.event.id); + const returned = await serve({ count: 1 }); + expect(returned.event.id).not.toBe(changed.event.id); + expect((await serve({ count: 1 }, { session_id: "another-session" })).event.id).not.toBe(returned.event.id); + expect(await call("events", { workspace: "repeat-serves", version_id: first.version.id })).toHaveLength(4); + expect(await call>("history", { workspace: "repeat-serves" })).toMatchObject([{ serve_count: 4 }]); +}); + test("version identity includes server source and client-only history stays compatible", async () => { const client = "same client\n"; const first = await call("recordServe", { workspace: "server-history", name: "overview", source: client, server_source: "server one\n", runtime: "sdk", initial_state: {}, mode: "live" }); diff --git a/cloudflare/library.ts b/cloudflare/library.ts index 5c705c5..aac0f10 100644 --- a/cloudflare/library.ts +++ b/cloudflare/library.ts @@ -150,6 +150,9 @@ export class ArtifactLibrary extends DurableObject { } } this.sql.exec("create index if not exists serve_events_version on serve_events(version_id, served_at)"); + // History lists read only these columns. Covering them keeps listings from + // reading each revision's source, which precedes them in the row. + this.sql.exec("create index if not exists versions_history on versions(artifact_id, revision, id, created_at, source_hash, runtime, reason, restored_from)"); }); } @@ -415,6 +418,11 @@ export class ArtifactLibrary extends DurableObject { version = this.capture(workspace, name, source, server_source, project, runtime, "served", null, false, input.compiled_id); } if (input.record_event === false) return { version, event: null }; + // Hosted page views replay the latest event's state. An identical event adds + // no replay information, so reuse it instead of writing a row per view. + const latest = first(this.sql.exec("select * from serve_events where version_id = ? order by rowid desc limit 1", version.id)); + if (latest && latest.mode === input.mode && latest.initial_state === initial_state && latest.runtime === runtime && + latest.pane_id === (input.pane_id ?? null) && latest.session_id === (input.session_id ?? null)) return { version, event: latest }; const event: ServeEvent = { id: uuid(), version_id: version.id, served_at: now(), pane_id: input.pane_id ?? null, session_id: input.session_id ?? null, mode: input.mode, initial_state, runtime, diff --git a/cloudflare/scripts.ts b/cloudflare/scripts.ts index c0774a7..8e07762 100644 --- a/cloudflare/scripts.ts +++ b/cloudflare/scripts.ts @@ -42,6 +42,9 @@ export class ScriptLibrary extends DurableObject { this.sql.exec(`alter table ${table} add column project text not null default '{"files":{},"dependencies":{},"lock":{}}'`); } } + // History lists read only these columns, newest first. Covering them keeps + // listings from reading each revision's source, which precedes them in the row. + this.sql.exec("create index if not exists script_versions_history on script_versions(created_at, id, workspace, name, revision, source_hash, reason, restored_from)"); } private row(input: {workspace: string; name: string}): Row { const {workspace,name} = key(input); diff --git a/docs/celld-deployment.md b/docs/celld-deployment.md index d301219..6f18db6 100644 --- a/docs/celld-deployment.md +++ b/docs/celld-deployment.md @@ -51,11 +51,18 @@ For production or multiple machines, use celld's bucket-backed node mode: 4. Terminate TLS and authenticate users in the application or a chosen reverse proxy. Keep the operator/peer listener on a trusted private network, with an encrypted overlay when that network does not provide confidentiality. -5. Use `/.well-known/celld/health` for node readiness. `/health` is an application +5. Set `CELLD_LTX_RETENTION_SECS` (for example, `3600`) on every node once all + nodes run 0.6.1. Without it, celld never deletes superseded snapshot epochs, + so bucket usage grows with each activation, including every wake after idle + eviction. Epoch deletion requires list-after-write consistency; see the + [storage guarantees](https://github.com/denoland/celld/blob/v0.6.1/docs/guarantees.md) + before enabling it on a multi-region bucket. Nodes using `bucket` durability + delete nothing. Unset it on every node before rolling back to celld 0.6.0. +6. Use `/.well-known/celld/health` for node readiness. `/health` is an application route and does not establish node readiness. Allow graceful SIGTERM shutdown: the supervisor's stop grace must exceed celld's configured shutdown bound (40 seconds by default), with enough time for the expected drain and handoff. -6. Update app code through `celld deploy`. Running nodes adopt deployments in +7. Update app code through `celld deploy`. Running nodes adopt deployments in place; a failed build leaves the previous deployment serving. For runtime upgrades, follow the release-specific shutdown requirements. The [0.6.0 release](https://github.com/denoland/celld/releases/tag/v0.6.0) requires diff --git a/docs/daemon.md b/docs/daemon.md index 9163423..86d9aa1 100644 --- a/docs/daemon.md +++ b/docs/daemon.md @@ -132,6 +132,13 @@ credentials or other environment variables. The native server binds only to loopback. Its state is local application data and is not uploaded or backed up automatically. +celld writes a new snapshot of a database each time the object wakes after +60 idle seconds (`CELLD_IDLE_EVICT_S`). The host deletes snapshots that are +older than one hour and no longer needed for recovery +(`CELLD_LTX_RETENTION_SECS`, default `3600`). Deletion can take about ten minutes +after the next write to that object. Set `CELLD_LTX_RETENTION_SECS=0` to keep every +snapshot; storage then grows with each wake. + For bucket-backed production nodes, TLS/authentication boundaries, health checks, and graceful rollout guidance, see [celld deployment](celld-deployment.md). diff --git a/docs/docker.md b/docs/docker.md index bf3574e..4a78294 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -15,7 +15,9 @@ docker run -d --name artifacts --restart unless-stopped \ Open [localhost:4786](http://127.0.0.1:4786); the MCP endpoint is `http://127.0.0.1:4786/mcp`. The named volume preserves apps, databases, files, and schedules across container replacements. Stop the container before backing -up the volume. Allow 60 seconds for graceful shutdown. +up the volume. Allow 60 seconds for graceful shutdown. The image deletes +superseded storage snapshots after one hour, as described in the +[host guide](daemon.md#data-and-configuration). ## Configure network access diff --git a/src/local/server.ts b/src/local/server.ts index 49f23f5..9626644 100644 --- a/src/local/server.ts +++ b/src/local/server.ts @@ -61,7 +61,10 @@ export async function runCelldServer(options: { port?: number; stateDir?: string const esbuild = esbuildRequire.resolve(`@esbuild/${process.platform}-${process.arch}/bin/esbuild`); child = Bun.spawn([executable, "dev", project, "--host", "127.0.0.1", "--port", String(port), "--no-watch", "--logs"], { cwd: project, - env: { ...process.env, CELLD_ESBUILD: esbuild, CELLD_IDLE_EVICT_S: process.env.CELLD_IDLE_EVICT_S ?? "60", RUST_LOG: process.env.RUST_LOG ?? "warn" }, + // Each idle eviction stores a new snapshot epoch. Retention lets celld delete + // superseded epochs; without it local storage grows with every wake. + env: { ...process.env, CELLD_ESBUILD: esbuild, CELLD_IDLE_EVICT_S: process.env.CELLD_IDLE_EVICT_S ?? "60", + CELLD_LTX_RETENTION_SECS: process.env.CELLD_LTX_RETENTION_SECS ?? "3600", RUST_LOG: process.env.RUST_LOG ?? "warn" }, stdin: "ignore", stdout: "pipe", stderr: "pipe", }); const forward = async (stream: ReadableStream | number | undefined) => { From b6c67c7118af79cd0848ffb0839e972781b27fc7 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Wed, 14 Oct 2026 06:32:15 -0700 Subject: [PATCH 2/3] Keep recording a serve event for every view Reusing identical serve events stopped serve_count from counting hosted page views. Leave serve event semantics unchanged in this change. --- cloudflare/library.test.ts | 16 ---------------- cloudflare/library.ts | 5 ----- 2 files changed, 21 deletions(-) diff --git a/cloudflare/library.test.ts b/cloudflare/library.test.ts index c96f6a7..72d23d2 100644 --- a/cloudflare/library.test.ts +++ b/cloudflare/library.test.ts @@ -214,22 +214,6 @@ test("successful serve snapshots deduplicate versions while retaining UUID event await fails("events", { workspace: "elsewhere", version_id: first.version.id }, "not found in this workspace"); }); -test("reopening a version with unchanged state reuses its latest event", async () => { - const source = "export default function Artifact() { return null; }\n"; - const serve = (initial_state: Record, extra: Record = {}) => call("recordServe", { - workspace: "repeat-serves", name: "overview", source, runtime: "sdk", initial_state, mode: "preview", ...extra, - }); - const first = await serve({ count: 1 }); - expect((await serve({ count: 1 }, { version_id: first.version.id })).event.id).toBe(first.event.id); - const changed = await serve({ count: 2 }); - expect(changed.event.id).not.toBe(first.event.id); - const returned = await serve({ count: 1 }); - expect(returned.event.id).not.toBe(changed.event.id); - expect((await serve({ count: 1 }, { session_id: "another-session" })).event.id).not.toBe(returned.event.id); - expect(await call("events", { workspace: "repeat-serves", version_id: first.version.id })).toHaveLength(4); - expect(await call>("history", { workspace: "repeat-serves" })).toMatchObject([{ serve_count: 4 }]); -}); - test("version identity includes server source and client-only history stays compatible", async () => { const client = "same client\n"; const first = await call("recordServe", { workspace: "server-history", name: "overview", source: client, server_source: "server one\n", runtime: "sdk", initial_state: {}, mode: "live" }); diff --git a/cloudflare/library.ts b/cloudflare/library.ts index aac0f10..530d7a4 100644 --- a/cloudflare/library.ts +++ b/cloudflare/library.ts @@ -418,11 +418,6 @@ export class ArtifactLibrary extends DurableObject { version = this.capture(workspace, name, source, server_source, project, runtime, "served", null, false, input.compiled_id); } if (input.record_event === false) return { version, event: null }; - // Hosted page views replay the latest event's state. An identical event adds - // no replay information, so reuse it instead of writing a row per view. - const latest = first(this.sql.exec("select * from serve_events where version_id = ? order by rowid desc limit 1", version.id)); - if (latest && latest.mode === input.mode && latest.initial_state === initial_state && latest.runtime === runtime && - latest.pane_id === (input.pane_id ?? null) && latest.session_id === (input.session_id ?? null)) return { version, event: latest }; const event: ServeEvent = { id: uuid(), version_id: version.id, served_at: now(), pane_id: input.pane_id ?? null, session_id: input.session_id ?? null, mode: input.mode, initial_state, runtime, From 40b953b7d96b5fcec5d62a51bf7150e9736996fd Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Wed, 14 Oct 2026 06:44:16 -0700 Subject: [PATCH 3/3] Keep idle objects resident on the local host A 60-second idle eviction made celld store two full copies of an object's database on every later wake. Leave idle eviction at celld's default so objects unload only under memory pressure. --- Dockerfile | 2 +- docs/daemon.md | 15 +++++++++------ src/local/server.ts | 7 ++++--- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/Dockerfile b/Dockerfile index a2721c3..81a496e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,7 +32,7 @@ COPY --from=build /src/dist/container/bin/ /usr/local/bin/ COPY --from=build /src/dist/worker-app/ /app/dist/worker-app/ COPY --from=build /src/dist/cloudflare/assets/ /app/dist/cloudflare/assets/ COPY --from=build /src/dist/container/wrangler.jsonc /app/wrangler.jsonc -ENV CELLD_ESBUILD=/usr/local/bin/esbuild CELLD_IDLE_EVICT_S=60 CELLD_LTX_RETENTION_SECS=3600 +ENV CELLD_ESBUILD=/usr/local/bin/esbuild CELLD_LTX_RETENTION_SECS=3600 WORKDIR /app USER 65532:65532 VOLUME ["/app/.celld"] diff --git a/docs/daemon.md b/docs/daemon.md index 86d9aa1..608da11 100644 --- a/docs/daemon.md +++ b/docs/daemon.md @@ -132,12 +132,15 @@ credentials or other environment variables. The native server binds only to loopback. Its state is local application data and is not uploaded or backed up automatically. -celld writes a new snapshot of a database each time the object wakes after -60 idle seconds (`CELLD_IDLE_EVICT_S`). The host deletes snapshots that are -older than one hour and no longer needed for recovery -(`CELLD_LTX_RETENTION_SECS`, default `3600`). Deletion can take about ten minutes -after the next write to that object. Set `CELLD_LTX_RETENTION_SECS=0` to keep every -snapshot; storage then grows with each wake. +celld stores full copies of an object's database each time the object is unloaded +and started again. The host keeps idle objects in memory, so this happens when the +server restarts or celld unloads objects under memory pressure, not after each idle +period. Setting `CELLD_IDLE_EVICT_S` unloads objects after that many idle seconds, +and every later wake stores more copies. The host deletes copies that are older than +one hour and no longer needed for recovery (`CELLD_LTX_RETENTION_SECS`, default +`3600`). Deletion can take about ten minutes after the next write to that object. +Set `CELLD_LTX_RETENTION_SECS=0` to keep every copy; storage then grows with each +restart. For bucket-backed production nodes, TLS/authentication boundaries, health checks, and graceful rollout guidance, see [celld deployment](celld-deployment.md). diff --git a/src/local/server.ts b/src/local/server.ts index 9626644..00d5db4 100644 --- a/src/local/server.ts +++ b/src/local/server.ts @@ -61,9 +61,10 @@ export async function runCelldServer(options: { port?: number; stateDir?: string const esbuild = esbuildRequire.resolve(`@esbuild/${process.platform}-${process.arch}/bin/esbuild`); child = Bun.spawn([executable, "dev", project, "--host", "127.0.0.1", "--port", String(port), "--no-watch", "--logs"], { cwd: project, - // Each idle eviction stores a new snapshot epoch. Retention lets celld delete - // superseded epochs; without it local storage grows with every wake. - env: { ...process.env, CELLD_ESBUILD: esbuild, CELLD_IDLE_EVICT_S: process.env.CELLD_IDLE_EVICT_S ?? "60", + // Each unload and restart of an object stores full copies of its database, so + // idle objects stay resident (celld still unloads them under memory pressure). + // Retention lets celld delete superseded copies. + env: { ...process.env, CELLD_ESBUILD: esbuild, CELLD_LTX_RETENTION_SECS: process.env.CELLD_LTX_RETENTION_SECS ?? "3600", RUST_LOG: process.env.RUST_LOG ?? "warn" }, stdin: "ignore", stdout: "pipe", stderr: "pipe", });