@bjornpagen/bumbledb-log 0.18.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/store-s3.ts CHANGED
@@ -3,14 +3,20 @@
3
3
  * `@aws-sdk/client-s3` client signs and talks; this module maps HTTP
4
4
  * outcomes onto the sums. One storage class for every key. The vendor
5
5
  * ETag header is the opaque token, carried verbatim. R2 rides region
6
- * `auto` and a required endpoint.
6
+ * `auto` and a required endpoint. A 409, a timeout, or any other
7
+ * unproved conditional-write result is Ambiguous; the GET-verify law
8
+ * resolves it. Credentials are consulted per request. Conditional
9
+ * writes are not retried by the client: a re-sent PUT is an unproved
10
+ * outcome. Body-stream failures wrap ErrStore.
7
11
  */
8
12
 
9
13
  import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"
10
14
  import * as errors from "@superbuilders/errors"
15
+ import { bytesEqual } from "#bytes.ts"
11
16
  import { wrapStore } from "#errors.ts"
12
17
  import type { StoreKey } from "#keys.ts"
13
- import type { Etag, ObjectStore } from "#store.ts"
18
+ import { parsePrefix } from "#keys.ts"
19
+ import type { Create, Etag, Fetched, ObjectStore, Swap } from "#store.ts"
14
20
 
15
21
  interface StaticKeys {
16
22
  readonly accessKeyId: string
@@ -29,21 +35,14 @@ interface S3Config {
29
35
  readonly prefix?: string
30
36
  }
31
37
 
38
+ const READ_ATTEMPTS = 6
39
+ const READ_BASE_MS = 50
40
+ const READ_CAP_MS = 2_000
41
+
32
42
  function asEtag(raw: string): Etag {
33
43
  return raw as Etag
34
44
  }
35
45
 
36
- function normalizePrefix(raw: string | undefined): string {
37
- if (raw === undefined || raw.length === 0) {
38
- return ""
39
- }
40
- const trimmed = raw.replace(/^\/+|\/+$/g, "")
41
- if (trimmed.length === 0 || trimmed.split("/").some((seg) => seg.length === 0)) {
42
- throw errors.new(`store prefix is not a slash path: ${raw}`)
43
- }
44
- return trimmed
45
- }
46
-
47
46
  function joinPrefix(prefix: string, key: string): string {
48
47
  return prefix.length === 0 ? key : `${prefix}/${key}`
49
48
  }
@@ -81,13 +80,82 @@ function etagOf(raw: string | undefined, op: string, key: StoreKey): Etag {
81
80
  return asEtag(raw)
82
81
  }
83
82
 
83
+ function isTransientName(error: unknown): boolean {
84
+ if (error === null || typeof error !== "object" || !("name" in error)) {
85
+ return true
86
+ }
87
+ const name = error.name
88
+ return (
89
+ name === "TimeoutError" ||
90
+ name === "RequestTimeout" ||
91
+ name === "TimeoutErrorException" ||
92
+ name === "NetworkingError" ||
93
+ name === "Conflict" ||
94
+ name === "ConflictException" ||
95
+ name === undefined
96
+ )
97
+ }
98
+
99
+ /** 409 Conflict is Ambiguous, never a proved Exists or Moved. */
100
+ function isUnprovedWrite(error: unknown): boolean {
101
+ const status = httpStatus(error)
102
+ if (status === 409) {
103
+ return true
104
+ }
105
+ if (status !== undefined && status >= 500 && status < 600) {
106
+ return true
107
+ }
108
+ if (status !== undefined) {
109
+ return false
110
+ }
111
+ return isTransientName(error)
112
+ }
113
+
114
+ function isRetryableRead(error: unknown): boolean {
115
+ const status = httpStatus(error)
116
+ if (status !== undefined) {
117
+ return status >= 500 && status < 600
118
+ }
119
+ return isTransientName(error)
120
+ }
121
+
122
+ async function sleep(ms: number): Promise<void> {
123
+ await new Promise(function later(resolve) {
124
+ setTimeout(resolve, ms)
125
+ })
126
+ }
127
+
128
+ function jitteredMs(ceiling: number): number {
129
+ return Math.floor(Math.random() * (ceiling + 1))
130
+ }
131
+
132
+ async function bodyBytes(body: unknown, op: string, key: StoreKey): Promise<Uint8Array> {
133
+ const ran = await errors.try(
134
+ (async function consume() {
135
+ if (body === null || typeof body !== "object") {
136
+ throw errors.new("vendor omitted body stream")
137
+ }
138
+ const stream = body as { transformToByteArray?: () => Promise<Uint8Array> }
139
+ if (typeof stream.transformToByteArray !== "function") {
140
+ throw errors.new("vendor omitted body stream")
141
+ }
142
+ return await stream.transformToByteArray()
143
+ })()
144
+ )
145
+ if (ran.error) {
146
+ throw wrapStore(ran.error, `${op} ${key}`)
147
+ }
148
+ return ran.data
149
+ }
150
+
84
151
  function s3Store(config: S3Config): ObjectStore {
85
152
  if (config.region === "auto" && config.endpoint === undefined) {
86
153
  throw errors.new("region auto needs an endpoint")
87
154
  }
88
- const prefix = normalizePrefix(config.prefix)
155
+ const prefix = parsePrefix(config.prefix ?? "")
89
156
  const client = new S3Client({
90
157
  region: config.region,
158
+ maxAttempts: 1,
91
159
  credentials: async function credentials() {
92
160
  const keys = await resolveKeys(config.credentials)
93
161
  return {
@@ -105,29 +173,104 @@ function s3Store(config: S3Config): ObjectStore {
105
173
  return joinPrefix(prefix, key)
106
174
  }
107
175
 
108
- return {
109
- async get(key) {
110
- const ran = await errors.try(
111
- client.send(
112
- new GetObjectCommand({
113
- Bucket: config.bucket,
114
- Key: objectKey(key)
115
- })
116
- )
176
+ type Load =
177
+ | { readonly tag: "hit"; readonly fetched: Fetched }
178
+ | { readonly tag: "miss" }
179
+ | { readonly tag: "retry"; readonly error: Error }
180
+ | { readonly tag: "fail"; readonly error: Error }
181
+
182
+ async function load(key: StoreKey): Promise<Load> {
183
+ const ran = await errors.try(
184
+ client.send(
185
+ new GetObjectCommand({
186
+ Bucket: config.bucket,
187
+ Key: objectKey(key)
188
+ })
117
189
  )
118
- if (ran.error) {
119
- if (httpStatus(ran.error) === 404) {
120
- return null
121
- }
122
- throw wrapStore(asError(ran.error), `get ${key}`)
190
+ )
191
+ if (ran.error) {
192
+ if (httpStatus(ran.error) === 404) {
193
+ return { tag: "miss" }
123
194
  }
124
- if (ran.data.Body === undefined) {
125
- throw wrapStore(errors.new("vendor omitted body"), `get ${key}`)
195
+ const error = wrapStore(asError(ran.error), `get ${key}`)
196
+ return isRetryableRead(ran.error) ? { tag: "retry", error } : { tag: "fail", error }
197
+ }
198
+ if (ran.data.Body === undefined) {
199
+ return { tag: "fail", error: wrapStore(errors.new("vendor omitted body"), `get ${key}`) }
200
+ }
201
+ const body = await errors.try(bodyBytes(ran.data.Body, "get", key))
202
+ if (body.error) {
203
+ return { tag: "retry", error: body.error }
204
+ }
205
+ const tag = await errors.try(
206
+ (async function etag() {
207
+ return etagOf(ran.data.ETag, "get", key)
208
+ })()
209
+ )
210
+ if (tag.error) {
211
+ return { tag: "fail", error: tag.error }
212
+ }
213
+ return { tag: "hit", fetched: { bytes: body.data, etag: tag.data } }
214
+ }
215
+
216
+ async function getOnce(key: StoreKey): Promise<Fetched | null> {
217
+ const step = await load(key)
218
+ if (step.tag === "hit") {
219
+ return step.fetched
220
+ }
221
+ if (step.tag === "miss") {
222
+ return null
223
+ }
224
+ throw step.error
225
+ }
226
+
227
+ async function retryGet(key: StoreKey): Promise<Fetched | null> {
228
+ let attempt = 0
229
+ for (;;) {
230
+ const step = await load(key)
231
+ if (step.tag === "hit") {
232
+ return step.fetched
126
233
  }
127
- return {
128
- bytes: await ran.data.Body.transformToByteArray(),
129
- etag: etagOf(ran.data.ETag, "get", key)
234
+ if (step.tag === "miss") {
235
+ return null
236
+ }
237
+ if (step.tag === "fail") {
238
+ throw step.error
239
+ }
240
+ attempt += 1
241
+ if (attempt === READ_ATTEMPTS) {
242
+ throw step.error
130
243
  }
244
+ const ceiling = Math.min(READ_CAP_MS, READ_BASE_MS * 2 ** (attempt - 1))
245
+ await sleep(jitteredMs(ceiling))
246
+ }
247
+ }
248
+
249
+ async function proveCreate(key: StoreKey, attempted: Uint8Array): Promise<Create> {
250
+ const fetched = await retryGet(key)
251
+ if (fetched === null) {
252
+ return { tag: "ambiguous" }
253
+ }
254
+ if (bytesEqual(fetched.bytes, attempted)) {
255
+ return { tag: "created", etag: fetched.etag }
256
+ }
257
+ return { tag: "exists" }
258
+ }
259
+
260
+ async function proveSwap(key: StoreKey, attempted: Uint8Array): Promise<Swap> {
261
+ const fetched = await retryGet(key)
262
+ if (fetched === null) {
263
+ return { tag: "moved" }
264
+ }
265
+ if (bytesEqual(fetched.bytes, attempted)) {
266
+ return { tag: "swapped", etag: fetched.etag }
267
+ }
268
+ return { tag: "moved" }
269
+ }
270
+
271
+ return {
272
+ async get(key) {
273
+ return await getOnce(key)
131
274
  },
132
275
 
133
276
  async getIfChanged(key, etag) {
@@ -152,7 +295,7 @@ function s3Store(config: S3Config): ObjectStore {
152
295
  return {
153
296
  tag: "changed",
154
297
  fetched: {
155
- bytes: await ran.data.Body.transformToByteArray(),
298
+ bytes: await bodyBytes(ran.data.Body, "getIfChanged", key),
156
299
  etag: etagOf(ran.data.ETag, "getIfChanged", key)
157
300
  }
158
301
  }
@@ -173,9 +316,15 @@ function s3Store(config: S3Config): ObjectStore {
173
316
  if (httpStatus(ran.error) === 412) {
174
317
  return { tag: "exists" }
175
318
  }
319
+ if (isUnprovedWrite(ran.error)) {
320
+ return await proveCreate(key, bytes)
321
+ }
176
322
  throw wrapStore(asError(ran.error), `putCreate ${key}`)
177
323
  }
178
- return { tag: "created", etag: etagOf(ran.data.ETag, "putCreate", key) }
324
+ if (ran.data.ETag === undefined || ran.data.ETag.length === 0) {
325
+ return await proveCreate(key, bytes)
326
+ }
327
+ return { tag: "created", etag: asEtag(ran.data.ETag) }
179
328
  },
180
329
 
181
330
  async putSwap(key, bytes, etag) {
@@ -194,9 +343,15 @@ function s3Store(config: S3Config): ObjectStore {
194
343
  if (status === 412 || status === 404) {
195
344
  return { tag: "moved" }
196
345
  }
346
+ if (isUnprovedWrite(ran.error)) {
347
+ return await proveSwap(key, bytes)
348
+ }
197
349
  throw wrapStore(asError(ran.error), `putSwap ${key}`)
198
350
  }
199
- return { tag: "swapped", etag: etagOf(ran.data.ETag, "putSwap", key) }
351
+ if (ran.data.ETag === undefined || ran.data.ETag.length === 0) {
352
+ return await proveSwap(key, bytes)
353
+ }
354
+ return { tag: "swapped", etag: asEtag(ran.data.ETag) }
200
355
  },
201
356
 
202
357
  async delete(key) {