Skip to content

Store Checkpoints

Implement SandboxCheckpointStore over your database, and keep the file bytes in an R2 bucket. Then call deleteCheckpoints and sweepSandboxBlobs to keep storage small.

The store never reads or deletes blob bytes. It counts how many checkpoints use each blob. The sweep deletes the bytes that no checkpoint uses.

DataWhere to keep it
Checkpoints, heads, writer leases, blob countsYour SandboxCheckpointStore, for example in SQL tables.
File and artifact bytesAny object with get, head, and put. An R2 bucket fits.

InMemorySandboxCheckpointStore in @tanstack/ai-sandbox obeys every rule on this page. Use its source as a reference for your own store.

Write the store

  1. Obey these rules in every method:
RuleWhat it means
A thread is a chainEach checkpoint points to its parent with parentCheckpointId. The head is the newest checkpoint of the chain.
Ids are uniqueA checkpoint id is unique across all threads.
Checkpoints never changeReads return copies. A change to a returned value does not change the store.
Ids are validThread ids and checkpoint ids are non-empty, well-formed Unicode strings. Other values throw SandboxCheckpointInvalidIdError.
A write is all or nothingDo the checks and the write in one transaction. When a write throws, the store does not change.
A write checks its writer firstwriter.threadId is the thread of the write, and the lease is current. Else the write throws SandboxCheckpointWriterLostError.
  1. Implement the required methods:
MethodWhat it does
get(id)Returns the checkpoint, or null. A missing id does not throw.
list(threadId)Returns all checkpoints of the thread, also the ones off the head chain. Sort by createdAt, then by the UTF-8 bytes of the id. An unknown thread returns [].
getHead(threadId)Returns the head id, or null.
append({ checkpoint, expectedHeadId, writer })Adds the checkpoint and makes it the head, as one compare-and-swap. Then adds 1 to the count of each distinct blob key.
deleteHead({ threadId, checkpointId, writer })Removes the head and moves the head to its parent. Subtracts 1 from each blob count, and forgets a key at 0. A checkpoint that is not the head throws SandboxCheckpointNotHeadError.
acquireWriter(threadId)Takes the writer lease of the thread. It does not wait.
listBlobReferences()Returns each blob key with a count above 0, for all threads, sorted by UTF-8 bytes.

append runs its checks in this order. Each failed check throws and changes nothing:

OrderCheckError
1The ids are valid.SandboxCheckpointInvalidIdError
2files, artifacts, and a finite createdAt are valid.SandboxCheckpointInvalidEntryError
3The writer is current.SandboxCheckpointWriterLostError
4The id is not used in any thread.SandboxCheckpointDuplicateIdError
5The head is expectedHeadId (null for an empty thread).SandboxCheckpointConflictError
6parentCheckpointId is expectedHeadId. A missing parent id counts as null.SandboxCheckpointParentMismatchError

The blob keys of a checkpoint are the blobKey of each file entry and of each artifact. Directories and symlinks have no blob. A checkpoint that uses one key two times counts one time.

  1. Give acquireWriter these lease rules:
  • An unexpired lease of the thread throws SandboxCheckpointWriterConflictError. An expired lease can be taken over.
  • Each new lease gets a new ownerToken and a fence that is higher than all earlier fences of the thread.
  • renew() moves expiresAt to now plus the lease duration. It throws SandboxCheckpointWriterLostError when the lease expired or was replaced.
  • release() ends the lease. It does nothing when a newer lease replaced it, and it never throws.
  • renewAfterMs is shorter than the lease duration. The in-memory store uses a lease of 120 seconds.
  1. Prove the store with the conformance suite. The suite passes a clock (now) and lease times to your factory. Use them in your store, so the lease tests can move the time.
ts
// checkpoint-store.test.ts
import { runSandboxCheckpointStoreConformance } from '@tanstack/ai-sandbox/testkit'
import { createCheckpointStore } from './checkpoint-store'
import { freshDb } from './test-db'

runSandboxCheckpointStoreConformance(
  'sql',
  async (options) => createCheckpointStore(await freshDb(), options),
  { now: () => 1_000, leaseDurationMs: 100, renewAfterMs: 25 },
)

When the suite passes, your store keeps the same promises as the in-memory store.

Optional methods

  • deleteCheckpoints: removes old checkpoints of a thread. See Keep only the newest checkpoints. The conformance suite skips its tests when your store does not have it.
  • forkFromCheckpoint: copies one checkpoint into a new, empty thread. Without it, the store cannot fork. Prove it with runSandboxCheckpointForkConformance.

Prove the instance store

The SandboxInstanceStore keeps the provider sandbox of each key. On Cloudflare, latestSnapshotId can keep the backup id from Your Own Cloudflare Container.

  • get returns the fields as you stored them. An optional field that you did not store is absent, not undefined and not null.
  • upsert replaces the full record of record.key. The last write wins, and no other record changes.
ts
// instance-store.test.ts
import { runSandboxInstanceStoreConformance } from '@tanstack/ai-sandbox/testkit'
import { createInstanceStore } from './instance-store'
import { freshDb } from './test-db'

runSandboxInstanceStoreConformance('sql', async () =>
  createInstanceStore(await freshDb()),
)

For the table and the other rules, see Build a Sandbox Adapter.

Keep only the newest checkpoints

deleteCheckpoints keeps the head and the newest checkpoints before it. It needs the writer lease of the thread:

ts
// prune.ts
import type { SandboxCheckpointStore } from '@tanstack/ai-sandbox'

export async function keepNewest(
  checkpoints: SandboxCheckpointStore,
  threadId: string,
  keep: number,
) {
  if (!checkpoints.deleteCheckpoints)
    throw new Error('This store cannot delete checkpoints')
  const writer = await checkpoints.acquireWriter(threadId)
  try {
    const { deleted } = await checkpoints.deleteCheckpoints({
      threadId,
      keep,
      writer,
    })
    return deleted
  } finally {
    await writer.release()
  }
}
  • keep is a safe integer of 1 or more. Else it throws SandboxCheckpointInvalidEntryError.
  • "Newest" follows the parent chain from the head, not createdAt. Checkpoints off the head chain stay.
  • It returns the removed ids, oldest first. The blob counts of the removed checkpoints go down.
  • While a run or a capture holds the lease, acquireWriter throws SandboxCheckpointWriterConflictError.

Delete blobs that no checkpoint uses

sweepSandboxBlobs lists the blob keys under sandbox-files/ and sandbox-artifacts/. It deletes each key that no checkpoint uses and that is older than olderThanMs. Other keys in the bucket stay.

Run the sweep at a time with no captures, for example from a nightly cron. A capture does not upload a blob that already exists. So a capture during the sweep can use a blob that the sweep deletes.

ts
// ignore: needs the Cloudflare Workers runtime types (cloudflare:workers, Env, R2Bucket), which the docs check does not load.
// src/worker.ts
import { sweepSandboxBlobs } from '@tanstack/ai-sandbox'
import { createCheckpointStore } from './checkpoint-store'

// R2 returns pages of keys. The sweep needs all of them.
async function listAll(bucket: R2Bucket, prefix: string) {
  const found: Array<{ key: string; uploadedAt: number }> = []
  let cursor: string | undefined
  for (;;) {
    const page = await bucket.list({ prefix, cursor })
    for (const object of page.objects) {
      found.push({ key: object.key, uploadedAt: object.uploaded.getTime() })
    }
    if (!page.truncated) return found
    cursor = page.cursor
  }
}

export default {
  async scheduled(_controller, env) {
    const deleted = await sweepSandboxBlobs({
      checkpoints: createCheckpointStore(env.DB),
      blobs: {
        list: (prefix) => listAll(env.BLOBS, prefix),
        delete: (key) => env.BLOBS.delete(key),
      },
      // Longer than your longest capture.
      olderThanMs: 24 * 60 * 60 * 1000,
    })
    console.log(`Deleted ${deleted.length} blobs`)
  },
} satisfies ExportedHandler<Env>
  • A capture uploads its blobs first and adds the checkpoint after. Until then, a new blob has no count. Make olderThanMs longer than your longest capture, so the sweep keeps that blob.
  • olderThanMs must be a finite number of 0 or more. Else the sweep throws RangeError.
  • Call keepNewest first, then the sweep. The sweep then also deletes the bytes that only the removed checkpoints used.

The cron log shows the number of deleted blobs, and your bucket keeps only the bytes that a checkpoint still uses.