Public: anyone can read this; nobody can change it.

The snapshot store tries again: five attempts with doubling waits, and a failure names its key #23

Closed opened this view on origin
Closed without merging

snapshot-retry → main 1 commit

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
3 files changed +149 −0
scripts/gate.ts +1
@@ -100,6 +100,7 @@ export const GATE: readonly string[] = [
100100 "test/git-trust.test.ts",
101101 "test/helpers.test.ts",
102102 "test/shards.test.ts",
103 "test/snapshot-store.test.ts",
103104 "test/migrate.test.ts",
104105 "test/schema-additive.test.ts",
105106 "test/schema-migrations.test.ts",
src/snapshot/store.ts +47
@@ -49,7 +49,54 @@ export function s3FromEnv(
4949 };
5050 }
5151
52 /**
53 * A store that tries again. Object stores answer a burst, a flake or a
54 * moment of maintenance with a 5xx — Backblaze says so in its own docs —
55 * and one such answer in a thousand puts used to fail the whole snapshot
56 * with "S3Error: an unexpected error has occurred" and no key named. Every
57 * call is tried `attempts` times with doubling waits, and what finally
58 * fails says which key it was.
59 */
60 export function withRetries(
61 inner: Store,
62 options: {
63 attempts?: number;
64 baseMs?: number;
65 sleep?: (ms: number) => Promise<void>;
66 } = {},
67 ): Store {
68 const attempts = Math.max(1, options.attempts ?? 5);
69 const baseMs = options.baseMs ?? 500;
70 const sleep = options.sleep ?? ((ms) => Bun.sleep(ms));
71 async function tried<T>(what: string, fn: () => Promise<T>): Promise<T> {
72 let last: unknown;
73 for (let n = 0; n < attempts; n++) {
74 try {
75 return await fn();
76 } catch (error) {
77 last = error;
78 if (n + 1 < attempts) await sleep(baseMs * 2 ** n);
79 }
80 }
81 throw new Error(
82 `${what} failed after ${attempts} attempt${attempts === 1 ? "" : "s"}: ${String(last instanceof Error ? last.message : last)}`,
83 );
84 }
85 return {
86 where: inner.where,
87 put: (key, body) => tried(`put ${key}`, () => inner.put(key, body)),
88 get: (key) => tried(`get ${key}`, () => inner.get(key)),
89 list: (prefix) => tried(`list ${prefix}`, () => inner.list(prefix)),
90 names: (prefix) => tried(`names ${prefix}`, () => inner.names(prefix)),
91 delete: (key) => tried(`delete ${key}`, () => inner.delete(key)),
92 };
93 }
94
5295 export function s3Store(target: S3Target): Store {
96 return withRetries(rawS3Store(target));
97 }
98
99 function rawS3Store(target: S3Target): Store {
53100 const client = new Bun.S3Client({
54101 endpoint: target.endpoint,
55102 bucket: target.bucket,
test/snapshot-store.test.ts added +101
@@ -0,0 +1,101 @@
1 /**
2 * The snapshot store tries again. An object store answers a burst or a
3 * moment of maintenance with a 5xx; one such answer used to fail a whole
4 * nightly snapshot and name no key. `withRetries` tries each call several
5 * times with doubling waits and, when it still fails, says which key.
6 */
7 import { describe, expect, test } from "bun:test";
8 import { withRetries, type Store } from "../src/snapshot/store.ts";
9
10 /** A store that fails the first `failures` calls of each kind, then works. */
11 function flaky(failures: number): { store: Store; calls: string[] } {
12 const calls: string[] = [];
13 const left = new Map<string, number>();
14 const gate = async (what: string): Promise<void> => {
15 calls.push(what);
16 const n = left.get(what) ?? failures;
17 if (n > 0) {
18 left.set(what, n - 1);
19 throw new Error("S3Error: an unexpected error has occurred");
20 }
21 };
22 const kept = new Map<string, Uint8Array | string>();
23 return {
24 calls,
25 store: {
26 where: "flaky",
27 async put(key, body) {
28 await gate(`put ${key}`);
29 kept.set(key, body);
30 },
31 async get(key) {
32 await gate(`get ${key}`);
33 const v = kept.get(key);
34 return v === undefined
35 ? undefined
36 : typeof v === "string"
37 ? new TextEncoder().encode(v)
38 : v;
39 },
40 async list(prefix) {
41 await gate(`list ${prefix}`);
42 return [...kept.keys()].filter((k) => k.startsWith(prefix));
43 },
44 async names(prefix) {
45 await gate(`names ${prefix}`);
46 return [];
47 },
48 async delete(key) {
49 await gate(`delete ${key}`);
50 kept.delete(key);
51 },
52 },
53 };
54 }
55
56 describe("the snapshot store, with retries", () => {
57 test("a call that fails twice and then works is one call to the caller, after two waits that double", async () => {
58 const waits: number[] = [];
59 const f = flaky(2);
60 const store = withRetries(f.store, {
61 attempts: 5,
62 baseMs: 100,
63 sleep: async (ms) => {
64 waits.push(ms);
65 },
66 });
67 await store.put("snapshots/a/forge.db", "db");
68 expect(
69 f.calls.filter((c) => c === "put snapshots/a/forge.db"),
70 ).toHaveLength(3);
71 expect(waits).toEqual([100, 200]);
72 expect(await store.list("snapshots/")).toEqual(["snapshots/a/forge.db"]);
73 });
74
75 test("a call that never works fails after the attempts, naming the key and the last reason", async () => {
76 const f = flaky(99);
77 const store = withRetries(f.store, {
78 attempts: 3,
79 baseMs: 1,
80 sleep: async () => {},
81 });
82 await expect(store.put("snapshots/b/repos/x.bundle", "x")).rejects.toThrow(
83 /put snapshots\/b\/repos\/x\.bundle failed after 3 attempts: S3Error: an unexpected error has occurred/,
84 );
85 expect(f.calls).toHaveLength(3);
86 });
87
88 test("a store that works is called once, with no wait", async () => {
89 const waits: number[] = [];
90 const f = flaky(0);
91 const store = withRetries(f.store, {
92 sleep: async (ms) => {
93 waits.push(ms);
94 },
95 });
96 await store.put("k", "v");
97 expect(new TextDecoder().decode(await store.get("k"))).toBe("v");
98 expect(waits).toEqual([]);
99 expect(f.calls).toEqual(["put k", "get k"]);
100 });
101 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs