@tranquilload/core 0.1.0
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/.turbo/turbo-build.log +88 -0
- package/dist/compression-service-Bm1VBnhT.mjs +18 -0
- package/dist/compression-service-Bm1VBnhT.mjs.map +1 -0
- package/dist/compression-service-Bn86iTJe.cjs +35 -0
- package/dist/compression-service-Bn86iTJe.cjs.map +1 -0
- package/dist/compression-service-CiF7Px08.d.cts +15 -0
- package/dist/compression-service-CiF7Px08.d.cts.map +1 -0
- package/dist/compression-service-DI7ZXVxH.d.mts +15 -0
- package/dist/compression-service-DI7ZXVxH.d.mts.map +1 -0
- package/dist/errors.cjs +9 -0
- package/dist/errors.d.cts +2 -0
- package/dist/errors.d.mts +2 -0
- package/dist/errors.mjs +2 -0
- package/dist/index-Ch8xM6Xt.d.cts +60 -0
- package/dist/index-Ch8xM6Xt.d.cts.map +1 -0
- package/dist/index-DBGtgXEd.d.mts +60 -0
- package/dist/index-DBGtgXEd.d.mts.map +1 -0
- package/dist/logger-service-1J5r_akj.mjs +8 -0
- package/dist/logger-service-1J5r_akj.mjs.map +1 -0
- package/dist/logger-service-BF2pZOHN.d.mts +12 -0
- package/dist/logger-service-BF2pZOHN.d.mts.map +1 -0
- package/dist/logger-service-CbN12RhO.d.cts +12 -0
- package/dist/logger-service-CbN12RhO.d.cts.map +1 -0
- package/dist/logger-service-cx8vzkXs.cjs +19 -0
- package/dist/logger-service-cx8vzkXs.cjs.map +1 -0
- package/dist/middleware-CAI0cnW2.d.mts +10 -0
- package/dist/middleware-CAI0cnW2.d.mts.map +1 -0
- package/dist/middleware-CYcctmlY.d.cts +10 -0
- package/dist/middleware-CYcctmlY.d.cts.map +1 -0
- package/dist/multipart.cjs +244 -0
- package/dist/multipart.cjs.map +1 -0
- package/dist/multipart.d.cts +2 -0
- package/dist/multipart.d.mts +2 -0
- package/dist/multipart.mjs +243 -0
- package/dist/multipart.mjs.map +1 -0
- package/dist/normalize-callback-BNBZZ1jT.cjs +44 -0
- package/dist/normalize-callback-BNBZZ1jT.cjs.map +1 -0
- package/dist/normalize-callback-DQ6C4gaV.mjs +33 -0
- package/dist/normalize-callback-DQ6C4gaV.mjs.map +1 -0
- package/dist/oneshot.cjs +64 -0
- package/dist/oneshot.cjs.map +1 -0
- package/dist/oneshot.d.cts +28 -0
- package/dist/oneshot.d.cts.map +1 -0
- package/dist/oneshot.d.mts +28 -0
- package/dist/oneshot.d.mts.map +1 -0
- package/dist/oneshot.mjs +63 -0
- package/dist/oneshot.mjs.map +1 -0
- package/dist/pipeline.cjs +16 -0
- package/dist/pipeline.cjs.map +1 -0
- package/dist/pipeline.d.cts +9 -0
- package/dist/pipeline.d.cts.map +1 -0
- package/dist/pipeline.d.mts +9 -0
- package/dist/pipeline.d.mts.map +1 -0
- package/dist/pipeline.mjs +14 -0
- package/dist/pipeline.mjs.map +1 -0
- package/dist/progress.cjs +0 -0
- package/dist/progress.d.cts +3 -0
- package/dist/progress.d.mts +3 -0
- package/dist/progress.mjs +1 -0
- package/dist/services.cjs +8 -0
- package/dist/services.d.cts +3 -0
- package/dist/services.d.mts +3 -0
- package/dist/services.mjs +3 -0
- package/dist/upload-error-B2ISUc_k.d.cts +48 -0
- package/dist/upload-error-B2ISUc_k.d.cts.map +1 -0
- package/dist/upload-error-BUexBh08.cjs +119 -0
- package/dist/upload-error-BUexBh08.cjs.map +1 -0
- package/dist/upload-error-jol-eoDW.d.mts +48 -0
- package/dist/upload-error-jol-eoDW.d.mts.map +1 -0
- package/dist/upload-error-zDvpxT9X.mjs +72 -0
- package/dist/upload-error-zDvpxT9X.mjs.map +1 -0
- package/dist/upload-event-C9TOVp5l.d.mts +36 -0
- package/dist/upload-event-C9TOVp5l.d.mts.map +1 -0
- package/dist/upload-event-D77olieX.d.cts +36 -0
- package/dist/upload-event-D77olieX.d.cts.map +1 -0
- package/package.json +70 -0
- package/src/errors/index.ts +10 -0
- package/src/errors/upload-error.test.ts +218 -0
- package/src/errors/upload-error.ts +89 -0
- package/src/multipart/chunk-stream.test.ts +79 -0
- package/src/multipart/chunk-stream.ts +37 -0
- package/src/multipart/circuit-breaker.test.ts +95 -0
- package/src/multipart/circuit-breaker.ts +68 -0
- package/src/multipart/index.test.ts +283 -0
- package/src/multipart/index.ts +119 -0
- package/src/multipart/upload-stream.test.ts +336 -0
- package/src/multipart/upload-stream.ts +246 -0
- package/src/oneshot/index.test.ts +153 -0
- package/src/oneshot/index.ts +76 -0
- package/src/oneshot/upload.test.ts +130 -0
- package/src/oneshot/upload.ts +52 -0
- package/src/pipeline/compress.test.ts +69 -0
- package/src/pipeline/compress.ts +8 -0
- package/src/pipeline/index.ts +3 -0
- package/src/pipeline/middleware.test.ts +102 -0
- package/src/pipeline/middleware.ts +30 -0
- package/src/progress/getprogress.test.ts +102 -0
- package/src/progress/index.ts +10 -0
- package/src/progress/upload-event.test.ts +102 -0
- package/src/progress/upload-event.ts +37 -0
- package/src/scaffold.test.ts +5 -0
- package/src/services/compression-service.test.ts +68 -0
- package/src/services/compression-service.ts +31 -0
- package/src/services/index.ts +11 -0
- package/src/services/logger-service-integration.test.ts +98 -0
- package/src/services/logger-service.test.ts +40 -0
- package/src/services/logger-service.ts +17 -0
- package/src/utils/abort-interop.test.ts +65 -0
- package/src/utils/abort-interop.ts +14 -0
- package/src/utils/normalize-callback.test.ts +46 -0
- package/src/utils/normalize-callback.ts +18 -0
- package/tsconfig.json +8 -0
- package/tsdown.config.ts +16 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest"
|
|
2
|
+
import { Cause, Effect, Fiber, Ref, Schedule, Stream, TestClock } from "effect"
|
|
3
|
+
import { AbortError, CircuitOpenError, CompleteUploadError, MaxRetriesExceededError, PartUploadError, PresignedUrlError, ReconcileError } from "../errors/upload-error.js"
|
|
4
|
+
import type { UploadEvent } from "../progress/upload-event.js"
|
|
5
|
+
import { LoggerServiceLive } from "../services/logger-service.js"
|
|
6
|
+
import { uploadMultipartEffect, type CompletedPart } from "./upload-stream.js"
|
|
7
|
+
|
|
8
|
+
const fromBytes = (bytes: Uint8Array): ReadableStream<Uint8Array> =>
|
|
9
|
+
new ReadableStream({ start: c => { c.enqueue(bytes); c.close() } })
|
|
10
|
+
|
|
11
|
+
const run = (options: Parameters<typeof uploadMultipartEffect>[0]) =>
|
|
12
|
+
Stream.runCollect(uploadMultipartEffect(options)).pipe(
|
|
13
|
+
Effect.map(chunk => Array.from(chunk)),
|
|
14
|
+
Effect.provide(LoggerServiceLive)
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
describe("uploadMultipartEffect", () => {
|
|
18
|
+
it.effect("emits PartCompleted per chunk and UploadCompleted at end", () =>
|
|
19
|
+
Effect.gen(function* () {
|
|
20
|
+
const etags = ["etag-1", "etag-2", "etag-3"]
|
|
21
|
+
const receivedParts: CompletedPart[] = []
|
|
22
|
+
|
|
23
|
+
const events = yield* run({
|
|
24
|
+
stream: fromBytes(new Uint8Array(30).fill(1)),
|
|
25
|
+
chunkSize: 10,
|
|
26
|
+
uploadPart: (partNumber, chunk) => {
|
|
27
|
+
expect(chunk.length).toBeLessThanOrEqual(10)
|
|
28
|
+
return etags[partNumber - 1]!
|
|
29
|
+
},
|
|
30
|
+
completeUpload: (_uploadId, parts) => { receivedParts.push(...parts) },
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const partEvents = events.filter(e => e._tag === "PartCompleted")
|
|
34
|
+
const completeEvent = events.find(e => e._tag === "UploadCompleted")
|
|
35
|
+
|
|
36
|
+
expect(partEvents).toHaveLength(3)
|
|
37
|
+
expect(partEvents[0]).toMatchObject({ _tag: "PartCompleted", partNumber: 1, etag: "etag-1", bytesUploaded: 10 })
|
|
38
|
+
expect(partEvents[1]).toMatchObject({ _tag: "PartCompleted", partNumber: 2, etag: "etag-2", bytesUploaded: 10 })
|
|
39
|
+
expect(partEvents[2]).toMatchObject({ _tag: "PartCompleted", partNumber: 3, etag: "etag-3", bytesUploaded: 10 })
|
|
40
|
+
expect(completeEvent).toMatchObject({ _tag: "UploadCompleted", totalParts: 3 })
|
|
41
|
+
|
|
42
|
+
expect(receivedParts).toHaveLength(3)
|
|
43
|
+
expect(receivedParts.map(p => p.partNumber).sort()).toEqual([1, 2, 3])
|
|
44
|
+
})
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
it.effect("limits concurrent parts to maxConcurrency", () =>
|
|
48
|
+
Effect.gen(function* () {
|
|
49
|
+
const refConcurrent = yield* Ref.make(0)
|
|
50
|
+
const refMaxObserved = yield* Ref.make(0)
|
|
51
|
+
|
|
52
|
+
const uploadPart = (_partNumber: number, _chunk: Uint8Array): Effect.Effect<string, never> =>
|
|
53
|
+
Effect.gen(function* () {
|
|
54
|
+
yield* Ref.update(refConcurrent, n => n + 1)
|
|
55
|
+
const current = yield* Ref.get(refConcurrent)
|
|
56
|
+
yield* Ref.update(refMaxObserved, max => Math.max(max, current))
|
|
57
|
+
yield* Effect.yieldNow()
|
|
58
|
+
yield* Ref.update(refConcurrent, n => n - 1)
|
|
59
|
+
return `etag-${_partNumber}`
|
|
60
|
+
}) as Effect.Effect<string, never>
|
|
61
|
+
|
|
62
|
+
yield* run({
|
|
63
|
+
stream: fromBytes(new Uint8Array(60).fill(1)),
|
|
64
|
+
chunkSize: 10,
|
|
65
|
+
uploadPart,
|
|
66
|
+
completeUpload: () => {},
|
|
67
|
+
maxConcurrency: 3,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const maxObserved = yield* Ref.get(refMaxObserved)
|
|
71
|
+
expect(maxObserved).toBeLessThanOrEqual(3)
|
|
72
|
+
expect(maxObserved).toBeGreaterThanOrEqual(1)
|
|
73
|
+
})
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
it.effect("retries on failure and emits PartCompleted on eventual success", () =>
|
|
77
|
+
Effect.gen(function* () {
|
|
78
|
+
const refAttempts = yield* Ref.make(0)
|
|
79
|
+
|
|
80
|
+
const events = yield* run({
|
|
81
|
+
stream: fromBytes(new Uint8Array(10).fill(1)),
|
|
82
|
+
chunkSize: 10,
|
|
83
|
+
uploadPart: (_partNumber, _chunk) => Effect.gen(function* () {
|
|
84
|
+
const attempts = yield* Ref.updateAndGet(refAttempts, n => n + 1)
|
|
85
|
+
if (attempts < 2) return yield* Effect.fail(new PartUploadError(1, attempts, new Error("transient")) as never)
|
|
86
|
+
return "etag-ok"
|
|
87
|
+
}) as Effect.Effect<string, PartUploadError>,
|
|
88
|
+
completeUpload: () => {},
|
|
89
|
+
retrySchedule: Schedule.recurs(2),
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const partEvent = events.find(e => e._tag === "PartCompleted")
|
|
93
|
+
expect(partEvent).toMatchObject({ _tag: "PartCompleted", etag: "etag-ok" })
|
|
94
|
+
expect(yield* Ref.get(refAttempts)).toBe(2)
|
|
95
|
+
})
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
it.effect("fails with PartUploadError on single-attempt failure (no retries)", () =>
|
|
99
|
+
Effect.gen(function* () {
|
|
100
|
+
const cause = new Error("immediate failure")
|
|
101
|
+
const result = yield* run({
|
|
102
|
+
stream: fromBytes(new Uint8Array(10).fill(1)),
|
|
103
|
+
chunkSize: 10,
|
|
104
|
+
uploadPart: () => Promise.reject(cause),
|
|
105
|
+
completeUpload: () => {},
|
|
106
|
+
retrySchedule: Schedule.recurs(0),
|
|
107
|
+
}).pipe(Effect.flip)
|
|
108
|
+
|
|
109
|
+
expect(result).toBeInstanceOf(PartUploadError)
|
|
110
|
+
expect((result as PartUploadError).partNumber).toBe(1)
|
|
111
|
+
expect((result as PartUploadError).attempt).toBe(1)
|
|
112
|
+
expect((result as PartUploadError).cause).toBe(cause)
|
|
113
|
+
})
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
it.effect("fails with MaxRetriesExceededError when retries exhausted", () =>
|
|
117
|
+
Effect.gen(function* () {
|
|
118
|
+
const cause = new Error("permanent failure")
|
|
119
|
+
const result = yield* run({
|
|
120
|
+
stream: fromBytes(new Uint8Array(10).fill(1)),
|
|
121
|
+
chunkSize: 10,
|
|
122
|
+
uploadPart: () => Promise.reject(cause),
|
|
123
|
+
completeUpload: () => {},
|
|
124
|
+
retrySchedule: Schedule.recurs(1),
|
|
125
|
+
}).pipe(Effect.flip)
|
|
126
|
+
|
|
127
|
+
expect(result).toBeInstanceOf(MaxRetriesExceededError)
|
|
128
|
+
expect((result as MaxRetriesExceededError).partNumber).toBe(1)
|
|
129
|
+
expect((result as MaxRetriesExceededError).totalAttempts).toBe(2)
|
|
130
|
+
expect((result as MaxRetriesExceededError).cause).toBe(cause)
|
|
131
|
+
})
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
it.effect("wraps completeUpload non-UploadError in CompleteUploadError", () =>
|
|
135
|
+
Effect.gen(function* () {
|
|
136
|
+
const cause = new TypeError("network down")
|
|
137
|
+
const result = yield* run({
|
|
138
|
+
stream: fromBytes(new Uint8Array(10).fill(1)),
|
|
139
|
+
chunkSize: 10,
|
|
140
|
+
uploadPart: () => "etag-1",
|
|
141
|
+
completeUpload: () => { throw cause },
|
|
142
|
+
}).pipe(Effect.flip)
|
|
143
|
+
|
|
144
|
+
expect(result).toBeInstanceOf(CompleteUploadError)
|
|
145
|
+
expect((result as CompleteUploadError)._tag).toBe("CompleteUploadError")
|
|
146
|
+
expect((result as CompleteUploadError).cause).toBe(cause)
|
|
147
|
+
})
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
it.effect("fails with AbortError when signal is aborted", () =>
|
|
151
|
+
Effect.gen(function* () {
|
|
152
|
+
const controller = new AbortController()
|
|
153
|
+
|
|
154
|
+
const uploadPart = () => new Promise<string>((_resolve) => {
|
|
155
|
+
setTimeout(() => controller.abort(), 5)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
const result = yield* run({
|
|
159
|
+
stream: fromBytes(new Uint8Array(10).fill(1)),
|
|
160
|
+
chunkSize: 10,
|
|
161
|
+
uploadPart,
|
|
162
|
+
completeUpload: () => {},
|
|
163
|
+
signal: controller.signal,
|
|
164
|
+
}).pipe(Effect.flip)
|
|
165
|
+
|
|
166
|
+
expect(result).toBeInstanceOf(AbortError)
|
|
167
|
+
})
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
it.effect("default schedule retries 3 total attempts (1 initial + 2 retries)", () =>
|
|
171
|
+
Effect.gen(function* () {
|
|
172
|
+
let attempts = 0
|
|
173
|
+
const cause = new Error("permanent")
|
|
174
|
+
|
|
175
|
+
const fiber = yield* Effect.fork(run({
|
|
176
|
+
stream: fromBytes(new Uint8Array(10).fill(1)),
|
|
177
|
+
chunkSize: 10,
|
|
178
|
+
// No retrySchedule → DEFAULT_RETRY_SCHEDULE = exponential(100ms) + recurs(2) = 3 total
|
|
179
|
+
uploadPart: () => { attempts++; throw cause },
|
|
180
|
+
completeUpload: () => {},
|
|
181
|
+
}).pipe(Effect.flip))
|
|
182
|
+
|
|
183
|
+
// Advance TestClock to let exponential backoff proceed (100ms + 200ms)
|
|
184
|
+
yield* TestClock.adjust("500 millis")
|
|
185
|
+
|
|
186
|
+
const result = yield* Fiber.join(fiber)
|
|
187
|
+
|
|
188
|
+
expect(attempts).toBe(3)
|
|
189
|
+
expect(result).toBeInstanceOf(MaxRetriesExceededError)
|
|
190
|
+
expect((result as MaxRetriesExceededError).totalAttempts).toBe(3)
|
|
191
|
+
expect((result as MaxRetriesExceededError).cause).toBe(cause)
|
|
192
|
+
})
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
it.effect("Schedule.whileInput allows differentiating by original error type", () =>
|
|
196
|
+
Effect.gen(function* () {
|
|
197
|
+
let attempts = 0
|
|
198
|
+
const cause = new PresignedUrlError(new Error("presigned URL expired"))
|
|
199
|
+
|
|
200
|
+
// Schedule that only retries when the cause is NOT a PresignedUrlError
|
|
201
|
+
// uploadPart errors are wrapped in PartUploadError by upload-stream.ts,
|
|
202
|
+
// so err.cause holds the original error thrown by the callback
|
|
203
|
+
const scheduleNoRetryForPresigned = Schedule.whileInput(
|
|
204
|
+
Schedule.recurs(2),
|
|
205
|
+
(err: PartUploadError) => !(err.cause instanceof PresignedUrlError)
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
const result = yield* run({
|
|
209
|
+
stream: fromBytes(new Uint8Array(10).fill(1)),
|
|
210
|
+
chunkSize: 10,
|
|
211
|
+
retrySchedule: scheduleNoRetryForPresigned,
|
|
212
|
+
uploadPart: () => { attempts++; throw cause },
|
|
213
|
+
completeUpload: () => {},
|
|
214
|
+
}).pipe(Effect.flip)
|
|
215
|
+
|
|
216
|
+
// Schedule.whileInput returns false on first attempt → no retries → 1 attempt only
|
|
217
|
+
expect(attempts).toBe(1)
|
|
218
|
+
// 1 attempt only → PartUploadError (not MaxRetriesExceededError — totalAttempts <= 1)
|
|
219
|
+
expect(result).toBeInstanceOf(PartUploadError)
|
|
220
|
+
expect((result as PartUploadError).cause).toBe(cause)
|
|
221
|
+
})
|
|
222
|
+
)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
describe("uploadMultipartEffect with reconcileCompletedParts", () => {
|
|
226
|
+
it.effect("skipped parts emit PartCompleted with reconciled etag, uploadPart not called for them", () =>
|
|
227
|
+
Effect.gen(function* () {
|
|
228
|
+
const uploadedPartNumbers: number[] = []
|
|
229
|
+
|
|
230
|
+
const events = yield* run({
|
|
231
|
+
stream: fromBytes(new Uint8Array(30).fill(1)),
|
|
232
|
+
chunkSize: 10,
|
|
233
|
+
reconcileCompletedParts: () => [
|
|
234
|
+
{ partNumber: 1, etag: "etag-reconciled-1" },
|
|
235
|
+
{ partNumber: 2, etag: "etag-reconciled-2" },
|
|
236
|
+
],
|
|
237
|
+
uploadPart: (n) => { uploadedPartNumbers.push(n); return `etag-fresh-${n}` },
|
|
238
|
+
completeUpload: () => {},
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
expect(uploadedPartNumbers).toEqual([3])
|
|
242
|
+
|
|
243
|
+
const partEvents = events.filter(e => e._tag === "PartCompleted")
|
|
244
|
+
expect(partEvents).toHaveLength(3)
|
|
245
|
+
expect(partEvents.find(e => e._tag === "PartCompleted" && e.partNumber === 1)).toMatchObject({ partNumber: 1, etag: "etag-reconciled-1" })
|
|
246
|
+
expect(partEvents.find(e => e._tag === "PartCompleted" && e.partNumber === 2)).toMatchObject({ partNumber: 2, etag: "etag-reconciled-2" })
|
|
247
|
+
expect(partEvents.find(e => e._tag === "PartCompleted" && e.partNumber === 3)).toMatchObject({ partNumber: 3, etag: "etag-fresh-3" })
|
|
248
|
+
})
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
it.effect("completeUpload receives all parts (reconciled + new)", () =>
|
|
252
|
+
Effect.gen(function* () {
|
|
253
|
+
let receivedParts: CompletedPart[] = []
|
|
254
|
+
|
|
255
|
+
yield* run({
|
|
256
|
+
stream: fromBytes(new Uint8Array(20).fill(1)),
|
|
257
|
+
chunkSize: 10,
|
|
258
|
+
reconcileCompletedParts: () => [{ partNumber: 1, etag: "etag-reconciled-1" }],
|
|
259
|
+
uploadPart: () => "etag-fresh-2",
|
|
260
|
+
completeUpload: (_uploadId, parts) => { receivedParts = [...parts] },
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
expect(receivedParts).toHaveLength(2)
|
|
264
|
+
expect(receivedParts.find(p => p.partNumber === 1)).toMatchObject({ partNumber: 1, etag: "etag-reconciled-1" })
|
|
265
|
+
expect(receivedParts.find(p => p.partNumber === 2)).toMatchObject({ partNumber: 2, etag: "etag-fresh-2" })
|
|
266
|
+
})
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
it.effect("empty reconcile: all parts uploaded normally", () =>
|
|
270
|
+
Effect.gen(function* () {
|
|
271
|
+
const uploadedPartNumbers: number[] = []
|
|
272
|
+
|
|
273
|
+
yield* run({
|
|
274
|
+
stream: fromBytes(new Uint8Array(20).fill(1)),
|
|
275
|
+
chunkSize: 10,
|
|
276
|
+
reconcileCompletedParts: () => [],
|
|
277
|
+
uploadPart: (n) => { uploadedPartNumbers.push(n); return `etag-${n}` },
|
|
278
|
+
completeUpload: () => {},
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
expect(uploadedPartNumbers.sort()).toEqual([1, 2])
|
|
282
|
+
})
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
it.effect("reconcileCompletedParts throws: fails with ReconcileError", () =>
|
|
286
|
+
Effect.gen(function* () {
|
|
287
|
+
const cause = new Error("reconcile failed")
|
|
288
|
+
|
|
289
|
+
const result = yield* run({
|
|
290
|
+
stream: fromBytes(new Uint8Array(10).fill(1)),
|
|
291
|
+
chunkSize: 10,
|
|
292
|
+
reconcileCompletedParts: () => { throw cause },
|
|
293
|
+
uploadPart: () => "etag",
|
|
294
|
+
completeUpload: () => {},
|
|
295
|
+
}).pipe(Effect.flip)
|
|
296
|
+
|
|
297
|
+
expect(result).toBeInstanceOf(ReconcileError)
|
|
298
|
+
expect((result as ReconcileError).cause).toBe(cause)
|
|
299
|
+
})
|
|
300
|
+
)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
describe("uploadMultipartEffect with circuitBreaker", () => {
|
|
304
|
+
it.effect("opens circuit after threshold consecutive failures, emits CircuitOpen event", () =>
|
|
305
|
+
Effect.gen(function* () {
|
|
306
|
+
const received: UploadEvent[] = []
|
|
307
|
+
|
|
308
|
+
// threshold=1: circuit opens on the very first part failure
|
|
309
|
+
// (with unbounded concurrency, only 1 part completes its failure cycle
|
|
310
|
+
// before Stream.mapEffect terminates the stream)
|
|
311
|
+
const stream = uploadMultipartEffect({
|
|
312
|
+
stream: fromBytes(new Uint8Array(30).fill(1)),
|
|
313
|
+
chunkSize: 10,
|
|
314
|
+
maxConcurrency: 1,
|
|
315
|
+
uploadPart: () => Effect.fail(new PartUploadError(0, 1, new Error("network error"))),
|
|
316
|
+
completeUpload: () => {},
|
|
317
|
+
retrySchedule: Schedule.once,
|
|
318
|
+
circuitBreaker: { threshold: 1, cooldown: 5000 },
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
const exit = yield* Stream.runForEach(
|
|
322
|
+
stream,
|
|
323
|
+
(event) => Effect.sync(() => received.push(event))
|
|
324
|
+
).pipe(Effect.exit, Effect.provide(LoggerServiceLive))
|
|
325
|
+
|
|
326
|
+
expect(exit._tag).toBe("Failure")
|
|
327
|
+
|
|
328
|
+
const circuitOpenEvent = received.find(e => e._tag === "CircuitOpen")
|
|
329
|
+
expect(circuitOpenEvent).toBeDefined()
|
|
330
|
+
expect(circuitOpenEvent!.failedParts).toBe(1)
|
|
331
|
+
|
|
332
|
+
const err = Cause.squash((exit as any).cause)
|
|
333
|
+
expect(err).toBeInstanceOf(CircuitOpenError)
|
|
334
|
+
})
|
|
335
|
+
)
|
|
336
|
+
})
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { Cause, Effect, Exit, Option, Ref, Schedule, Stream } from "effect"
|
|
2
|
+
import type { UploadError } from "../errors/upload-error.js"
|
|
3
|
+
import { CircuitOpenError, CompleteUploadError, InitiateUploadError, MaxRetriesExceededError, PartUploadError, ReconcileError } from "../errors/upload-error.js"
|
|
4
|
+
import type { CircuitOpen, PartCompleted, ProgressTick, UploadCompleted, UploadEvent, UploadInitiated } from "../progress/upload-event.js"
|
|
5
|
+
import { LoggerService } from "../services/logger-service.js"
|
|
6
|
+
import { fromAbortSignal } from "../utils/abort-interop.js"
|
|
7
|
+
import { normalizeCallback } from "../utils/normalize-callback.js"
|
|
8
|
+
import { makeCircuitBreaker, type CircuitBreakerConfig } from "./circuit-breaker.js"
|
|
9
|
+
import { chunkStream } from "./chunk-stream.js"
|
|
10
|
+
|
|
11
|
+
export interface CompletedPart {
|
|
12
|
+
readonly partNumber: number
|
|
13
|
+
readonly etag: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface UploadMultipartOptions {
|
|
17
|
+
readonly stream: ReadableStream<Uint8Array>
|
|
18
|
+
readonly chunkSize: number
|
|
19
|
+
readonly uploadPart: (
|
|
20
|
+
partNumber: number,
|
|
21
|
+
chunk: Uint8Array
|
|
22
|
+
) => string | Promise<string> | Effect.Effect<string, UploadError>
|
|
23
|
+
readonly completeUpload: (
|
|
24
|
+
uploadId: string,
|
|
25
|
+
parts: ReadonlyArray<CompletedPart>
|
|
26
|
+
) => void | Promise<void> | Effect.Effect<void, UploadError>
|
|
27
|
+
readonly initiate?: () =>
|
|
28
|
+
| { uploadId: string }
|
|
29
|
+
| Promise<{ uploadId: string }>
|
|
30
|
+
| Effect.Effect<{ uploadId: string }, UploadError>
|
|
31
|
+
readonly reconcileCompletedParts?: () =>
|
|
32
|
+
| ReadonlyArray<CompletedPart>
|
|
33
|
+
| Promise<ReadonlyArray<CompletedPart>>
|
|
34
|
+
| Effect.Effect<ReadonlyArray<CompletedPart>, UploadError>
|
|
35
|
+
readonly maxConcurrency?: number
|
|
36
|
+
readonly signal?: AbortSignal
|
|
37
|
+
readonly retrySchedule?: Schedule.Schedule<unknown, PartUploadError>
|
|
38
|
+
readonly circuitBreaker?: CircuitBreakerConfig
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const DEFAULT_MAX_CONCURRENCY = 4
|
|
42
|
+
|
|
43
|
+
// 3 total attempts: 1 initial + 2 retries, with exponential backoff
|
|
44
|
+
const DEFAULT_RETRY_SCHEDULE = Schedule.exponential("100 millis").pipe(
|
|
45
|
+
Schedule.compose(Schedule.recurs(2))
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
export const uploadMultipartEffect = (
|
|
49
|
+
options: UploadMultipartOptions
|
|
50
|
+
): Stream.Stream<UploadEvent, UploadError, LoggerService> => {
|
|
51
|
+
const {
|
|
52
|
+
stream,
|
|
53
|
+
chunkSize,
|
|
54
|
+
uploadPart,
|
|
55
|
+
completeUpload,
|
|
56
|
+
initiate,
|
|
57
|
+
reconcileCompletedParts,
|
|
58
|
+
maxConcurrency = DEFAULT_MAX_CONCURRENCY,
|
|
59
|
+
signal,
|
|
60
|
+
retrySchedule = DEFAULT_RETRY_SCHEDULE,
|
|
61
|
+
} = options
|
|
62
|
+
|
|
63
|
+
return Stream.unwrap(
|
|
64
|
+
Effect.gen(function* () {
|
|
65
|
+
const logger = yield* LoggerService
|
|
66
|
+
const semaphore = yield* Effect.makeSemaphore(maxConcurrency)
|
|
67
|
+
const refParts = yield* Ref.make<CompletedPart[]>([])
|
|
68
|
+
const refBytesUploaded = yield* Ref.make(0)
|
|
69
|
+
const refUploadId = yield* Ref.make("")
|
|
70
|
+
const breaker = options.circuitBreaker
|
|
71
|
+
? yield* makeCircuitBreaker(options.circuitBreaker)
|
|
72
|
+
: null
|
|
73
|
+
|
|
74
|
+
const reconciledMap: Map<number, string> = reconcileCompletedParts
|
|
75
|
+
? new Map(
|
|
76
|
+
(yield* normalizeCallback(reconcileCompletedParts).pipe(
|
|
77
|
+
Effect.mapError((cause): UploadError => new ReconcileError(cause))
|
|
78
|
+
)).map(p => [p.partNumber, p.etag])
|
|
79
|
+
)
|
|
80
|
+
: new Map()
|
|
81
|
+
|
|
82
|
+
const initiateStream: Stream.Stream<UploadEvent, UploadError, never> = initiate
|
|
83
|
+
? Stream.fromEffect(
|
|
84
|
+
normalizeCallback(initiate).pipe(
|
|
85
|
+
Effect.mapError((cause): UploadError => new InitiateUploadError(cause)),
|
|
86
|
+
Effect.flatMap(({ uploadId }) =>
|
|
87
|
+
Ref.set(refUploadId, uploadId).pipe(
|
|
88
|
+
Effect.as({
|
|
89
|
+
_tag: "UploadInitiated" as const,
|
|
90
|
+
uploadId,
|
|
91
|
+
timestamp: Date.now(),
|
|
92
|
+
} satisfies UploadInitiated)
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
: Stream.empty
|
|
98
|
+
|
|
99
|
+
const makeUploadOne = (
|
|
100
|
+
partNumber: number,
|
|
101
|
+
chunk: Uint8Array
|
|
102
|
+
): Effect.Effect<PartCompleted, UploadError> =>
|
|
103
|
+
Effect.gen(function* () {
|
|
104
|
+
const reconciledEtag = reconciledMap.get(partNumber)
|
|
105
|
+
if (reconciledEtag !== undefined) {
|
|
106
|
+
const event: PartCompleted = {
|
|
107
|
+
_tag: "PartCompleted" as const,
|
|
108
|
+
partNumber,
|
|
109
|
+
etag: reconciledEtag,
|
|
110
|
+
bytesUploaded: chunk.length,
|
|
111
|
+
timestamp: Date.now(),
|
|
112
|
+
}
|
|
113
|
+
yield* Ref.update(refParts, parts => [...parts, { partNumber, etag: reconciledEtag }])
|
|
114
|
+
yield* Effect.sync(() => logger.log("info", `Part ${partNumber} skipped (reconciled)`))
|
|
115
|
+
return event
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const refAttempts = yield* Ref.make(0)
|
|
119
|
+
|
|
120
|
+
const single: Effect.Effect<string, PartUploadError> = Effect.gen(function* () {
|
|
121
|
+
yield* Ref.update(refAttempts, n => n + 1)
|
|
122
|
+
const attempt = yield* Ref.get(refAttempts)
|
|
123
|
+
return yield* normalizeCallback(() => uploadPart(partNumber, chunk)).pipe(
|
|
124
|
+
Effect.mapError(
|
|
125
|
+
(cause): PartUploadError => new PartUploadError(partNumber, attempt, cause)
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
const etag = yield* Effect.retry(single, retrySchedule).pipe(
|
|
131
|
+
Effect.catchAll(err =>
|
|
132
|
+
Effect.gen(function* () {
|
|
133
|
+
const totalAttempts = yield* Ref.get(refAttempts)
|
|
134
|
+
if (totalAttempts <= 1) {
|
|
135
|
+
return yield* Effect.fail(err)
|
|
136
|
+
}
|
|
137
|
+
return yield* Effect.fail(
|
|
138
|
+
new MaxRetriesExceededError(partNumber, totalAttempts, err.cause)
|
|
139
|
+
)
|
|
140
|
+
})
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
const event: PartCompleted = {
|
|
145
|
+
_tag: "PartCompleted" as const,
|
|
146
|
+
partNumber,
|
|
147
|
+
etag,
|
|
148
|
+
bytesUploaded: chunk.length,
|
|
149
|
+
timestamp: Date.now(),
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
yield* Ref.update(refParts, parts => [...parts, { partNumber, etag }])
|
|
153
|
+
yield* Effect.sync(() => logger.log("info", `Part ${partNumber} completed`))
|
|
154
|
+
return event
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const partsStream: Stream.Stream<UploadEvent, UploadError, never> = chunkStream(
|
|
158
|
+
stream,
|
|
159
|
+
chunkSize
|
|
160
|
+
).pipe(
|
|
161
|
+
Stream.mapError((cause): UploadError => new PartUploadError(0, 0, cause)),
|
|
162
|
+
Stream.zipWithIndex,
|
|
163
|
+
Stream.mapEffect(
|
|
164
|
+
([chunk, idx]) => {
|
|
165
|
+
const partNumber = Number(idx) + 1
|
|
166
|
+
|
|
167
|
+
if (!breaker) {
|
|
168
|
+
const partEffect = semaphore.withPermits(1)(
|
|
169
|
+
makeUploadOne(partNumber, chunk)
|
|
170
|
+
)
|
|
171
|
+
return signal ? Effect.raceFirst(partEffect, fromAbortSignal(signal)) : partEffect
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const partEffect = Effect.gen(function* () {
|
|
175
|
+
yield* breaker.guard
|
|
176
|
+
return yield* semaphore.withPermits(1)(
|
|
177
|
+
Effect.gen(function* () {
|
|
178
|
+
const exit = yield* Effect.exit(makeUploadOne(partNumber, chunk))
|
|
179
|
+
if (Exit.isSuccess(exit)) {
|
|
180
|
+
yield* breaker.onSuccess
|
|
181
|
+
return exit.value
|
|
182
|
+
}
|
|
183
|
+
const circuitEvent = yield* breaker.onFailure
|
|
184
|
+
if (circuitEvent !== null) {
|
|
185
|
+
return yield* Effect.fail(new CircuitOpenError(circuitEvent.failedParts))
|
|
186
|
+
}
|
|
187
|
+
return yield* Effect.fail(Cause.squash(exit.cause) as UploadError)
|
|
188
|
+
})
|
|
189
|
+
)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
return signal ? Effect.raceFirst(partEffect, fromAbortSignal(signal)) : partEffect
|
|
193
|
+
},
|
|
194
|
+
{ concurrency: "unbounded" }
|
|
195
|
+
),
|
|
196
|
+
Stream.flatMap(
|
|
197
|
+
(event): Stream.Stream<UploadEvent, UploadError, never> => {
|
|
198
|
+
const tickEffect = Ref.updateAndGet(refBytesUploaded, (n) => n + event.bytesUploaded).pipe(
|
|
199
|
+
Effect.map(
|
|
200
|
+
(total): ProgressTick => ({
|
|
201
|
+
_tag: "ProgressTick" as const,
|
|
202
|
+
bytesUploaded: total,
|
|
203
|
+
totalBytes: Option.none(),
|
|
204
|
+
timestamp: Date.now(),
|
|
205
|
+
})
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
return Stream.concat(Stream.make(event), Stream.fromEffect(tickEffect))
|
|
209
|
+
}
|
|
210
|
+
),
|
|
211
|
+
Stream.catchAll((err: UploadError): Stream.Stream<UploadEvent, UploadError, never> => {
|
|
212
|
+
if (breaker && err._tag === "CircuitOpenError") {
|
|
213
|
+
const event: UploadEvent = {
|
|
214
|
+
_tag: "CircuitOpen",
|
|
215
|
+
failedParts: err.failedParts,
|
|
216
|
+
timestamp: Date.now(),
|
|
217
|
+
}
|
|
218
|
+
return Stream.concat(Stream.succeed(event), Stream.fail(err))
|
|
219
|
+
}
|
|
220
|
+
return Stream.fail(err)
|
|
221
|
+
})
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
const finalEffect: Effect.Effect<UploadEvent, UploadError, never> = Effect.gen(
|
|
225
|
+
function* () {
|
|
226
|
+
const uploadId = yield* Ref.get(refUploadId)
|
|
227
|
+
const parts = yield* Ref.get(refParts)
|
|
228
|
+
yield* normalizeCallback(() => completeUpload(uploadId, parts)).pipe(
|
|
229
|
+
Effect.mapError(
|
|
230
|
+
(cause): UploadError => new CompleteUploadError(cause)
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
yield* Effect.sync(() => logger.log("info", "Multipart upload completed"))
|
|
234
|
+
return {
|
|
235
|
+
_tag: "UploadCompleted" as const,
|
|
236
|
+
uploadId,
|
|
237
|
+
totalParts: parts.length,
|
|
238
|
+
timestamp: Date.now(),
|
|
239
|
+
} satisfies UploadCompleted
|
|
240
|
+
}
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
return Stream.concat(initiateStream, partsStream.pipe(Stream.concat(Stream.fromEffect(finalEffect))))
|
|
244
|
+
})
|
|
245
|
+
)
|
|
246
|
+
}
|