@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/README.md +5 -4
- package/package.json +4 -3
- package/src/braids.ts +17 -2
- package/src/bytes.ts +78 -10
- package/src/chain.ts +173 -56
- package/src/codec.ts +122 -41
- package/src/descriptor.ts +182 -107
- package/src/errors.ts +133 -5
- package/src/index.ts +15 -2
- package/src/keys.ts +161 -18
- package/src/manifest.ts +165 -73
- package/src/replica.ts +577 -264
- package/src/store-s3.ts +191 -36
- package/src/store.ts +458 -84
- package/src/tenants.ts +322 -26
- package/src/value.ts +65 -14
- package/src/vector.ts +173 -0
- package/src/writer.ts +453 -92
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
|
|
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 =
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|