mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 06:10:44 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A cloud instance keeps run logs and evidence in object storage, so a run touches the store as soon as it starts > - In storage chaos wave `w-20261109T145628-s9902` (ST1: 75–176 s windows where every S3 call returns 503), only 1 of 8 tasks finished. A run starting or resuming during an outage failed within a second as `adapter_failed`, generic recovery burned its attempts seconds apart, the task was escalated to the board, and the user saw the raw S3 error > - This pull request treats a transient storage error as an infrastructure wait: the run is retried on a minute-scale schedule, rapid recovery is suppressed, the board shows it as working, and the run error says storage was unavailable > - The benefit is that a store outage of several minutes delays work instead of failing and stranding it ## Linked Issues or Issue Description Refs #19831. Storage chaos wave `w-20261109T145628-s9902` (master `610b54f551`, `--storage s3`, ST1). **Where the run fails.** At run start or resume, `run-output` `begin` opens the run log: `getRunLogStore().begin` → object run log store → object byte journal `claim` → S3 conditional PUT. The journal retries only 3 times within about 300 ms, so a 503 escapes and fails the run as `adapter_failed`. The async stack ends in the SDK middleware with no app frame. `log_store` stays null on the failed run, which pins the claim. **What followed.** - `failRun` released the issue into generic immediate recovery. Recovery retried 1–2 s apart inside the outage (t01: 14:58:33, :35, :59, 14:59:01), then stopped and opened a board-owned `stranded_assigned_issue` action. These are the open recovery actions on CHA-1, CHA-4, CHA-7 and CHA-8; a later Retry hit the outage again and escalated the same way. - The run error was the raw S3 text ("Injected storage outage"). **Slow store (ST2, `w-20261109T151946-s9903`).** With about 9.7 s of added latency per call, the same claim hit the journal's 15 s per-operation deadline (`AbortSignal.timeout(15_000)`). The run failed after about 36 s with `AbortError: Request aborted` and burned 3 runs the same way; 4 of 8 tasks needed a Retry. t02/t03 (Retry parked with `process_identity_missing`) are a separate native-recovery class and are handled in a follow-up. **Daytona storage chaos ST1 (`w-20261109T161202-s9912`, 1/8):** with S3 returning 503 for 72 to 154 s at a time: - Workspace restore failed at once (`remote_workspace_restore_failed: step=snapshot_download; storage=provider_error`), three times in 40 s, then `setup_failed`. It left an active recovery action behind. - Runs reattached after a restart died within 2 s on the run-log claim, then became reconciliation-required. - Setup failed on other storage reads. - 6 of 7 failures needed a UI Retry. So the rule here is one class rule: a transient store error on a run's critical path waits, it never fails the run. ## What Changed - New `server/src/storage/storage-unavailable.ts`: - `isStorageUnavailableError` reads the recorded storage failure diagnostics first, then the SDK error shape. Unavailable means 429/500/502/503/504, the S3 throttling and timeout names, network codes (`ECONNRESET`, `ECONNREFUSED`, `ETIMEDOUT`, `EPIPE`, `EAI_AGAIN`, `ENOTFOUND`), or an `AbortError` whose cause is a `TimeoutError` (a per-operation deadline). 403, 404 and 412 are not outages, and neither is an abort caused by the run's own Stop. - It defines the `storage_unavailable` code, the `storage_unavailable_wait` retry reason, the retry policy (60 s × 10 attempts, about 10 minutes) and the user-facing message. - `server/src/services/heartbeat/run-completion.ts`: - `failRun` classifies such a failure as `storage_unavailable` with the plain message. - Unless native recovery owns the run, it schedules the storage wait with `scheduleBoundedRetryForRun`, skips the interaction-continuation retry, and releases the issue with `suppressImmediateRecovery`. - `failRunSetup` uses the same policy for this code. - `server/src/services/heartbeat/retries.ts`: `storage_unavailable` joins the transient infrastructure codes. It gets its own retry budget and policy, like `runner_revision_mismatch`. - `server/src/services/task-interruption-presentation.ts`: `storage_unavailable_wait` is a resource wait, so the board shows the task as working with no Retry while it waits. - **One shared outage wait for the run's critical path** (`server/src/storage/storage-unavailable.ts`): - `retryThroughStorageOutage` retries a transient store failure with backoff (2 s doubling to 30 s) until that storage operation's outage has lasted 5 minutes, then gives the error back. The outermost wait of an operation records its first failure, and nested and parallel waits inside it share that start; no other request's success extends it. Shared probes and shared reads run under their own scope, so one run's Stop never cancels work another run waits on. A stop signal ends the wait, and non-outage errors are never retried. - Inside `waitOutStorageOutages`, every replayable S3 request waits instead of failing: get, head, delete, buffered and file puts, and multipart create, file parts and complete. A consumed upload stream is never resent, and requests outside the scope keep failing fast. - The scope covers run execution (`heartbeat.ts` `executeRun`: setup, workspace restore, run-log claim and the run itself), plus `loadWorkspaceSnapshotArchive` and `uploadWorkspaceSnapshot`, which copy-back and recovery workers use too. - The run-log claim retries as a whole, because the journal bounds each operation with a 15 s deadline. - Past the budget, the run takes the bounded `storage_unavailable` wait below. - The classifier follows the error's `cause` chain, so a restore failure wrapping an outage counts. `failRunSetup` checks storage before restore failures, so that case takes the storage wait instead of two quick retries and an escalation (the R6 leak). - `object-byte-journal.ts`: when its deadline expires, the journal rethrows the recorded storage error instead of a bare `TimeoutError`. ## Verification Red/green (each new test fails against master and passes with this PR): - `run-completion.test.ts` "waits a minute and suppresses rapid recovery when a run fails because storage is unavailable (storage chaos ST1)". An S3-shaped 503 records `storage_unavailable` with the plain message (no raw S3 text), schedules `STORAGE_UNAVAILABLE_RETRY`, releases with `suppressImmediateRecovery: true`, and skips the continuation retry. "keeps an ordinary adapter failure on the normal recovery path" guards the default. - `task-interruption-query.test.ts` "does not present an overdue storage_unavailable_wait wait as a failed attempt": working, `canRetry: false`. - `storage-unavailable.test.ts`: which errors count as an outage and which do not, including recorded diagnostics, a deadline-aborted call (ST2), and a Stop-aborted call that must not count. Static checks: - `tsc --noEmit` passes for `server`. Live verification: Phase 3 will rerun ST1 (seed 9902) with the MinIO fault proxy after merge. - `server/src/__tests__/storage-outage-wait.test.ts` runs against a local S3 that answers 503 for 3 s: - Inside the scope, a snapshot `getObject` and a run-log conditional PUT succeed once the store is back. - Outside it, a request fails fast. - A stop ends the wait. - Unit cases cover the budget, non-outage errors, and a `RemoteWorkspaceRestoreError` wrapping an outage. - `run-completion.test.ts`: a setup failure from a restore that wrapped an outage takes the storage wait (red before). - `object-byte-journal.test.ts`: a real `claim` whose head PUT hits its deadline is classified as a storage outage (red before). ## Risks - A storage outage now holds a task for up to about 10 minutes of automatic waits instead of escalating within seconds. A longer outage still ends in the existing board recovery. - A run whose failure is misclassified as storage-unavailable waits instead of using fast recovery. The classifier accepts only server-side or network failures from the storage SDK, never 4xx answers. - No schema change. - A run can now wait until a storage outage has lasted 5 minutes before it fails over to the bounded wait. It shows as working throughout, and Stop ends the wait. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with tool use (shell, file edits). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
833 lines
31 KiB
TypeScript
833 lines
31 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import { runSharedStorageWork, sendThroughStorageOutage } from "../storage/storage-unavailable.js";
|
|
import { z } from "zod";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import type { StorageProvider } from "../storage/types.js";
|
|
|
|
const MAX_INDEX_LEVEL = 52;
|
|
const MAX_OBJECT_BYTES = 4 * 1024 * 1024;
|
|
const offset = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
|
|
const referenceSchema = z
|
|
.object({
|
|
key: z.string(),
|
|
start: offset,
|
|
end: offset,
|
|
sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
})
|
|
.strict();
|
|
type Reference = z.infer<typeof referenceSchema>;
|
|
const nodeSchema = z
|
|
.object({
|
|
level: z.number().int().min(0).max(MAX_INDEX_LEVEL),
|
|
children: z.array(referenceSchema).min(1).max(64),
|
|
})
|
|
.strict();
|
|
type Node = z.infer<typeof nodeSchema>;
|
|
const rootSchema = referenceSchema.extend({ level: z.number().int().min(0).max(MAX_INDEX_LEVEL) });
|
|
type Root = z.infer<typeof rootSchema>;
|
|
const headSchema = z
|
|
.object({
|
|
v: z.literal(1),
|
|
owner: z.string().uuid(),
|
|
generation: offset,
|
|
end: offset,
|
|
sequence: offset.default(0),
|
|
sourceCursor: offset.default(0),
|
|
root: referenceSchema.nullable(),
|
|
roots: z
|
|
.array(rootSchema)
|
|
.max(MAX_INDEX_LEVEL + 1)
|
|
.optional(),
|
|
closed: z.boolean(),
|
|
deleted: z.boolean(),
|
|
cleanupAfter: offset.default(0),
|
|
})
|
|
.strict();
|
|
export type ObjectJournalHead = z.infer<typeof headSchema>;
|
|
export interface ObjectJournalWriter {
|
|
head: ObjectJournalHead;
|
|
etag: string;
|
|
}
|
|
export class ObjectJournalConflictError extends Error {
|
|
readonly code = "object_journal_writer_conflict";
|
|
constructor() {
|
|
super("The output journal is owned by another writer");
|
|
}
|
|
}
|
|
export class ObjectJournalSourcePrefixProofUnavailableError extends Error {
|
|
constructor(readonly writer: ObjectJournalWriter) {
|
|
super("The committed log prefix has no available source proof");
|
|
}
|
|
}
|
|
class ObjectJournalSourcePrefixProofInvalidError extends Error {
|
|
constructor() { super("object_journal_source_prefix_proof_invalid"); }
|
|
}
|
|
export function isObjectPreconditionFailure(error: unknown) {
|
|
const e = error as { name?: string; $metadata?: { httpStatusCode?: number } };
|
|
return e?.name === "PreconditionFailed" || e?.$metadata?.httpStatusCode === 412;
|
|
}
|
|
function isRetryable(error: unknown) {
|
|
const e = error as { name?: string; code?: string; $metadata?: { httpStatusCode?: number } };
|
|
const status = e?.$metadata?.httpStatusCode;
|
|
return (
|
|
e?.name === "ConditionalRequestConflict" ||
|
|
status === 409 ||
|
|
status === 429 ||
|
|
(status !== undefined && status >= 500) ||
|
|
["TimeoutError", "RequestTimeout", "NetworkingError"].includes(e?.name ?? "") ||
|
|
["ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "EPIPE"].includes(e?.code ?? "")
|
|
);
|
|
}
|
|
async function retryObjectOperation<T>(operation: () => Promise<T>, signal?: AbortSignal) {
|
|
for (let attempt = 0; ; attempt++) {
|
|
signal?.throwIfAborted();
|
|
try {
|
|
return await operation();
|
|
} catch (error) {
|
|
if (attempt >= 2 || !isRetryable(error) || signal?.aborted) throw error;
|
|
await delay(100 * 2 ** attempt, undefined, { signal });
|
|
}
|
|
}
|
|
}
|
|
function isMissing(error: unknown) {
|
|
const e = error as { name?: string; status?: number; $metadata?: { httpStatusCode?: number } };
|
|
return (
|
|
e?.name === "NoSuchKey" ||
|
|
e?.name === "NotFound" ||
|
|
e?.status === 404 ||
|
|
e?.$metadata?.httpStatusCode === 404
|
|
);
|
|
}
|
|
const sha256 = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex");
|
|
interface ConditionalQualification {
|
|
work: Promise<void>;
|
|
controller: AbortController;
|
|
waiters: number;
|
|
}
|
|
const qualifiedProviders = new WeakSet<StorageProvider>();
|
|
const qualifications = new WeakMap<StorageProvider, ConditionalQualification>();
|
|
async function qualifyConditionalWrites(
|
|
provider: StorageProvider,
|
|
prefix: string,
|
|
signal?: AbortSignal,
|
|
) {
|
|
signal?.throwIfAborted();
|
|
if (qualifiedProviders.has(provider)) return;
|
|
let qualification = qualifications.get(provider);
|
|
if (!qualification || qualification.controller.signal.aborted) {
|
|
const controller = new AbortController();
|
|
const probeSignal = AbortSignal.any([controller.signal, AbortSignal.timeout(15_000)]);
|
|
const current: ConditionalQualification = { controller, waiters: 0, work: Promise.resolve() };
|
|
qualification = current;
|
|
current.work = runSharedStorageWork(async () => {
|
|
const objectKey = `${prefix}/conditional-probe-${randomUUID()}`;
|
|
const body = Buffer.from("paperclip-conditional-probe-v1");
|
|
const put = provider.putObjectConditional!.bind(provider);
|
|
try {
|
|
await retryObjectOperation(
|
|
() =>
|
|
put({
|
|
objectKey,
|
|
body,
|
|
contentLength: body.length,
|
|
contentType: "text/plain",
|
|
ifNoneMatch: "*",
|
|
signal: probeSignal,
|
|
}),
|
|
probeSignal,
|
|
);
|
|
for (const condition of [
|
|
{ ifNoneMatch: "*" as const },
|
|
{ ifMatch: '"paperclip-impossible-etag"' },
|
|
]) {
|
|
let rejected = false;
|
|
try {
|
|
await retryObjectOperation(
|
|
() =>
|
|
put({
|
|
objectKey,
|
|
body,
|
|
contentLength: body.length,
|
|
contentType: "text/plain",
|
|
...condition,
|
|
signal: probeSignal,
|
|
}),
|
|
probeSignal,
|
|
);
|
|
} catch (error) {
|
|
if (!isObjectPreconditionFailure(error)) throw error;
|
|
rejected = true;
|
|
}
|
|
if (!rejected) throw new Error("Object storage does not enforce conditional publication");
|
|
}
|
|
} finally {
|
|
await provider.deleteObject({ objectKey, signal: probeSignal });
|
|
}
|
|
probeSignal.throwIfAborted();
|
|
qualifiedProviders.add(provider);
|
|
}).finally(() => {
|
|
if (qualifications.get(provider) === current) qualifications.delete(provider);
|
|
});
|
|
qualifications.set(provider, current);
|
|
void current.work.catch(() => undefined);
|
|
}
|
|
const pending = qualification;
|
|
pending.waiters++;
|
|
await new Promise<void>((resolve, reject) => {
|
|
let finished = false;
|
|
const release = () => {
|
|
if (finished) return false;
|
|
finished = true;
|
|
signal?.removeEventListener("abort", abort);
|
|
pending.waiters--;
|
|
return true;
|
|
};
|
|
const abort = () => {
|
|
if (!release()) return;
|
|
if (!pending.waiters) {
|
|
pending.controller.abort(signal!.reason);
|
|
void pending.work.then(
|
|
() => reject(signal!.reason),
|
|
() => reject(signal!.reason),
|
|
);
|
|
} else reject(signal!.reason);
|
|
};
|
|
signal?.addEventListener("abort", abort, { once: true });
|
|
if (signal?.aborted) abort();
|
|
pending.work.then(
|
|
() => {
|
|
if (release()) resolve();
|
|
},
|
|
(error) => {
|
|
if (release()) reject(error);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
export class ObjectJournalBusyError extends Error {
|
|
readonly code = "object_journal_io_busy";
|
|
constructor() {
|
|
super("Object storage is busy; retry shortly");
|
|
}
|
|
}
|
|
export function isObjectJournalBusyError(error: unknown): error is ObjectJournalBusyError {
|
|
return error instanceof ObjectJournalBusyError;
|
|
}
|
|
|
|
export type ObjectJournalIOPriority = "background" | "interactive";
|
|
const MAX_ACTIVE_IO = 8;
|
|
const MAX_WAITING_INTERACTIVE_IO = 32;
|
|
let activeIO = 0;
|
|
const waitingIO: Record<ObjectJournalIOPriority, Array<() => void>> = {
|
|
background: [],
|
|
interactive: [],
|
|
};
|
|
async function objectIO<T>(
|
|
operation: () => Promise<T>,
|
|
priority: ObjectJournalIOPriority = "background",
|
|
signal?: AbortSignal,
|
|
): Promise<T> {
|
|
signal?.throwIfAborted();
|
|
if (activeIO >= MAX_ACTIVE_IO) {
|
|
const queue = waitingIO[priority];
|
|
if (priority === "interactive" && queue.length >= MAX_WAITING_INTERACTIVE_IO)
|
|
throw new ObjectJournalBusyError();
|
|
await new Promise<void>((resolve, reject) => {
|
|
const grant = () => {
|
|
signal?.removeEventListener("abort", abort);
|
|
resolve();
|
|
};
|
|
const abort = () => {
|
|
const index = queue.indexOf(grant);
|
|
if (index >= 0) queue.splice(index, 1);
|
|
reject(signal!.reason);
|
|
};
|
|
queue.push(grant);
|
|
signal?.addEventListener("abort", abort, { once: true });
|
|
});
|
|
} else activeIO++;
|
|
try {
|
|
return await operation();
|
|
} finally {
|
|
const next = waitingIO.background.shift() ?? waitingIO.interactive.shift();
|
|
if (next) next();
|
|
else activeIO--;
|
|
}
|
|
}
|
|
|
|
// Segments and index nodes are content-addressed: each key ends in the SHA-256
|
|
// of the object's bytes, and load() verifies that digest. Their bytes cannot
|
|
// change, so repeated reads of a run's tail (live polling, reopening a task)
|
|
// reuse them instead of paying one object-store round trip per object. Only the
|
|
// mutable head is read again. The cache is bounded by total bytes (payload plus
|
|
// per-entry overhead) and evicts the least recently used objects first. Readers authorize before any read.
|
|
// Entries are scoped to the storage provider instance, so a different store
|
|
// (or a test's fresh store) never sees objects that another store returned.
|
|
const READ_CACHE_MAX_BYTES = 32 * 1024 * 1024;
|
|
const READ_CACHE_MAX_OBJECT_BYTES = 1024 * 1024;
|
|
const readCache = new Map<string, Buffer>();
|
|
let readCacheBytes = 0;
|
|
const providerScopes = new WeakMap<StorageProvider, number>();
|
|
let nextProviderScope = 0;
|
|
function providerCacheScope(provider: StorageProvider) {
|
|
let scope = providerScopes.get(provider);
|
|
if (scope === undefined) providerScopes.set(provider, (scope = nextProviderScope++));
|
|
return `${scope}:`;
|
|
}
|
|
|
|
// Concurrent readers of one object share one fetch. The fetch has its own
|
|
// controller: a reader that aborts only stops its own wait, and the fetch is
|
|
// aborted only when no reader is still waiting for it.
|
|
interface SharedObjectRead {
|
|
work: Promise<Buffer>;
|
|
controller: AbortController;
|
|
waiters: number;
|
|
}
|
|
const readsInFlight = new Map<string, SharedObjectRead>();
|
|
function cachedObject(key: string) {
|
|
const bytes = readCache.get(key);
|
|
if (bytes) {
|
|
readCache.delete(key);
|
|
readCache.set(key, bytes);
|
|
}
|
|
return bytes;
|
|
}
|
|
// The budget counts each entry's key and a fixed bookkeeping overhead with its
|
|
// bytes, so many small segments cannot hold much more memory than the cap.
|
|
const READ_CACHE_ENTRY_OVERHEAD_BYTES = 256;
|
|
const readCacheEntryCost = (key: string, bytes: Buffer) =>
|
|
bytes.length + key.length * 2 + READ_CACHE_ENTRY_OVERHEAD_BYTES;
|
|
function cacheObject(key: string, bytes: Buffer) {
|
|
if (bytes.length > READ_CACHE_MAX_OBJECT_BYTES || readCache.has(key)) return;
|
|
readCache.set(key, bytes);
|
|
readCacheBytes += readCacheEntryCost(key, bytes);
|
|
for (const [oldestKey, oldest] of readCache) {
|
|
if (readCacheBytes <= READ_CACHE_MAX_BYTES) break;
|
|
readCache.delete(oldestKey);
|
|
readCacheBytes -= readCacheEntryCost(oldestKey, oldest);
|
|
}
|
|
}
|
|
/** Bytes the read cache counts against its budget, including entry overhead. */
|
|
export function objectJournalReadCacheBytes() {
|
|
return readCacheBytes;
|
|
}
|
|
export function clearObjectJournalReadCache() {
|
|
readCache.clear();
|
|
readCacheBytes = 0;
|
|
// Readers already waiting keep their fetch; new readers start a new one.
|
|
readsInFlight.clear();
|
|
}
|
|
|
|
// One range read fetches its objects with bounded overlap. The shared objectIO
|
|
// limiter still caps the process; this keeps one tail read from taking all of
|
|
// its slots or filling the interactive wait queue.
|
|
const READ_RANGE_CONCURRENCY = 4;
|
|
function concurrencyLimit(max: number) {
|
|
let active = 0;
|
|
const waiting: Array<() => void> = [];
|
|
return async <T>(operation: () => Promise<T>): Promise<T> => {
|
|
if (active >= max) await new Promise<void>((resolve) => waiting.push(resolve));
|
|
else active++;
|
|
try {
|
|
return await operation();
|
|
} finally {
|
|
const next = waiting.shift();
|
|
if (next) next();
|
|
else active--;
|
|
}
|
|
};
|
|
}
|
|
|
|
// Wait for a shared operation, but stop waiting when this caller's signal aborts.
|
|
function untilAborted<T>(operation: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
if (!signal) return operation;
|
|
signal.throwIfAborted();
|
|
return new Promise<T>((resolve, reject) => {
|
|
const onAbort = () => reject(signal.reason);
|
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
operation.then(
|
|
(value) => {
|
|
signal.removeEventListener("abort", onAbort);
|
|
resolve(value);
|
|
},
|
|
(error) => {
|
|
signal.removeEventListener("abort", onAbort);
|
|
reject(error);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
export interface ObjectByteJournalOptions {
|
|
provider: StorageProvider;
|
|
prefix: string;
|
|
readPriority?: ObjectJournalIOPriority;
|
|
|
|
encode?: (bytes: Buffer) => Promise<Buffer>;
|
|
decode?: (bytes: Buffer) => Promise<Buffer>;
|
|
}
|
|
|
|
export function createObjectByteJournal(options: ObjectByteJournalOptions) {
|
|
const { provider } = options;
|
|
const prefix = options.prefix.replace(/\/+$/, "");
|
|
if (
|
|
!prefix ||
|
|
prefix.length > 512 ||
|
|
prefix.startsWith("/") ||
|
|
prefix.split("/").some((p) => !/^[a-zA-Z0-9._-]+$/.test(p))
|
|
) {
|
|
throw new Error("invalid_object_journal_prefix");
|
|
}
|
|
if (!provider.putObjectConditional || !provider.listObjects)
|
|
throw new Error("Object storage requires conditional writes and scoped listing");
|
|
const headKey = `${prefix}/head.json`;
|
|
const objectPrefix = `${prefix}/objects/`;
|
|
const readPriority = options.readPriority ?? "background";
|
|
const cacheScope = providerCacheScope(provider);
|
|
const putConditional = (
|
|
input: Parameters<NonNullable<StorageProvider["putObjectConditional"]>>[0],
|
|
) => objectIO(() => provider.putObjectConditional!(input), "background", input.signal);
|
|
|
|
async function getBytes(key: string, limit: number, signal?: AbortSignal) {
|
|
return objectIO(async () => {
|
|
signal?.throwIfAborted();
|
|
signal = signal
|
|
? AbortSignal.any([signal, AbortSignal.timeout(15_000)])
|
|
: AbortSignal.timeout(15_000);
|
|
const read = signal;
|
|
return sendThroughStorageOutage(async () => {
|
|
const result = await retryObjectOperation(
|
|
() => provider.getObject({ objectKey: key, signal: read }),
|
|
read,
|
|
);
|
|
const chunks: Buffer[] = [];
|
|
let size = 0;
|
|
try {
|
|
for await (const chunk of result.stream) {
|
|
const bytes = Buffer.from(chunk);
|
|
size += bytes.length;
|
|
if (size > limit) throw new Error("object_journal_object_too_large");
|
|
chunks.push(bytes);
|
|
}
|
|
} finally {
|
|
result.stream.destroy();
|
|
}
|
|
read?.throwIfAborted();
|
|
return { bytes: Buffer.concat(chunks), etag: result.etag };
|
|
}, read);
|
|
}, readPriority, signal);
|
|
}
|
|
async function head(signal?: AbortSignal): Promise<ObjectJournalWriter | null> {
|
|
let result;
|
|
try {
|
|
result = await getBytes(headKey, 64 * 1024, signal);
|
|
} catch (error) {
|
|
if (isMissing(error)) return null;
|
|
throw error;
|
|
}
|
|
if (!result.etag) throw new Error("object_journal_missing_etag");
|
|
const value = headSchema.parse(JSON.parse(result.bytes.toString("utf8")));
|
|
if (value.roots) {
|
|
let end = 0,
|
|
level = MAX_INDEX_LEVEL + 1;
|
|
if (value.root) throw new Error("object_journal_invalid_head");
|
|
for (const root of value.roots) {
|
|
validateReference(root);
|
|
if (root.start !== end || root.level >= level)
|
|
throw new Error("object_journal_invalid_head");
|
|
end = root.end;
|
|
level = root.level;
|
|
}
|
|
if (end !== value.end) throw new Error("object_journal_invalid_head");
|
|
} else if ((value.root?.end ?? 0) !== value.end || (value.root && value.root.start !== 0)) {
|
|
throw new Error("object_journal_invalid_head");
|
|
}
|
|
return { head: value, etag: result.etag };
|
|
}
|
|
function sourcePrefixProof(snapshot: ObjectJournalHead) {
|
|
// Leave strict head v1 unchanged for older readers/rollback. Ownership and
|
|
// close/claim state may change without changing this immutable byte prefix.
|
|
const fingerprint = sha256(Buffer.from(JSON.stringify({
|
|
end: snapshot.end, sequence: snapshot.sequence, sourceCursor: snapshot.sourceCursor,
|
|
root: snapshot.root, roots: snapshot.roots ?? null,
|
|
})));
|
|
return {
|
|
// Existing scoped object cleanup also removes these unreferenced metadata objects.
|
|
key: `${objectPrefix}source-prefix-proof-v1-${fingerprint}`,
|
|
bytes: Buffer.from(JSON.stringify({ schema: "paperclip.run-log.source-prefix-proof.v1", fingerprint })),
|
|
};
|
|
}
|
|
async function hasVerifiedSourcePrefix(snapshot: ObjectJournalHead, signal?: AbortSignal) {
|
|
const proof = sourcePrefixProof(snapshot);
|
|
let stored: Awaited<ReturnType<typeof getBytes>>;
|
|
try { stored = await getBytes(proof.key, 4096, signal); }
|
|
catch (error) { if (isMissing(error)) return false; throw error; }
|
|
if (!stored.bytes.equals(proof.bytes)) throw new ObjectJournalSourcePrefixProofInvalidError();
|
|
return true;
|
|
}
|
|
async function recordVerifiedSourcePrefix(writer: ObjectJournalWriter, sourceCursor: number, signal?: AbortSignal) {
|
|
const callerSignal = signal;
|
|
signal = signal
|
|
? AbortSignal.any([signal, AbortSignal.timeout(15_000)])
|
|
: AbortSignal.timeout(15_000);
|
|
signal.throwIfAborted();
|
|
if (!Number.isSafeInteger(sourceCursor) || sourceCursor < writer.head.sourceCursor)
|
|
throw new Error("object_journal_invalid_source_prefix_cursor");
|
|
const current = await head(signal);
|
|
if (!current || current.etag !== writer.etag || current.head.closed || current.head.deleted)
|
|
throw new ObjectJournalConflictError();
|
|
// One-time verification can repair legacy max-cursor metadata, never payload bytes.
|
|
if (sourceCursor !== writer.head.sourceCursor)
|
|
writer = await publish({ ...writer.head, sourceCursor }, writer.etag, signal);
|
|
const proof = sourcePrefixProof(writer.head);
|
|
try {
|
|
await retryObjectOperation(() => putConditional({
|
|
objectKey: proof.key, body: proof.bytes, contentLength: proof.bytes.length,
|
|
contentType: "application/json", ifNoneMatch: "*", signal,
|
|
}), signal);
|
|
} catch (error) {
|
|
callerSignal?.throwIfAborted();
|
|
if (error instanceof ObjectJournalConflictError) throw error;
|
|
// A lost reply or an already published identical immutable proof is safe.
|
|
try {
|
|
if (await hasVerifiedSourcePrefix(writer.head, signal)) return writer;
|
|
} catch (readError) {
|
|
callerSignal?.throwIfAborted();
|
|
if (readError instanceof ObjectJournalSourcePrefixProofInvalidError ||
|
|
readError instanceof ObjectJournalConflictError) throw readError;
|
|
if (signal.aborted || isRetryable(readError))
|
|
throw new ObjectJournalSourcePrefixProofUnavailableError(writer);
|
|
throw readError;
|
|
}
|
|
// Only this immutable proof publication is optional. The ownership read
|
|
// and any cursor/head CAS above retain their fatal failure boundary.
|
|
if (signal.aborted || isRetryable(error))
|
|
throw new ObjectJournalSourcePrefixProofUnavailableError(writer);
|
|
throw error;
|
|
}
|
|
return writer;
|
|
}
|
|
async function publish(value: ObjectJournalHead, etag: string | null, signal?: AbortSignal) {
|
|
value = headSchema.parse(value);
|
|
signal = signal
|
|
? AbortSignal.any([signal, AbortSignal.timeout(15_000)])
|
|
: AbortSignal.timeout(15_000);
|
|
const bytes = Buffer.from(JSON.stringify(value));
|
|
return retryObjectOperation(async () => {
|
|
try {
|
|
const result = await putConditional({
|
|
objectKey: headKey,
|
|
body: bytes,
|
|
contentLength: bytes.length,
|
|
contentType: "application/json",
|
|
signal,
|
|
...(etag ? { ifMatch: etag } : { ifNoneMatch: "*" as const }),
|
|
});
|
|
return { head: value, etag: result.etag };
|
|
} catch (error) {
|
|
if (signal.aborted) throw error;
|
|
const observed = await head(signal);
|
|
if (observed && JSON.stringify(observed.head) === JSON.stringify(value)) return observed;
|
|
if (isObjectPreconditionFailure(error) || (observed?.etag ?? null) !== etag)
|
|
throw new ObjectJournalConflictError();
|
|
throw error;
|
|
}
|
|
}, signal);
|
|
}
|
|
async function claim(input: {
|
|
assertAuthority: () => Promise<void>;
|
|
signal?: AbortSignal;
|
|
}) {
|
|
const current = await head(input.signal);
|
|
if (current?.head.deleted) throw new ObjectJournalConflictError();
|
|
await input.assertAuthority();
|
|
await qualifyConditionalWrites(provider, prefix, input.signal);
|
|
await input.assertAuthority();
|
|
const value: ObjectJournalHead = {
|
|
v: 1,
|
|
owner: randomUUID(),
|
|
generation: (current?.head.generation ?? 0) + 1,
|
|
end: current?.head.end ?? 0,
|
|
sequence: current?.head.sequence ?? 0,
|
|
sourceCursor: current?.head.sourceCursor ?? 0,
|
|
root: current?.head.root ?? null,
|
|
...(current?.head.roots ? { roots: current.head.roots } : {}),
|
|
closed: false,
|
|
deleted: false,
|
|
cleanupAfter: 0,
|
|
};
|
|
return publish(value, current?.etag ?? null, input.signal);
|
|
}
|
|
function validateReference(ref: Reference) {
|
|
if (
|
|
!ref.key.startsWith(objectPrefix) ||
|
|
ref.key !== `${objectPrefix}${ref.sha256}` ||
|
|
ref.end <= ref.start
|
|
)
|
|
throw new Error("object_journal_invalid_reference");
|
|
}
|
|
async function load(ref: Reference, signal?: AbortSignal) {
|
|
validateReference(ref);
|
|
signal?.throwIfAborted();
|
|
const cacheKey = `${cacheScope}${ref.key}`;
|
|
const cached = cachedObject(cacheKey);
|
|
if (cached) return cached;
|
|
let shared = readsInFlight.get(cacheKey);
|
|
if (!shared) {
|
|
const controller = new AbortController();
|
|
const work = runSharedStorageWork(async () => {
|
|
const stored = await getBytes(ref.key, MAX_OBJECT_BYTES * 2, controller.signal);
|
|
const bytes = options.decode ? await options.decode(stored.bytes) : stored.bytes;
|
|
if (bytes.length > MAX_OBJECT_BYTES || sha256(bytes) !== ref.sha256)
|
|
throw new Error("object_journal_integrity_error");
|
|
cacheObject(cacheKey, bytes);
|
|
return bytes;
|
|
});
|
|
const entry: SharedObjectRead = { work, controller, waiters: 0 };
|
|
const release = () => {
|
|
if (readsInFlight.get(cacheKey) === entry) readsInFlight.delete(cacheKey);
|
|
};
|
|
void work.then(release, release);
|
|
readsInFlight.set(cacheKey, entry);
|
|
shared = entry;
|
|
}
|
|
const entry = shared;
|
|
entry.waiters++;
|
|
try {
|
|
return await untilAborted(entry.work, signal);
|
|
} finally {
|
|
entry.waiters--;
|
|
// The last reader left before the fetch finished: stop the fetch, and
|
|
// let a later reader start a new one.
|
|
if (!entry.waiters && signal?.aborted) {
|
|
if (readsInFlight.get(cacheKey) === entry) readsInFlight.delete(cacheKey);
|
|
entry.controller.abort(signal.reason);
|
|
}
|
|
}
|
|
}
|
|
async function save(
|
|
bytes: Buffer,
|
|
start: number,
|
|
end: number,
|
|
signal?: AbortSignal,
|
|
): Promise<Reference> {
|
|
if (bytes.length > MAX_OBJECT_BYTES) throw new Error("object_journal_segment_too_large");
|
|
signal = signal
|
|
? AbortSignal.any([signal, AbortSignal.timeout(15_000)])
|
|
: AbortSignal.timeout(15_000);
|
|
const digest = sha256(bytes),
|
|
key = `${objectPrefix}${digest}`;
|
|
const body = options.encode ? await options.encode(bytes) : bytes;
|
|
try {
|
|
await retryObjectOperation(
|
|
() =>
|
|
putConditional({
|
|
objectKey: key,
|
|
body,
|
|
contentLength: body.length,
|
|
contentType: "application/octet-stream",
|
|
ifNoneMatch: "*",
|
|
signal,
|
|
}),
|
|
signal,
|
|
);
|
|
} catch (error) {
|
|
const ref = { key, sha256: digest, start, end };
|
|
try {
|
|
await load(ref, signal);
|
|
} catch {
|
|
throw error;
|
|
}
|
|
}
|
|
return { key, start, end, sha256: digest };
|
|
}
|
|
async function loadNode(ref: Reference, signal?: AbortSignal): Promise<Node> {
|
|
const node = nodeSchema.parse(JSON.parse((await load(ref, signal)).toString("utf8")));
|
|
let next = ref.start;
|
|
for (const child of node.children) {
|
|
validateReference(child);
|
|
if (child.start !== next) throw new Error("object_journal_noncontiguous_index");
|
|
next = child.end;
|
|
}
|
|
if (next !== ref.end) throw new Error("object_journal_invalid_index_range");
|
|
return node;
|
|
}
|
|
const saveNode = (node: Node, signal?: AbortSignal) =>
|
|
save(
|
|
Buffer.from(JSON.stringify(node)),
|
|
node.children[0]!.start,
|
|
node.children.at(-1)!.end,
|
|
signal,
|
|
);
|
|
async function readRange(
|
|
snapshot: ObjectJournalHead,
|
|
start: number,
|
|
limit: number,
|
|
signal?: AbortSignal,
|
|
): Promise<Buffer> {
|
|
if (snapshot.deleted) throw new Error("object_journal_deleted");
|
|
if (
|
|
!Number.isSafeInteger(start) ||
|
|
start < 0 ||
|
|
!Number.isSafeInteger(limit) ||
|
|
limit < 0 ||
|
|
limit > MAX_OBJECT_BYTES
|
|
)
|
|
throw new Error("invalid_object_journal_range");
|
|
if ((!snapshot.root && !snapshot.roots?.length) || limit === 0 || start >= snapshot.end)
|
|
return Buffer.alloc(0);
|
|
const end = Math.min(snapshot.end, start + limit),
|
|
pieces: Array<{ start: number; bytes: Buffer }> = [],
|
|
limited = concurrencyLimit(READ_RANGE_CONCURRENCY),
|
|
failed = new AbortController(),
|
|
readSignal = signal ? AbortSignal.any([signal, failed.signal]) : failed.signal;
|
|
const overlaps = (ref: Reference) => ref.end > start && ref.start < end;
|
|
// Siblings are fetched concurrently; pieces are reassembled by offset.
|
|
async function readPayload(ref: Reference) {
|
|
const bytes = await limited(() => load(ref, readSignal));
|
|
if (bytes.length !== ref.end - ref.start)
|
|
throw new Error("object_journal_invalid_segment_length");
|
|
pieces.push({
|
|
start: ref.start,
|
|
bytes: bytes.subarray(Math.max(0, start - ref.start), Math.min(bytes.length, end - ref.start)),
|
|
});
|
|
}
|
|
async function visit(ref: Reference, expectedLevel?: number): Promise<void> {
|
|
const node = await limited(() => loadNode(ref, readSignal));
|
|
if (expectedLevel !== undefined && node.level !== expectedLevel)
|
|
throw new Error("object_journal_invalid_index_level");
|
|
await Promise.all(node.children.filter(overlaps).map((child) =>
|
|
node.level > 0 ? visit(child, node.level - 1) : readPayload(child)));
|
|
}
|
|
try {
|
|
if (snapshot.roots) {
|
|
await Promise.all(snapshot.roots.filter(overlaps).map((root) =>
|
|
root.level === 0 ? readPayload(root) : visit(root, root.level - 1)));
|
|
} else if (snapshot.root) await visit(snapshot.root);
|
|
} catch (error) {
|
|
failed.abort(error);
|
|
throw error;
|
|
}
|
|
pieces.sort((a, b) => a.start - b.start);
|
|
const bytes = Buffer.concat(pieces.map((piece) => piece.bytes));
|
|
if (bytes.length !== end - start) throw new Error("object_journal_missing_range");
|
|
return bytes;
|
|
}
|
|
async function append(
|
|
writer: ObjectJournalWriter,
|
|
bytes: Buffer,
|
|
signal?: AbortSignal,
|
|
sequence = writer.head.sequence,
|
|
sourceCursor = writer.head.sourceCursor,
|
|
) {
|
|
signal = signal
|
|
? AbortSignal.any([signal, AbortSignal.timeout(15_000)])
|
|
: AbortSignal.timeout(15_000);
|
|
if (writer.head.closed || writer.head.deleted) throw new ObjectJournalConflictError();
|
|
if (bytes.length === 0) return writer;
|
|
if (
|
|
bytes.length > 8 * 1024 * 1024 ||
|
|
!Number.isSafeInteger(writer.head.end + bytes.length) ||
|
|
!Number.isSafeInteger(sequence) ||
|
|
sequence < writer.head.sequence ||
|
|
!Number.isSafeInteger(sourceCursor) ||
|
|
sourceCursor < writer.head.sourceCursor
|
|
)
|
|
throw new Error("object_journal_invalid_batch");
|
|
const roots: Root[] = writer.head.roots
|
|
? [...writer.head.roots]
|
|
: writer.head.root
|
|
? [{ ...writer.head.root, level: (await loadNode(writer.head.root, signal)).level + 1 }]
|
|
: [];
|
|
let end = writer.head.end;
|
|
for (let start = 0; start < bytes.length; start += 1024 * 1024) {
|
|
const part = bytes.subarray(start, start + 1024 * 1024);
|
|
let root: Root = { ...(await save(part, end, end + part.length, signal)), level: 0 };
|
|
end = root.end;
|
|
while (roots.at(-1)?.level === root.level) {
|
|
const left = roots.pop()!;
|
|
const { level: leftLevel, ...leftRef } = left;
|
|
const { level, ...rightRef } = root;
|
|
if (level >= MAX_INDEX_LEVEL || leftLevel !== level)
|
|
throw new Error("object_journal_index_capacity_exceeded");
|
|
root = {
|
|
...(await saveNode({ level, children: [leftRef, rightRef] }, signal)),
|
|
level: level + 1,
|
|
};
|
|
}
|
|
roots.push(root);
|
|
}
|
|
return publish(
|
|
{ ...writer.head, end, root: null, roots, sequence, sourceCursor },
|
|
writer.etag,
|
|
signal,
|
|
);
|
|
}
|
|
|
|
async function close(writer: ObjectJournalWriter, signal?: AbortSignal) {
|
|
return publish({ ...writer.head, closed: true }, writer.etag, signal);
|
|
}
|
|
async function remove(signal?: AbortSignal) {
|
|
let tombstone: ObjectJournalWriter | undefined;
|
|
for (let attempt = 0; attempt < 8; attempt++) {
|
|
const current = await head(signal);
|
|
if (current?.head.deleted) {
|
|
tombstone = current;
|
|
break;
|
|
}
|
|
const value: ObjectJournalHead = current
|
|
? {
|
|
...current.head,
|
|
deleted: true,
|
|
closed: true,
|
|
cleanupAfter: current.head.closed ? 0 : Date.now() + 30_000,
|
|
}
|
|
: {
|
|
v: 1,
|
|
sourceCursor: 0,
|
|
owner: randomUUID(),
|
|
generation: 0,
|
|
end: 0,
|
|
sequence: 0,
|
|
root: null,
|
|
deleted: true,
|
|
closed: true,
|
|
cleanupAfter: 0,
|
|
};
|
|
try {
|
|
tombstone = await publish(value, current?.etag ?? null, signal);
|
|
break;
|
|
} catch (error) {
|
|
if (!(error instanceof ObjectJournalConflictError)) throw error;
|
|
}
|
|
}
|
|
if (!tombstone) throw new ObjectJournalConflictError();
|
|
if (tombstone.head.cleanupAfter > Date.now()) throw new Error("object_journal_cleanup_pending");
|
|
|
|
let continuationToken: string | undefined;
|
|
do {
|
|
const deadline = () =>
|
|
signal
|
|
? AbortSignal.any([signal, AbortSignal.timeout(15_000)])
|
|
: AbortSignal.timeout(15_000);
|
|
const page = await objectIO(
|
|
() => provider.listObjects!({ prefix: objectPrefix, continuationToken, signal: deadline() }),
|
|
"background",
|
|
signal,
|
|
);
|
|
for (const key of page.keys) {
|
|
if (!key.startsWith(objectPrefix)) throw new Error("object_journal_invalid_listing");
|
|
await objectIO(
|
|
() => provider.deleteObject({ objectKey: key, signal: deadline() }),
|
|
"background",
|
|
signal,
|
|
);
|
|
}
|
|
continuationToken = page.continuationToken;
|
|
} while (continuationToken);
|
|
}
|
|
return { head, claim, append, close, readRange, remove, hasVerifiedSourcePrefix, recordVerifiedSourcePrefix };
|
|
}
|
|
export type ObjectByteJournal = ReturnType<typeof createObjectByteJournal>;
|