refactor: 프론트엔드 리펙토링
This commit is contained in:
@@ -32,319 +32,19 @@ import type {
|
||||
} from "../../src/adapters/browser-transfer/resumable-upload/upload-cancellation-channel.ts";
|
||||
import type { UploadMutationLock } from "../../src/adapters/browser-transfer/resumable-upload/upload-mutation-lock.ts";
|
||||
|
||||
type TestCapability = Readonly<{ id: string }>;
|
||||
|
||||
const activeSignal = new AbortController().signal;
|
||||
const noContentionLock: UploadMutationLock = Object.freeze({
|
||||
async run<Value>(
|
||||
_uploadKey: string,
|
||||
_signal: AbortSignal,
|
||||
task: () => Promise<Value>,
|
||||
): Promise<Value> {
|
||||
return await task();
|
||||
},
|
||||
});
|
||||
|
||||
function createSerialMutationLock(): UploadMutationLock {
|
||||
let tail = Promise.resolve();
|
||||
return Object.freeze({
|
||||
run<Value>(
|
||||
_uploadKey: string,
|
||||
signal: AbortSignal,
|
||||
task: () => Promise<Value>,
|
||||
): Promise<Value> {
|
||||
const result = tail.then(async () => {
|
||||
if (signal.aborted) {
|
||||
throw new DOMException(
|
||||
"The operation was aborted.",
|
||||
"AbortError",
|
||||
);
|
||||
}
|
||||
return await task();
|
||||
});
|
||||
tail = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return result;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createMemoryCancellationPair(): readonly [
|
||||
UploadCancellationChannel,
|
||||
UploadCancellationChannel,
|
||||
] {
|
||||
const listeners = [
|
||||
new Set<UploadCancellationListener>(),
|
||||
new Set<UploadCancellationListener>(),
|
||||
] as const;
|
||||
const channels = listeners.map((ownListeners, ownIndex) => {
|
||||
let closed = false;
|
||||
return Object.freeze({
|
||||
publish(uploadKey: string) {
|
||||
if (closed) return false;
|
||||
for (const [index, peerListeners] of listeners.entries()) {
|
||||
if (index === ownIndex) continue;
|
||||
for (const listener of [...peerListeners]) {
|
||||
listener(uploadKey);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
subscribe(listener: UploadCancellationListener) {
|
||||
if (closed) throw new TypeError("closed");
|
||||
ownListeners.add(listener);
|
||||
return () => ownListeners.delete(listener);
|
||||
},
|
||||
close() {
|
||||
closed = true;
|
||||
ownListeners.clear();
|
||||
},
|
||||
});
|
||||
});
|
||||
return channels as unknown as readonly [
|
||||
UploadCancellationChannel,
|
||||
UploadCancellationChannel,
|
||||
];
|
||||
}
|
||||
|
||||
class MemoryCheckpointStore implements ResumableUploadCheckpointStore {
|
||||
readonly rows = new Map<string, ResumableUploadCheckpoint>();
|
||||
closed = false;
|
||||
|
||||
async read(
|
||||
uploadKey: string,
|
||||
): Promise<BrowserDataResult<ResumableUploadCheckpoint | null>> {
|
||||
return browserDataSuccess(
|
||||
structuredClone(this.rows.get(uploadKey) ?? null),
|
||||
);
|
||||
}
|
||||
|
||||
async compareAndSwap(
|
||||
input: Parameters<
|
||||
ResumableUploadCheckpointStore["compareAndSwap"]
|
||||
>[0],
|
||||
): Promise<BrowserDataResult<ResumableUploadCheckpoint>> {
|
||||
const current = this.rows.get(input.checkpoint.uploadKey);
|
||||
if (
|
||||
(input.expectedRevision === null && current) ||
|
||||
(input.expectedRevision !== null &&
|
||||
current?.revision !== input.expectedRevision)
|
||||
) {
|
||||
return browserDataFailure("CONFLICT", "UPLOAD_RECONCILE", {
|
||||
recovery: "RECONCILE",
|
||||
});
|
||||
}
|
||||
const snapshot = structuredClone(input.checkpoint);
|
||||
this.rows.set(snapshot.uploadKey, snapshot);
|
||||
return browserDataSuccess(snapshot);
|
||||
}
|
||||
|
||||
async remove(
|
||||
input: Parameters<ResumableUploadCheckpointStore["remove"]>[0],
|
||||
): Promise<BrowserDataResult<void>> {
|
||||
const current = this.rows.get(input.uploadKey);
|
||||
if (current?.revision !== input.expectedRevision) {
|
||||
return browserDataFailure("CONFLICT", "UPLOAD_RECONCILE", {
|
||||
recovery: "RECONCILE",
|
||||
});
|
||||
}
|
||||
this.rows.delete(input.uploadKey);
|
||||
return browserDataSuccess(undefined);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
function rangeSource(bytes: Uint8Array): ResumableUploadSource {
|
||||
return Object.freeze({
|
||||
kind: "RANGE_READER" as const,
|
||||
reader: Object.freeze({
|
||||
byteLength: bytes.byteLength,
|
||||
async readRange(input: Readonly<{
|
||||
offset: number;
|
||||
length: number;
|
||||
signal: AbortSignal;
|
||||
}>) {
|
||||
if (input.signal.aborted) {
|
||||
return browserDataFailure("ABORTED", "FILE_READ");
|
||||
}
|
||||
return browserDataSuccess(
|
||||
bytes.slice(input.offset, input.offset + input.length),
|
||||
);
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function byteStreamSource(bytes: Uint8Array): ResumableUploadSource {
|
||||
return Object.freeze({
|
||||
kind: "FILE_BYTE_SOURCE" as const,
|
||||
bytes: Object.freeze({
|
||||
byteLength: bytes.byteLength,
|
||||
async *stream(signal: AbortSignal) {
|
||||
if (signal.aborted) {
|
||||
yield browserDataFailure("ABORTED", "FILE_READ");
|
||||
return;
|
||||
}
|
||||
yield browserDataSuccess(bytes.slice(0, 3));
|
||||
yield browserDataSuccess(bytes.slice(3));
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function runtimePolicy(
|
||||
overrides: Partial<
|
||||
Parameters<typeof resolveResumableUploadRuntimePolicy>[0]
|
||||
> = {},
|
||||
) {
|
||||
return {
|
||||
partSizeBytes: 4,
|
||||
maxFileBytes: 100,
|
||||
maxPartCount: 25,
|
||||
maxConcurrency: 3,
|
||||
maxInFlightBytes: 48,
|
||||
partBufferCopyFactor: 4,
|
||||
maxSourceChunkBytes: 8,
|
||||
maxRetries: 2,
|
||||
retryBaseDelayMs: 1,
|
||||
retryMaxDelayMs: 10,
|
||||
maxRetryAfterMs: 100,
|
||||
capabilityRefreshSkewMs: 5,
|
||||
maxSessionLifetimeMs: 10_000,
|
||||
providerAttemptTimeoutMs: 100,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
type ControlHarness = Readonly<{
|
||||
control: ResumableUploadControlPlane<TestCapability>;
|
||||
accepted: Map<number, UploadPartReceipt>;
|
||||
issued: ReturnType<typeof vi.fn>;
|
||||
completedParts: UploadPartReceipt[][];
|
||||
getSession(): UploadSession | null;
|
||||
}>;
|
||||
|
||||
function createControlHarness(options: Readonly<{
|
||||
now?: number;
|
||||
serverMaxConcurrency?: number;
|
||||
sessionId?: (createIndex: number) => string;
|
||||
statusParts?: (
|
||||
session: UploadSession,
|
||||
accepted: Map<number, UploadPartReceipt>,
|
||||
) => readonly UploadPartReceipt[];
|
||||
issueCapability?: (
|
||||
input: Parameters<
|
||||
ResumableUploadControlPlane<TestCapability>["issuePartCapability"]
|
||||
>[0],
|
||||
callIndex: number,
|
||||
) => UploadProviderResult<Readonly<{
|
||||
capability: TestCapability;
|
||||
uploadBindingSha256: string;
|
||||
expiresAtEpochMs: number;
|
||||
}>>;
|
||||
}> = {}): ControlHarness {
|
||||
const now = options.now ?? 1_000;
|
||||
const accepted = new Map<number, UploadPartReceipt>();
|
||||
const completedParts: UploadPartReceipt[][] = [];
|
||||
let session: UploadSession | null = null;
|
||||
let createCount = 0;
|
||||
let issueCount = 0;
|
||||
const issued = vi.fn();
|
||||
const control: ResumableUploadControlPlane<TestCapability> = {
|
||||
async createSession(input) {
|
||||
createCount += 1;
|
||||
session = Object.freeze({
|
||||
protocol: RESUMABLE_UPLOAD_PROTOCOL,
|
||||
sessionId:
|
||||
options.sessionId?.(createCount) ?? "session_01",
|
||||
requestBindingSha256: input.requestBindingSha256,
|
||||
fingerprint: input.fingerprint,
|
||||
partSizeBytes: input.requestedPartSizeBytes,
|
||||
partCount: input.fingerprint.partCount,
|
||||
maxConcurrency: options.serverMaxConcurrency ?? 2,
|
||||
expiresAtEpochMs: now + 5_000,
|
||||
});
|
||||
return browserDataSuccess(session);
|
||||
},
|
||||
async getStatus() {
|
||||
if (!session) {
|
||||
return browserDataFailure("NOT_FOUND", "UPLOAD_RECONCILE");
|
||||
}
|
||||
const parts =
|
||||
options.statusParts?.(session, accepted) ??
|
||||
[...accepted.values()].sort(
|
||||
(left, right) => left.partNumber - right.partNumber,
|
||||
);
|
||||
return browserDataSuccess({
|
||||
state: "ACTIVE",
|
||||
session,
|
||||
acceptedParts: parts,
|
||||
});
|
||||
},
|
||||
async issuePartCapability(input) {
|
||||
issueCount += 1;
|
||||
issued(input);
|
||||
return (
|
||||
options.issueCapability?.(input, issueCount) ??
|
||||
browserDataSuccess({
|
||||
capability: Object.freeze({ id: `cap-${issueCount}` }),
|
||||
uploadBindingSha256: input.uploadBindingSha256,
|
||||
expiresAtEpochMs: now + 4_000,
|
||||
})
|
||||
);
|
||||
},
|
||||
async complete(input) {
|
||||
completedParts.push([...input.orderedParts]);
|
||||
return browserDataSuccess({
|
||||
state: "QUARANTINED",
|
||||
protocol: RESUMABLE_UPLOAD_PROTOCOL,
|
||||
sessionId: input.sessionId,
|
||||
requestBindingSha256: input.requestBindingSha256,
|
||||
fingerprint: input.fingerprint,
|
||||
resourceId: "resource_01",
|
||||
});
|
||||
},
|
||||
async abort() {
|
||||
return browserDataSuccess({ state: "ABORTED" });
|
||||
},
|
||||
};
|
||||
return {
|
||||
control,
|
||||
accepted,
|
||||
issued,
|
||||
completedParts,
|
||||
getSession: () => session,
|
||||
};
|
||||
}
|
||||
|
||||
function executorFor(
|
||||
harness: ControlHarness,
|
||||
options: Readonly<{
|
||||
delay?: () => Promise<void>;
|
||||
onActive?: (active: number) => void;
|
||||
}> = {},
|
||||
): UploadPartExecutor<TestCapability> {
|
||||
let active = 0;
|
||||
return {
|
||||
async uploadPart(input) {
|
||||
active += 1;
|
||||
options.onActive?.(active);
|
||||
await options.delay?.();
|
||||
active -= 1;
|
||||
const receipt = Object.freeze({
|
||||
...input.part,
|
||||
receiptToken: `etag-part-${input.part.partNumber}`,
|
||||
});
|
||||
harness.accepted.set(input.part.partNumber, receipt);
|
||||
return browserDataSuccess(receipt);
|
||||
},
|
||||
};
|
||||
}
|
||||
import {
|
||||
activeSignal,
|
||||
byteStreamSource,
|
||||
createControlHarness,
|
||||
createMemoryCancellationPair,
|
||||
createSerialMutationLock,
|
||||
executorFor,
|
||||
MemoryCheckpointStore,
|
||||
noContentionLock,
|
||||
rangeSource,
|
||||
runtimePolicy,
|
||||
type TestCapability,
|
||||
} from "./resumable-upload-runtime-fixture.ts";
|
||||
|
||||
describe("production resumable upload runtime", () => {
|
||||
it("disposes through one drain that proves quiescence", async () => {
|
||||
@@ -1280,177 +980,3 @@ describe("production resumable upload runtime", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
/**
|
||||
* TR-RR-06. A non-cooperative mutation lock or provider must not make teardown
|
||||
* unbounded: `dispose()` bounds its drain and reports honestly when the runtime
|
||||
* is still CLOSING, and an abort is admitted physical work it cannot step over.
|
||||
*/
|
||||
describe("TR-RR-06 bounded resumable teardown", () => {
|
||||
it("reports an unproved drain instead of waiting forever", async () => {
|
||||
const checkpoints = new MemoryCheckpointStore();
|
||||
const harness = createControlHarness();
|
||||
const runtime = createResumableUploadRuntime({
|
||||
controlPlane: harness.control,
|
||||
partExecutor: executorFor(harness, { delay: async () => {} }),
|
||||
checkpoints,
|
||||
// A lock that never grants: dispose must still be bounded.
|
||||
mutationLock: Object.freeze({
|
||||
async run<Value>(): Promise<Value> {
|
||||
return await new Promise<never>(() => {});
|
||||
},
|
||||
}),
|
||||
crypto,
|
||||
policy: runtimePolicy({ cleanupDeadlineMs: 20 }),
|
||||
now: () => 1_000,
|
||||
random: () => 0,
|
||||
sleep: async () => {},
|
||||
});
|
||||
|
||||
void runtime.upload({
|
||||
uploadKey: "upload_key_hung",
|
||||
purpose: "attachment",
|
||||
mediaType: "application/octet-stream",
|
||||
source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])),
|
||||
signal: activeSignal,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
const disposed = await runtime.dispose();
|
||||
expect(disposed.ok).toBe(false);
|
||||
// Still CLOSING: physical work the caller must not treat as finished.
|
||||
expect(runtime.lifecycle()).toBe("CLOSING");
|
||||
expect(checkpoints.closed).toBe(false);
|
||||
});
|
||||
|
||||
it("closes once every admitted operation settles", async () => {
|
||||
const checkpoints = new MemoryCheckpointStore();
|
||||
const harness = createControlHarness();
|
||||
const runtime = createResumableUploadRuntime({
|
||||
controlPlane: harness.control,
|
||||
partExecutor: executorFor(harness, { delay: async () => {} }),
|
||||
checkpoints,
|
||||
mutationLock: noContentionLock,
|
||||
crypto,
|
||||
policy: runtimePolicy({ cleanupDeadlineMs: 200 }),
|
||||
now: () => 1_000,
|
||||
random: () => 0,
|
||||
sleep: async () => {},
|
||||
});
|
||||
const disposed = await runtime.dispose();
|
||||
expect(disposed).toMatchObject({ ok: true });
|
||||
expect(runtime.lifecycle()).toBe("CLOSED");
|
||||
expect(checkpoints.closed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* TR-04. `dispose()` proved quiescence from the wrapper registry only. A
|
||||
* provider that ignored its attempt deadline let the wrapper settle first and
|
||||
* leave the set empty, so teardown reported a drained runtime — and closed the
|
||||
* checkpoint store — while the provider was still running.
|
||||
*/
|
||||
describe("TR-04 teardown waits for raw provider work, not only its wrapper", () => {
|
||||
it("refuses to report a drained runtime while a provider is still running", async () => {
|
||||
const harness = createControlHarness();
|
||||
const checkpoints = new MemoryCheckpointStore();
|
||||
const closeStore = vi.spyOn(checkpoints, "close");
|
||||
let releaseProvider: (() => void) | undefined;
|
||||
// Ignores the attempt signal entirely and outlives its own deadline.
|
||||
harness.control.createSession = () =>
|
||||
new Promise((resolve) => {
|
||||
releaseProvider = () =>
|
||||
resolve(
|
||||
browserDataFailure("UNAVAILABLE", "UPLOAD_SESSION", {
|
||||
retryable: true,
|
||||
recovery: "RESUME",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const runtime = createResumableUploadRuntime({
|
||||
controlPlane: harness.control,
|
||||
partExecutor: executorFor(harness),
|
||||
checkpoints,
|
||||
mutationLock: noContentionLock,
|
||||
crypto,
|
||||
policy: runtimePolicy({
|
||||
providerAttemptTimeoutMs: 5,
|
||||
cleanupDeadlineMs: 25,
|
||||
maxRetries: 0,
|
||||
}),
|
||||
now: () => 1_000,
|
||||
random: () => 0,
|
||||
sleep: async () => {},
|
||||
});
|
||||
|
||||
const uploading = runtime.upload({
|
||||
uploadKey: "upload_key_raw",
|
||||
purpose: "attachment",
|
||||
mediaType: "application/octet-stream",
|
||||
source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])),
|
||||
signal: activeSignal,
|
||||
});
|
||||
await vi.waitFor(() => expect(releaseProvider).toBeDefined());
|
||||
// The wrapper has already given up on the attempt.
|
||||
await uploading;
|
||||
|
||||
const disposed = await runtime.dispose();
|
||||
expect(disposed.ok).toBe(false);
|
||||
if (!disposed.ok) {
|
||||
expect(disposed.error.code).toBe("UNAVAILABLE");
|
||||
expect(disposed.error.recovery).toBe("RESUME");
|
||||
}
|
||||
// The store stays open while something could still write a checkpoint.
|
||||
expect(closeStore).not.toHaveBeenCalled();
|
||||
|
||||
releaseProvider?.();
|
||||
});
|
||||
|
||||
it("reports a drained runtime once the raw provider settles", async () => {
|
||||
const harness = createControlHarness();
|
||||
const checkpoints = new MemoryCheckpointStore();
|
||||
let releaseProvider: (() => void) | undefined;
|
||||
harness.control.createSession = () =>
|
||||
new Promise((resolve) => {
|
||||
releaseProvider = () =>
|
||||
resolve(
|
||||
browserDataFailure("UNAVAILABLE", "UPLOAD_SESSION", {
|
||||
retryable: true,
|
||||
recovery: "RESUME",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const runtime = createResumableUploadRuntime({
|
||||
controlPlane: harness.control,
|
||||
partExecutor: executorFor(harness),
|
||||
checkpoints,
|
||||
mutationLock: noContentionLock,
|
||||
crypto,
|
||||
policy: runtimePolicy({
|
||||
providerAttemptTimeoutMs: 5,
|
||||
cleanupDeadlineMs: 1_000,
|
||||
maxRetries: 0,
|
||||
}),
|
||||
now: () => 1_000,
|
||||
random: () => 0,
|
||||
sleep: async () => {},
|
||||
});
|
||||
|
||||
const uploading = runtime.upload({
|
||||
uploadKey: "upload_key_raw_2",
|
||||
purpose: "attachment",
|
||||
mediaType: "application/octet-stream",
|
||||
source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])),
|
||||
signal: activeSignal,
|
||||
});
|
||||
await vi.waitFor(() => expect(releaseProvider).toBeDefined());
|
||||
await uploading;
|
||||
|
||||
const disposing = runtime.dispose();
|
||||
releaseProvider?.();
|
||||
await expect(disposing).resolves.toMatchObject({ ok: true });
|
||||
expect(runtime.lifecycle()).toBe("CLOSED");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user