@flowscripter/pluggable-io-framework 1.2.6 → 2.0.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/README.md +47 -178
- package/dist/index.d.ts +11 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -4
- package/dist/src/concurrency/AsyncChannel.d.ts +9 -0
- package/dist/src/concurrency/AsyncChannel.d.ts.map +1 -0
- package/dist/src/concurrency/AsyncChannel.js +48 -0
- package/dist/src/{ConcurrencyLimiter.d.ts → concurrency/ConcurrencyLimiter.d.ts} +1 -12
- package/dist/src/concurrency/ConcurrencyLimiter.d.ts.map +1 -0
- package/dist/src/concurrency/ConcurrencyLimiter.js +64 -0
- package/dist/src/concurrency/mapAsyncIterableConcurrently.d.ts +13 -0
- package/dist/src/concurrency/mapAsyncIterableConcurrently.d.ts.map +1 -0
- package/dist/src/concurrency/mapAsyncIterableConcurrently.js +61 -0
- package/dist/src/decorators/locallyCached.d.ts +9 -10
- package/dist/src/decorators/locallyCached.d.ts.map +1 -1
- package/dist/src/decorators/locallyCached.js +23 -16
- package/dist/src/decorators/seekable.d.ts +4 -4
- package/dist/src/decorators/seekable.d.ts.map +1 -1
- package/dist/src/decorators/seekable.js +2 -2
- package/dist/src/registry/ProviderRegistry.d.ts +52 -0
- package/dist/src/registry/ProviderRegistry.d.ts.map +1 -0
- package/dist/src/registry/ProviderRegistry.js +148 -0
- package/dist/src/registry/detectProtocol.d.ts +8 -0
- package/dist/src/registry/detectProtocol.d.ts.map +1 -0
- package/dist/src/registry/detectProtocol.js +15 -0
- package/dist/src/registry/negotiateTransfer.d.ts +29 -0
- package/dist/src/registry/negotiateTransfer.d.ts.map +1 -0
- package/dist/src/registry/negotiateTransfer.js +69 -0
- package/dist/src/retry/RetryOptions.d.ts +19 -0
- package/dist/src/retry/RetryOptions.d.ts.map +1 -0
- package/dist/src/retry/RetryOptions.js +9 -0
- package/dist/src/{withRetry.d.ts → retry/withRetry.d.ts} +1 -5
- package/dist/src/retry/withRetry.d.ts.map +1 -0
- package/dist/src/{withRetry.js → retry/withRetry.js} +2 -5
- package/dist/src/transfer/EntryContext.d.ts +16 -0
- package/dist/src/transfer/EntryContext.d.ts.map +1 -0
- package/dist/src/transfer/EntryContext.js +1 -0
- package/dist/src/transfer/TransferOptions.d.ts +45 -0
- package/dist/src/transfer/TransferOptions.d.ts.map +1 -0
- package/dist/src/transfer/TransferOptions.js +1 -0
- package/dist/src/transfer/TransferResult.d.ts +9 -0
- package/dist/src/transfer/TransferResult.d.ts.map +1 -0
- package/dist/src/transfer/TransferResult.js +1 -0
- package/dist/src/transfer/aggregateResults.d.ts +4 -0
- package/dist/src/transfer/aggregateResults.d.ts.map +1 -0
- package/dist/src/transfer/aggregateResults.js +9 -0
- package/dist/src/transfer/copyMove.d.ts +23 -0
- package/dist/src/transfer/copyMove.d.ts.map +1 -0
- package/dist/src/transfer/copyMove.js +68 -0
- package/dist/src/transfer/leaseTransfer.d.ts +14 -0
- package/dist/src/transfer/leaseTransfer.d.ts.map +1 -0
- package/dist/src/transfer/leaseTransfer.js +74 -0
- package/dist/src/transfer/multipartTransfer.d.ts +23 -0
- package/dist/src/transfer/multipartTransfer.d.ts.map +1 -0
- package/dist/src/transfer/multipartTransfer.js +94 -0
- package/dist/src/transfer/negotiatePartSize.d.ts +10 -0
- package/dist/src/transfer/negotiatePartSize.d.ts.map +1 -0
- package/dist/src/transfer/negotiatePartSize.js +27 -0
- package/dist/src/transfer/patternTransfer.d.ts +12 -0
- package/dist/src/transfer/patternTransfer.d.ts.map +1 -0
- package/dist/src/transfer/patternTransfer.js +61 -0
- package/dist/src/transfer/rangeReadableMultipartReader.d.ts +8 -0
- package/dist/src/transfer/rangeReadableMultipartReader.d.ts.map +1 -0
- package/dist/src/transfer/rangeReadableMultipartReader.js +19 -0
- package/dist/src/transfer/recursiveTransfer.d.ts +13 -0
- package/dist/src/transfer/recursiveTransfer.d.ts.map +1 -0
- package/dist/src/transfer/recursiveTransfer.js +112 -0
- package/dist/src/transfer/streamTransfer.d.ts +32 -0
- package/dist/src/transfer/streamTransfer.d.ts.map +1 -0
- package/dist/src/transfer/streamTransfer.js +186 -0
- package/dist/src/transfer/transferEntry.d.ts +25 -0
- package/dist/src/transfer/transferEntry.d.ts.map +1 -0
- package/dist/src/transfer/transferEntry.js +136 -0
- package/dist/src/util/abort.d.ts +12 -0
- package/dist/src/util/abort.d.ts.map +1 -0
- package/dist/src/util/abort.js +39 -0
- package/dist/src/util/applyPayloadConverter.d.ts +9 -0
- package/dist/src/util/applyPayloadConverter.d.ts.map +1 -0
- package/dist/src/util/applyPayloadConverter.js +26 -0
- package/dist/src/util/describePath.d.ts +6 -0
- package/dist/src/util/describePath.d.ts.map +1 -0
- package/dist/src/util/describePath.js +9 -0
- package/dist/src/util/globToRegex.d.ts +8 -0
- package/dist/src/util/globToRegex.d.ts.map +1 -0
- package/dist/src/util/globToRegex.js +35 -0
- package/dist/src/util/itemLength.d.ts +4 -0
- package/dist/src/util/itemLength.d.ts.map +1 -0
- package/dist/src/util/itemLength.js +5 -0
- package/dist/src/util/keys.d.ts +8 -0
- package/dist/src/util/keys.d.ts.map +1 -0
- package/dist/src/util/keys.js +19 -0
- package/dist/src/util/withParentOperationId.d.ts +4 -0
- package/dist/src/util/withParentOperationId.d.ts.map +1 -0
- package/dist/src/util/withParentOperationId.js +12 -0
- package/index.ts +11 -4
- package/package.json +6 -5
- package/src/concurrency/AsyncChannel.ts +52 -0
- package/src/concurrency/ConcurrencyLimiter.ts +72 -0
- package/src/concurrency/mapAsyncIterableConcurrently.ts +66 -0
- package/src/decorators/locallyCached.ts +30 -22
- package/src/decorators/seekable.ts +7 -7
- package/src/registry/ProviderRegistry.ts +241 -0
- package/src/registry/detectProtocol.ts +16 -0
- package/src/registry/negotiateTransfer.ts +121 -0
- package/src/retry/RetryOptions.ts +26 -0
- package/src/{withRetry.ts → retry/withRetry.ts} +2 -12
- package/src/transfer/EntryContext.ts +17 -0
- package/src/transfer/TransferOptions.ts +45 -0
- package/src/transfer/TransferResult.ts +8 -0
- package/src/transfer/aggregateResults.ts +15 -0
- package/src/transfer/copyMove.ts +113 -0
- package/src/transfer/leaseTransfer.ts +93 -0
- package/src/transfer/multipartTransfer.ts +119 -0
- package/src/transfer/negotiatePartSize.ts +40 -0
- package/src/transfer/patternTransfer.ts +84 -0
- package/src/transfer/rangeReadableMultipartReader.ts +30 -0
- package/src/transfer/recursiveTransfer.ts +147 -0
- package/src/transfer/streamTransfer.ts +214 -0
- package/src/transfer/transferEntry.ts +185 -0
- package/src/util/abort.ts +42 -0
- package/src/util/applyPayloadConverter.ts +37 -0
- package/src/util/describePath.ts +12 -0
- package/src/util/globToRegex.ts +32 -0
- package/src/util/itemLength.ts +6 -0
- package/src/util/keys.ts +21 -0
- package/src/util/withParentOperationId.ts +17 -0
- package/dist/src/ConcurrencyLimiter.d.ts.map +0 -1
- package/dist/src/ConcurrencyLimiter.js +0 -161
- package/dist/src/ProviderRegistry.d.ts +0 -14
- package/dist/src/ProviderRegistry.d.ts.map +0 -1
- package/dist/src/ProviderRegistry.js +0 -22
- package/dist/src/chunkLength.d.ts +0 -3
- package/dist/src/chunkLength.d.ts.map +0 -1
- package/dist/src/chunkLength.js +0 -4
- package/dist/src/copyMove.d.ts +0 -46
- package/dist/src/copyMove.d.ts.map +0 -1
- package/dist/src/copyMove.js +0 -280
- package/dist/src/withRetry.d.ts.map +0 -1
- package/src/ConcurrencyLimiter.ts +0 -178
- package/src/ProviderRegistry.ts +0 -32
- package/src/chunkLength.ts +0 -5
- package/src/copyMove.ts +0 -458
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BYTES_PAYLOAD_TYPE,
|
|
3
|
+
type IOProviderFactory,
|
|
4
|
+
type PayloadConverter,
|
|
5
|
+
PayloadKind,
|
|
6
|
+
} from "@flowscripter/pluggable-io-framework-api";
|
|
7
|
+
import { describeConverter, describeKind } from "../util/describePath.ts";
|
|
8
|
+
|
|
9
|
+
/** One (factory, kind, domain) choice for one side of a transfer. */
|
|
10
|
+
export interface Endpoint {
|
|
11
|
+
readonly factory: IOProviderFactory;
|
|
12
|
+
readonly kind: PayloadKind;
|
|
13
|
+
readonly domain?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Negotiation {
|
|
17
|
+
readonly source: Endpoint;
|
|
18
|
+
readonly dest: Endpoint;
|
|
19
|
+
readonly converter?: PayloadConverter;
|
|
20
|
+
readonly path: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_NATIVE_DOMAINS: readonly string[] = ["host"];
|
|
24
|
+
|
|
25
|
+
/** Every (kind, domain) a factory offers, in its preference order. */
|
|
26
|
+
export function endpointsOf(factory: IOProviderFactory): Endpoint[] {
|
|
27
|
+
if (factory.kind === PayloadKind.Js) {
|
|
28
|
+
return [{ factory, kind: factory.kind }];
|
|
29
|
+
}
|
|
30
|
+
return (factory.domains ?? DEFAULT_NATIVE_DOMAINS).map((domain) => ({
|
|
31
|
+
factory,
|
|
32
|
+
kind: factory.kind,
|
|
33
|
+
domain,
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sharesPayloadType(source: Endpoint, dest: Endpoint): boolean {
|
|
38
|
+
const reads = source.factory.readPayloadTypes ?? [BYTES_PAYLOAD_TYPE];
|
|
39
|
+
const writes = dest.factory.writePayloadTypes ?? [BYTES_PAYLOAD_TYPE];
|
|
40
|
+
return reads.some((payloadType) => writes.includes(payloadType));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function matches(end: PayloadConverter["from"], endpoint: Endpoint): boolean {
|
|
44
|
+
return end.kind === endpoint.kind && (end.domain === undefined || end.domain === endpoint.domain);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function kindOrder(kind: PayloadKind): number {
|
|
48
|
+
return kind === PayloadKind.Js ? 0 : 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function describeSide(protocol: string, endpoints: readonly Endpoint[]): string {
|
|
52
|
+
const kinds = endpoints.map((endpoint) => describeKind(endpoint.kind, endpoint.domain));
|
|
53
|
+
return `"${protocol}" supports [${kinds.join(", ")}]`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Chooses the (kind, domain) for each side of a transfer between factories
|
|
58
|
+
* of two protocols, considering only pairs that share an exact payload type:
|
|
59
|
+
* 1. a common (kind, domain), preferring `js`, then the source factory's
|
|
60
|
+
* domain order;
|
|
61
|
+
* 2. otherwise the cheapest single converter from a source pair to a
|
|
62
|
+
* destination pair;
|
|
63
|
+
* 3. otherwise an error listing what each side supports.
|
|
64
|
+
* An explicit `kind` restricts both sides to it, and allows only converters
|
|
65
|
+
* that keep it.
|
|
66
|
+
*/
|
|
67
|
+
export function negotiateTransfer(
|
|
68
|
+
sourceProtocol: string,
|
|
69
|
+
sourceFactories: readonly IOProviderFactory[],
|
|
70
|
+
destProtocol: string,
|
|
71
|
+
destFactories: readonly IOProviderFactory[],
|
|
72
|
+
converters: readonly PayloadConverter[],
|
|
73
|
+
kind?: PayloadKind,
|
|
74
|
+
): Negotiation {
|
|
75
|
+
const restrict = (factories: readonly IOProviderFactory[]) =>
|
|
76
|
+
factories
|
|
77
|
+
.filter((factory) => kind === undefined || factory.kind === kind)
|
|
78
|
+
.flatMap(endpointsOf)
|
|
79
|
+
.sort((a, b) => kindOrder(a.kind) - kindOrder(b.kind));
|
|
80
|
+
const sources = restrict(sourceFactories);
|
|
81
|
+
const dests = restrict(destFactories);
|
|
82
|
+
const describe = (source: Endpoint, dest: Endpoint, converter?: PayloadConverter) =>
|
|
83
|
+
`${sourceProtocol}/${describeKind(source.kind, source.domain)} -> ${destProtocol}/${describeKind(dest.kind, dest.domain)}${converter ? describeConverter(converter) : ""}`;
|
|
84
|
+
|
|
85
|
+
const pairs = sources.flatMap((source) =>
|
|
86
|
+
dests.filter((dest) => sharesPayloadType(source, dest)).map((dest) => ({ source, dest })),
|
|
87
|
+
);
|
|
88
|
+
if (pairs.length === 0 && sources.length > 0 && dests.length > 0) {
|
|
89
|
+
const reads = sourceFactories.flatMap((f) => f.readPayloadTypes ?? [BYTES_PAYLOAD_TYPE]);
|
|
90
|
+
const writes = destFactories.flatMap((f) => f.writePayloadTypes ?? [BYTES_PAYLOAD_TYPE]);
|
|
91
|
+
throw new Error(
|
|
92
|
+
`No common payload type: "${sourceProtocol}" reads [${[...new Set(reads)].join(", ")}], "${destProtocol}" writes [${[...new Set(writes)].join(", ")}]`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const common = pairs.find(
|
|
97
|
+
({ source, dest }) => source.kind === dest.kind && source.domain === dest.domain,
|
|
98
|
+
);
|
|
99
|
+
if (common) {
|
|
100
|
+
return { ...common, path: describe(common.source, common.dest) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const usable = converters
|
|
104
|
+
.filter(
|
|
105
|
+
(converter) =>
|
|
106
|
+
kind === undefined || (converter.from.kind === kind && converter.to.kind === kind),
|
|
107
|
+
)
|
|
108
|
+
.toSorted((a, b) => a.cost - b.cost);
|
|
109
|
+
for (const converter of usable) {
|
|
110
|
+
const pair = pairs.find(
|
|
111
|
+
({ source, dest }) => matches(converter.from, source) && matches(converter.to, dest),
|
|
112
|
+
);
|
|
113
|
+
if (pair) {
|
|
114
|
+
return { ...pair, converter, path: describe(pair.source, pair.dest, converter) };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
throw new Error(
|
|
119
|
+
`No common payload kind/domain and no registered converter between ${describeSide(sourceProtocol, sources)} and ${describeSide(destProtocol, dests)}`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface RetryOptions {
|
|
2
|
+
/**
|
|
3
|
+
* Maximum retries after a `TransientIOError`. For an unbounded source this
|
|
4
|
+
* counts consecutive failures and resets once an item arrives.
|
|
5
|
+
*/
|
|
6
|
+
readonly maxRetries: number;
|
|
7
|
+
/** Delay in ms before the given retry attempt (1-based). Defaults to `min(200 * 2^attempt, 5000)`. */
|
|
8
|
+
readonly backoffMs?: (attempt: number) => number;
|
|
9
|
+
/**
|
|
10
|
+
* Unbounded sources only: what to do when the source fails and has to be
|
|
11
|
+
* reconnected, leaving a gap. Defaults to `"continue"`.
|
|
12
|
+
*/
|
|
13
|
+
readonly onGap?: "continue" | "fail";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_RETRY: RetryOptions = { maxRetries: 3 };
|
|
17
|
+
|
|
18
|
+
export function defaultBackoffMs(attempt: number): number {
|
|
19
|
+
return Math.min(200 * 2 ** attempt, 5000);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Waits for the backoff delay before retry `attempt`. */
|
|
23
|
+
export function backoff(options: RetryOptions, attempt: number): Promise<void> {
|
|
24
|
+
const delay = (options.backoffMs ?? defaultBackoffMs)(attempt);
|
|
25
|
+
return new Promise((resolve) => setTimeout(resolve, delay));
|
|
26
|
+
}
|
|
@@ -1,14 +1,5 @@
|
|
|
1
1
|
import { TransientIOError } from "@flowscripter/pluggable-io-framework-api";
|
|
2
|
-
|
|
3
|
-
export interface RetryOptions {
|
|
4
|
-
readonly maxRetries: number;
|
|
5
|
-
/** Delay in ms before the given retry attempt (1-based). Defaults to `min(200 * 2^attempt, 5000)`. */
|
|
6
|
-
readonly backoffMs?: (attempt: number) => number;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
function defaultBackoffMs(attempt: number): number {
|
|
10
|
-
return Math.min(200 * 2 ** attempt, 5000);
|
|
11
|
-
}
|
|
2
|
+
import { backoff, type RetryOptions } from "./RetryOptions.ts";
|
|
12
3
|
|
|
13
4
|
/**
|
|
14
5
|
* Runs `fn`, retrying only on `TransientIOError` up to `options.maxRetries`
|
|
@@ -17,7 +8,6 @@ function defaultBackoffMs(attempt: number): number {
|
|
|
17
8
|
* rethrown immediately.
|
|
18
9
|
*/
|
|
19
10
|
export async function withRetry<T>(fn: () => Promise<T>, options: RetryOptions): Promise<T> {
|
|
20
|
-
const backoffMs = options.backoffMs ?? defaultBackoffMs;
|
|
21
11
|
let attempt = 0;
|
|
22
12
|
for (;;) {
|
|
23
13
|
try {
|
|
@@ -27,7 +17,7 @@ export async function withRetry<T>(fn: () => Promise<T>, options: RetryOptions):
|
|
|
27
17
|
throw error;
|
|
28
18
|
}
|
|
29
19
|
attempt += 1;
|
|
30
|
-
await
|
|
20
|
+
await backoff(options, attempt);
|
|
31
21
|
}
|
|
32
22
|
}
|
|
33
23
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { TelemetryHooks } from "@flowscripter/pluggable-io-framework-api";
|
|
2
|
+
import type { TransferOptions } from "./TransferOptions.ts";
|
|
3
|
+
|
|
4
|
+
/** Per-entry state shared by the transfer strategies. */
|
|
5
|
+
export interface EntryContext {
|
|
6
|
+
readonly operationId: string;
|
|
7
|
+
readonly hooks: TelemetryHooks;
|
|
8
|
+
readonly options: TransferOptions;
|
|
9
|
+
readonly type: "copy" | "move";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Bytes and items moved by one strategy run, and whether `stop` ended it. */
|
|
13
|
+
export interface EntryOutcome {
|
|
14
|
+
readonly bytes: number;
|
|
15
|
+
readonly items: number;
|
|
16
|
+
readonly stopped: boolean;
|
|
17
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { PayloadConverter, TelemetryHooks } from "@flowscripter/pluggable-io-framework-api";
|
|
2
|
+
import type { ConcurrencyLimiter } from "../concurrency/ConcurrencyLimiter.ts";
|
|
3
|
+
import type { RetryOptions } from "../retry/RetryOptions.ts";
|
|
4
|
+
|
|
5
|
+
export interface TransferOptions {
|
|
6
|
+
readonly telemetry?: TelemetryHooks;
|
|
7
|
+
/** Minimum entry size (bytes) before multipart transfer is attempted over plain streaming. */
|
|
8
|
+
readonly multipartThreshold?: number;
|
|
9
|
+
/**
|
|
10
|
+
* The payload converter chosen by negotiation, applied to every item.
|
|
11
|
+
* Without one, the source and sink must share a payload kind. Set by the
|
|
12
|
+
* options `createProvidersForTransfer` returns.
|
|
13
|
+
*/
|
|
14
|
+
readonly converter?: PayloadConverter;
|
|
15
|
+
/**
|
|
16
|
+
* The negotiated path description reported in {@link TransferResult.path}.
|
|
17
|
+
* Set by the options `createProvidersForTransfer` returns; without it the
|
|
18
|
+
* path is described from the provider kinds.
|
|
19
|
+
*/
|
|
20
|
+
readonly path?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Payload type IDs the sink's factory accepts, which an opened source
|
|
23
|
+
* handle's `payloadType` must be one of. Providers don't carry their
|
|
24
|
+
* factory's declarations, so `createProvidersForTransfer` returns them in
|
|
25
|
+
* its options. Defaults to `["bytes"]`.
|
|
26
|
+
*/
|
|
27
|
+
readonly writePayloadTypes?: readonly string[];
|
|
28
|
+
/**
|
|
29
|
+
* Bounds concurrent multipart parts and recursive/pattern entries.
|
|
30
|
+
* Defaults to the shared `defaultConcurrencyLimiter` - so two
|
|
31
|
+
* `copy()`/`move()` calls in the same process share one cap unless a
|
|
32
|
+
* caller passes its own isolated instance.
|
|
33
|
+
*/
|
|
34
|
+
readonly concurrencyLimiter?: ConcurrencyLimiter;
|
|
35
|
+
/** Retry policy for the non-direct transfer path. Defaults to `{ maxRetries: 3 }`. */
|
|
36
|
+
readonly retry?: RetryOptions;
|
|
37
|
+
/** Cancels the transfer: the source is cancelled, the sink aborted, and the transfer rejects with an `AbortError`. */
|
|
38
|
+
readonly signal?: AbortSignal;
|
|
39
|
+
/** Ends the transfer gracefully: the source is cancelled, the sink closed normally, and the result reports `stopped`. */
|
|
40
|
+
readonly stop?: AbortSignal;
|
|
41
|
+
/** When `false`, `directCopy`/`directMove` are never used. Defaults to `true`. */
|
|
42
|
+
readonly directTransfer?: boolean;
|
|
43
|
+
/** Maximum leases outstanding on the lease path, capped by the sink's `maxOutstanding`. Defaults to 2. */
|
|
44
|
+
readonly leaseDepth?: number;
|
|
45
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface TransferResult {
|
|
2
|
+
/** Whether `stop` ended the transfer early (output is truncated for a bounded source). */
|
|
3
|
+
readonly stopped: boolean;
|
|
4
|
+
readonly bytes: number;
|
|
5
|
+
readonly items: number;
|
|
6
|
+
/** Human-readable description of the negotiated transfer path. */
|
|
7
|
+
readonly path: string;
|
|
8
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { TransferResult } from "./TransferResult.ts";
|
|
2
|
+
|
|
3
|
+
/** Sums per-entry results; the aggregate is stopped if any entry was. */
|
|
4
|
+
export function aggregateResults(
|
|
5
|
+
results: readonly TransferResult[],
|
|
6
|
+
path: string,
|
|
7
|
+
stopped: boolean,
|
|
8
|
+
): TransferResult {
|
|
9
|
+
return {
|
|
10
|
+
stopped: stopped || results.some((result) => result.stopped),
|
|
11
|
+
bytes: results.reduce((total, result) => total + result.bytes, 0),
|
|
12
|
+
items: results.reduce((total, result) => total + result.items, 0),
|
|
13
|
+
path,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type IOProvider,
|
|
3
|
+
type LocationTarget,
|
|
4
|
+
PermanentIOError,
|
|
5
|
+
} from "@flowscripter/pluggable-io-framework-api";
|
|
6
|
+
import { baseName, joinKey } from "../util/keys.ts";
|
|
7
|
+
import { patternTransfer } from "./patternTransfer.ts";
|
|
8
|
+
import { recursiveTransfer } from "./recursiveTransfer.ts";
|
|
9
|
+
import { transferEntry } from "./transferEntry.ts";
|
|
10
|
+
import type { TransferOptions } from "./TransferOptions.ts";
|
|
11
|
+
import type { TransferResult } from "./TransferResult.ts";
|
|
12
|
+
|
|
13
|
+
async function checkSourceVariant(
|
|
14
|
+
source: IOProvider,
|
|
15
|
+
key: string,
|
|
16
|
+
expectContainer: boolean,
|
|
17
|
+
variant: string,
|
|
18
|
+
) {
|
|
19
|
+
const properties = await source.getProperties(key);
|
|
20
|
+
if (properties.isContainer !== expectContainer) {
|
|
21
|
+
const actual = properties.isContainer ? "a container" : "an entry";
|
|
22
|
+
throw new PermanentIOError(`Source "${key}" is declared as ${variant} but is ${actual}`);
|
|
23
|
+
}
|
|
24
|
+
return properties;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function transfer(
|
|
28
|
+
source: IOProvider,
|
|
29
|
+
sourceTarget: LocationTarget,
|
|
30
|
+
sink: IOProvider,
|
|
31
|
+
destTarget: LocationTarget,
|
|
32
|
+
options: TransferOptions,
|
|
33
|
+
type: "copy" | "move",
|
|
34
|
+
): Promise<TransferResult> {
|
|
35
|
+
if (destTarget.kind === "pattern") {
|
|
36
|
+
throw new PermanentIOError("A destination cannot be a pattern");
|
|
37
|
+
}
|
|
38
|
+
if (sourceTarget.kind === "entry") {
|
|
39
|
+
const properties = await checkSourceVariant(source, sourceTarget.key, false, "an entry");
|
|
40
|
+
const destKey =
|
|
41
|
+
destTarget.kind === "entry"
|
|
42
|
+
? destTarget.key
|
|
43
|
+
: joinKey(sink, destTarget.key, baseName(sourceTarget.key));
|
|
44
|
+
return transferEntry({
|
|
45
|
+
source,
|
|
46
|
+
sourceKey: sourceTarget.key,
|
|
47
|
+
sink,
|
|
48
|
+
destKey,
|
|
49
|
+
properties,
|
|
50
|
+
options,
|
|
51
|
+
type,
|
|
52
|
+
deleteSource: type === "move",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
if (destTarget.kind === "entry") {
|
|
56
|
+
throw new PermanentIOError(
|
|
57
|
+
sourceTarget.kind === "container"
|
|
58
|
+
? "Cannot transfer a container onto an entry"
|
|
59
|
+
: "Cannot transfer a pattern of entries onto a single entry",
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (sourceTarget.kind === "container") {
|
|
63
|
+
await checkSourceVariant(source, sourceTarget.key, true, "a container");
|
|
64
|
+
return recursiveTransfer(source, sourceTarget.key, sink, destTarget.key, options, type);
|
|
65
|
+
}
|
|
66
|
+
await checkSourceVariant(source, sourceTarget.containerKey, true, "a pattern container");
|
|
67
|
+
return patternTransfer(
|
|
68
|
+
source,
|
|
69
|
+
sourceTarget.containerKey,
|
|
70
|
+
sourceTarget.pattern,
|
|
71
|
+
sink,
|
|
72
|
+
destTarget.key,
|
|
73
|
+
options,
|
|
74
|
+
type,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Copies `sourceTarget` on `source` to `destTarget` on `sink`:
|
|
80
|
+
* - entry -> entry writes exactly the destination key;
|
|
81
|
+
* - entry -> container writes `<container>/<source basename>`;
|
|
82
|
+
* - container -> container copies recursively with `cp -r` semantics;
|
|
83
|
+
* - pattern -> container copies each matching entry;
|
|
84
|
+
* - container or pattern -> entry, and any pattern destination, are rejected.
|
|
85
|
+
*
|
|
86
|
+
* Each entry uses `directCopy` when eligible (and `options.directTransfer`
|
|
87
|
+
* is not `false`), otherwise multipart, the lease path or plain streaming.
|
|
88
|
+
*/
|
|
89
|
+
export function copy(
|
|
90
|
+
source: IOProvider,
|
|
91
|
+
sourceTarget: LocationTarget,
|
|
92
|
+
sink: IOProvider,
|
|
93
|
+
destTarget: LocationTarget,
|
|
94
|
+
options: TransferOptions = {},
|
|
95
|
+
): Promise<TransferResult> {
|
|
96
|
+
return transfer(source, sourceTarget, sink, destTarget, options, "copy");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Moves `sourceTarget` on `source` to `destTarget` on `sink`, with the same
|
|
101
|
+
* target rules as {@link copy}. Non-direct moves copy and then delete the
|
|
102
|
+
* source. Unbounded sources are rejected, and a stopped move never deletes
|
|
103
|
+
* its source.
|
|
104
|
+
*/
|
|
105
|
+
export function move(
|
|
106
|
+
source: IOProvider,
|
|
107
|
+
sourceTarget: LocationTarget,
|
|
108
|
+
sink: IOProvider,
|
|
109
|
+
destTarget: LocationTarget,
|
|
110
|
+
options: TransferOptions = {},
|
|
111
|
+
): Promise<TransferResult> {
|
|
112
|
+
return transfer(source, sourceTarget, sink, destTarget, options, "move");
|
|
113
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BufferLease,
|
|
3
|
+
BufferProvider,
|
|
4
|
+
FillReadable,
|
|
5
|
+
Item,
|
|
6
|
+
StreamHandle,
|
|
7
|
+
} from "@flowscripter/pluggable-io-framework-api";
|
|
8
|
+
import { createAbortError } from "../util/abort.ts";
|
|
9
|
+
import type { EntryContext, EntryOutcome } from "./EntryContext.ts";
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_LEASE_DEPTH = 2;
|
|
12
|
+
|
|
13
|
+
/** The number of leases the engine keeps outstanding for `writable`. */
|
|
14
|
+
export function leaseDepth(writable: BufferProvider, requested: number | undefined): number {
|
|
15
|
+
return Math.max(
|
|
16
|
+
1,
|
|
17
|
+
Math.min(requested ?? DEFAULT_LEASE_DEPTH, writable.maxOutstanding ?? Infinity),
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function ignore(): void {}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Moves data by having the source fill buffers handed out by the sink:
|
|
25
|
+
* `acquire -> readInto -> commit`, with up to `depth` leases outstanding so
|
|
26
|
+
* the next read overlaps earlier commits. `readInto` calls are serialised
|
|
27
|
+
* and commits happen in acquisition order. Every lease not yet committed is
|
|
28
|
+
* released on abort, error or end of stream.
|
|
29
|
+
*/
|
|
30
|
+
export async function leaseTransfer(
|
|
31
|
+
readable: StreamHandle & FillReadable,
|
|
32
|
+
writable: StreamHandle & BufferProvider,
|
|
33
|
+
depth: number,
|
|
34
|
+
context: EntryContext,
|
|
35
|
+
): Promise<EntryOutcome> {
|
|
36
|
+
const { options, hooks, operationId, type } = context;
|
|
37
|
+
const outstanding = new Set<BufferLease>();
|
|
38
|
+
const pending: Promise<void>[] = [];
|
|
39
|
+
let commitChain: Promise<void> = Promise.resolve();
|
|
40
|
+
let bytes = 0;
|
|
41
|
+
let items = 0;
|
|
42
|
+
let stopped = false;
|
|
43
|
+
const writer = (writable.stream as WritableStream<Item>).getWriter();
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
for (;;) {
|
|
47
|
+
if (options.signal?.aborted) throw createAbortError();
|
|
48
|
+
if (options.stop?.aborted) {
|
|
49
|
+
stopped = true;
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
while (pending.length >= depth) {
|
|
53
|
+
await pending.shift();
|
|
54
|
+
}
|
|
55
|
+
const lease = await writable.acquire();
|
|
56
|
+
outstanding.add(lease);
|
|
57
|
+
const length = await readable.readInto(lease);
|
|
58
|
+
if (length === null) {
|
|
59
|
+
outstanding.delete(lease);
|
|
60
|
+
lease.release();
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
bytes += length;
|
|
64
|
+
items += 1;
|
|
65
|
+
const committed = commitChain.then(async () => {
|
|
66
|
+
await lease.commit(length);
|
|
67
|
+
outstanding.delete(lease);
|
|
68
|
+
});
|
|
69
|
+
committed.catch(ignore);
|
|
70
|
+
commitChain = committed;
|
|
71
|
+
pending.push(committed);
|
|
72
|
+
hooks.onProgress?.({
|
|
73
|
+
operationId,
|
|
74
|
+
type,
|
|
75
|
+
bytesProcessed: bytes,
|
|
76
|
+
totalBytes: undefined,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
await commitChain;
|
|
80
|
+
await writer.close();
|
|
81
|
+
if (!stopped) {
|
|
82
|
+
await (readable.stream as ReadableStream<Item>).cancel().catch(ignore);
|
|
83
|
+
}
|
|
84
|
+
return { bytes, items, stopped };
|
|
85
|
+
} catch (error) {
|
|
86
|
+
for (const lease of outstanding) {
|
|
87
|
+
lease.release();
|
|
88
|
+
}
|
|
89
|
+
await writer.abort(error).catch(ignore);
|
|
90
|
+
await (readable.stream as ReadableStream<Item>).cancel(error).catch(ignore);
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type IOProvider,
|
|
3
|
+
type Item,
|
|
4
|
+
type Part,
|
|
5
|
+
type RangeReadable,
|
|
6
|
+
type StreamHandle,
|
|
7
|
+
TransientIOError,
|
|
8
|
+
} from "@flowscripter/pluggable-io-framework-api";
|
|
9
|
+
import type { ConcurrencyLimiter } from "../concurrency/ConcurrencyLimiter.ts";
|
|
10
|
+
import { mapAsyncIterableConcurrently } from "../concurrency/mapAsyncIterableConcurrently.ts";
|
|
11
|
+
import { backoff, DEFAULT_RETRY } from "../retry/RetryOptions.ts";
|
|
12
|
+
import { createAbortError } from "../util/abort.ts";
|
|
13
|
+
import { applyPayloadConverter } from "../util/applyPayloadConverter.ts";
|
|
14
|
+
import { itemLength } from "../util/itemLength.ts";
|
|
15
|
+
import type { EntryContext, EntryOutcome } from "./EntryContext.ts";
|
|
16
|
+
import { rangeReadableMultipartReader } from "./rangeReadableMultipartReader.ts";
|
|
17
|
+
|
|
18
|
+
export interface MultipartTransferInput {
|
|
19
|
+
readonly readable: StreamHandle & RangeReadable;
|
|
20
|
+
readonly sink: IOProvider;
|
|
21
|
+
readonly writer: { write(parts: AsyncIterable<Part>): Promise<void> };
|
|
22
|
+
readonly totalBytes: number;
|
|
23
|
+
readonly partSize: number;
|
|
24
|
+
readonly limiter: ConcurrencyLimiter;
|
|
25
|
+
readonly context: EntryContext;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Reads parts with `readRange` and hands them to the sink's multipart
|
|
30
|
+
* writer, bounded by `limiter`. A part whose read fails with a
|
|
31
|
+
* `TransientIOError` is re-read on its own, up to `retry.maxRetries` times,
|
|
32
|
+
* within the same upload. `stop` ends the transfer after the parts already
|
|
33
|
+
* started; `signal` aborts it.
|
|
34
|
+
*/
|
|
35
|
+
export async function multipartTransfer(input: MultipartTransferInput): Promise<EntryOutcome> {
|
|
36
|
+
const { readable, sink, writer, totalBytes, partSize, limiter, context } = input;
|
|
37
|
+
const { options, hooks, operationId, type } = context;
|
|
38
|
+
const retry = options.retry ?? DEFAULT_RETRY;
|
|
39
|
+
let bytes = 0;
|
|
40
|
+
let items = 0;
|
|
41
|
+
let stopped = false;
|
|
42
|
+
|
|
43
|
+
async function readPart(sourcePart: Part): Promise<Item[]> {
|
|
44
|
+
const partOperationId = crypto.randomUUID();
|
|
45
|
+
const end = Math.min(sourcePart.offset + partSize, totalBytes);
|
|
46
|
+
let stream = sourcePart.stream as ReadableStream<Item>;
|
|
47
|
+
let attempt = 0;
|
|
48
|
+
for (;;) {
|
|
49
|
+
const reader = applyPayloadConverter(
|
|
50
|
+
stream,
|
|
51
|
+
readable.kind,
|
|
52
|
+
sink.kind,
|
|
53
|
+
options.converter,
|
|
54
|
+
).getReader();
|
|
55
|
+
const partItems: Item[] = [];
|
|
56
|
+
let partBytes = 0;
|
|
57
|
+
try {
|
|
58
|
+
for (;;) {
|
|
59
|
+
if (options.signal?.aborted) {
|
|
60
|
+
await reader.cancel().catch(() => {});
|
|
61
|
+
throw createAbortError();
|
|
62
|
+
}
|
|
63
|
+
const { done, value } = await reader.read();
|
|
64
|
+
if (done) break;
|
|
65
|
+
partItems.push(value);
|
|
66
|
+
partBytes += itemLength(value);
|
|
67
|
+
hooks.onProgress?.({
|
|
68
|
+
operationId: partOperationId,
|
|
69
|
+
parentOperationId: operationId,
|
|
70
|
+
type,
|
|
71
|
+
bytesProcessed: partBytes,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
bytes += partBytes;
|
|
75
|
+
items += partItems.length;
|
|
76
|
+
hooks.onProgress?.({ operationId, type, bytesProcessed: bytes, totalBytes });
|
|
77
|
+
return partItems;
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (!(error instanceof TransientIOError) || attempt >= retry.maxRetries) {
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
attempt += 1;
|
|
83
|
+
await backoff(retry, attempt);
|
|
84
|
+
stream = (await readable.readRange(sourcePart.offset, end)) as ReadableStream<Item>;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function transferPart(sourcePart: Part): Promise<Part> {
|
|
90
|
+
const partItems = await readPart(sourcePart);
|
|
91
|
+
return {
|
|
92
|
+
index: sourcePart.index,
|
|
93
|
+
offset: sourcePart.offset,
|
|
94
|
+
kind: sink.kind,
|
|
95
|
+
stream: new ReadableStream<Item>({
|
|
96
|
+
start(controller) {
|
|
97
|
+
for (const item of partItems) controller.enqueue(item);
|
|
98
|
+
controller.close();
|
|
99
|
+
},
|
|
100
|
+
}),
|
|
101
|
+
complete: () => sourcePart.complete(),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function* sourceParts(): AsyncGenerator<Part> {
|
|
106
|
+
for await (const part of rangeReadableMultipartReader(readable, totalBytes, partSize)) {
|
|
107
|
+
if (options.signal?.aborted) throw createAbortError();
|
|
108
|
+
if (options.stop?.aborted) {
|
|
109
|
+
stopped = true;
|
|
110
|
+
await (part.stream as ReadableStream<Item>).cancel().catch(() => {});
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
yield part;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
await writer.write(mapAsyncIterableConcurrently(sourceParts(), transferPart, limiter));
|
|
118
|
+
return { bytes, items, stopped };
|
|
119
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { IOProvider, PartSizeConstraints } from "@flowscripter/pluggable-io-framework-api";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_PART_SIZE = 8 * 1024 * 1024;
|
|
4
|
+
|
|
5
|
+
const UNCONSTRAINED_PART_SIZE: PartSizeConstraints = {
|
|
6
|
+
minPartSize: 0,
|
|
7
|
+
maxPartSize: Infinity,
|
|
8
|
+
maxParts: Infinity,
|
|
9
|
+
defaultPartSize: DEFAULT_PART_SIZE,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Reconciles `source`'s and `sink`'s {@link PartSizeConstraints} for a
|
|
14
|
+
* transfer of `totalSize` bytes into a single part size satisfying both.
|
|
15
|
+
* Returns `undefined` when the bounds are mutually infeasible (source's
|
|
16
|
+
* minimum exceeds sink's maximum) - the caller should fall back to plain
|
|
17
|
+
* streaming rather than throwing.
|
|
18
|
+
*/
|
|
19
|
+
export function negotiatePartSize(
|
|
20
|
+
source: IOProvider,
|
|
21
|
+
sink: IOProvider,
|
|
22
|
+
totalSize: number,
|
|
23
|
+
): number | undefined {
|
|
24
|
+
const sourceConstraints = source.getPartSizeConstraints?.(totalSize) ?? UNCONSTRAINED_PART_SIZE;
|
|
25
|
+
const sinkConstraints = sink.getPartSizeConstraints?.(totalSize) ?? UNCONSTRAINED_PART_SIZE;
|
|
26
|
+
const minPartSize = Math.max(sourceConstraints.minPartSize, sinkConstraints.minPartSize);
|
|
27
|
+
const maxPartSize = Math.min(sourceConstraints.maxPartSize, sinkConstraints.maxPartSize);
|
|
28
|
+
const maxParts = Math.min(sourceConstraints.maxParts, sinkConstraints.maxParts);
|
|
29
|
+
if (minPartSize > maxPartSize) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
const byMaxParts = Math.ceil(totalSize / maxParts);
|
|
33
|
+
const candidate = Math.max(
|
|
34
|
+
minPartSize,
|
|
35
|
+
sourceConstraints.defaultPartSize,
|
|
36
|
+
sinkConstraints.defaultPartSize,
|
|
37
|
+
byMaxParts,
|
|
38
|
+
);
|
|
39
|
+
return Math.min(candidate, maxPartSize);
|
|
40
|
+
}
|