@beam-network/sdk 0.6.1 → 0.8.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 CHANGED
@@ -39,8 +39,7 @@ const transfer = await beam.createTransfer({
39
39
  secret_access_key: "aws-secret-key"
40
40
  })
41
41
  ],
42
- name: "r2-to-s3-report",
43
- testMode: true
42
+ name: "r2-to-s3-report"
44
43
  });
45
44
 
46
45
  const status = await beam.waitForTransfer(transfer.transfer_id);
@@ -49,7 +48,32 @@ await beam.close();
49
48
  ```
50
49
 
51
50
  The main `createTransfer` API is provider-aware and strictly typed for S3, R2, S3-compatible, Hippius, and Hugging Face configs.
52
- Use `testMode: true` to create a BeamCore test-mode transfer.
51
+
52
+ ## Storage Credentials
53
+
54
+ Source and destination credentials must not be restricted to specific IP addresses or networks (for example Cloudflare R2 API-token client IP filtering, S3 bucket policies with `aws:SourceIp`, or VPC-only endpoints). Beam moves data through many workers on different networks, so restricted credentials make the transfer fail.
55
+
56
+ ## Failed Transfers
57
+
58
+ `waitForTransfer` rejects a failed transfer with `BeamTransferFailedError`, whose `errorMessage` is BeamCore's `error_message` verbatim. When the storage refused Beam's requests, the error is the `BeamStorageAccessError` subclass, with `code` set to `source_access_denied` or `destination_access_denied`:
59
+
60
+ ```ts
61
+ import { BeamStorageAccessError, BeamTransferFailedError } from "@beam-network/sdk";
62
+
63
+ try {
64
+ await beam.waitForTransfer(transfer.transfer_id);
65
+ } catch (error) {
66
+ if (error instanceof BeamStorageAccessError) {
67
+ // For example: "destination_access_denied: The destination storage refused Beam's requests (403 AccessDenied). ..."
68
+ console.error(error.code, error.errorMessage);
69
+ } else if (error instanceof BeamTransferFailedError) {
70
+ console.error(error.errorMessage);
71
+ }
72
+ throw error;
73
+ }
74
+ ```
75
+
76
+ Callers that poll `transferStatus` themselves can pass a failed status to `transferFailedError(transferId, status.error_message)` to get the same classification.
53
77
 
54
78
  ## S3-Compatible Providers
55
79
 
@@ -195,3 +219,24 @@ Multipart control helpers (`createMultipartUpload`, `listMultipartParts`, `compl
195
219
  Route streaming flushes at 1,024 routes, with no timer, and sends the final partial batch at stream completion. Signing overlaps acknowledged publication through bounded buffering; existing encoded-payload limits still apply. A source grant is reused across destinations only within the same signing generation and actual expiry.
196
220
 
197
221
  The optional `onDiagnostics` callback receives bounded preparation measurements and source-grant reuse counts. Callbacks are best effort, may be dropped under load, and do not affect transfer outcomes. Durations may overlap; do not sum them into elapsed transfer time. Transfer status also exposes a typed `performance` summary when supported by Core.
222
+
223
+ Optional `storage_location` (`StorageLocation` in Go) describes the physical
224
+ storage location. It is separate from the region used to sign provider requests.
225
+ Leave it unset when unknown; a signing region such as R2's `auto` is not a location.
226
+
227
+ When supported by Core, diagnostics use `sdk-performance/v2`: bounded histograms,
228
+ preparation milestones, concurrency high-water marks, and separate provider,
229
+ callback, manifest, signing, and transport waits. Histogram bins are noncumulative,
230
+ with upper bounds in milliseconds of 0.1, 0.5, 1, 2, 5, 10, 25, 50, 100, 250,
231
+ 500, 1000, 2500, 5000, 10000, 30000, 120000, then overflow. `unmeasured` explicitly
232
+ identifies unavailable measurements. Process CPU includes other concurrent work
233
+ in the SDK process; it is not transfer-exclusive CPU. Detailed reporting can be
234
+ disabled with `BEAM_SDK_PERFORMANCE_DETAILS=false`. Older peers retain v1 reports.
235
+ No credentials or storage grants are included in these diagnostics.
236
+
237
+ `sdk.producer_wait` measures waiting for a signed route separately from publication
238
+ backpressure. Configured-limit gauges preserve starting limits; effective-limit
239
+ gauges report the highest limit reached. Optional `source_renewals` counts source
240
+ grants recreated in later preparation generations. A bounded 16 KiB history tracks
241
+ 131,072 chunk indices; the counter is omitted after takeover or beyond that bound.
242
+ It does not include separate recovery-control signing operations.
package/dist/client.d.ts CHANGED
@@ -19,6 +19,33 @@ export declare class BeamProviderTransferError extends AggregateError {
19
19
  cleanupError?: unknown;
20
20
  });
21
21
  }
22
+ /** Failure codes BeamCore reports when source or destination storage refuses Beam's requests. */
23
+ export type BeamStorageAccessErrorCode = "source_access_denied" | "destination_access_denied";
24
+ /** A transfer that BeamCore reported as failed. */
25
+ export declare class BeamTransferFailedError extends Error {
26
+ readonly transferId: string;
27
+ /** The transfer's `error_message` from BeamCore, verbatim; null when BeamCore sent none. */
28
+ readonly errorMessage: string | null;
29
+ constructor(transferId: string, errorMessage: string | null);
30
+ }
31
+ /**
32
+ * The source or destination storage refused Beam's requests (`source_access_denied` or
33
+ * `destination_access_denied`). `errorMessage` carries BeamCore's explanation verbatim.
34
+ *
35
+ * Check that the credentials allow the operation on this bucket and path and are not restricted
36
+ * to specific IP addresses or networks (for example Cloudflare R2 API-token client IP filtering,
37
+ * S3 bucket policies with `aws:SourceIp`, or VPC-only endpoints). Beam moves data through many
38
+ * workers on different networks, so restricted credentials make the transfer fail.
39
+ */
40
+ export declare class BeamStorageAccessError extends BeamTransferFailedError {
41
+ readonly code: BeamStorageAccessErrorCode;
42
+ constructor(transferId: string, errorMessage: string, code: BeamStorageAccessErrorCode);
43
+ }
44
+ /**
45
+ * Builds the error for a failed transfer status: a {@link BeamStorageAccessError} when the
46
+ * server message starts with a storage access code, otherwise a {@link BeamTransferFailedError}.
47
+ */
48
+ export declare function transferFailedError(transferId: string, errorMessage: string | null): BeamTransferFailedError;
22
49
  export declare class BeamApiError extends Error {
23
50
  readonly status: number;
24
51
  readonly body: string;
@@ -29,7 +56,6 @@ type TransferPrepareInput = {
29
56
  sources: PreparedHttpSource[];
30
57
  destinations: PreparedDestination[];
31
58
  name?: string;
32
- testMode?: boolean;
33
59
  urlsExpiresAt?: string;
34
60
  signedUrlFlow?: SignedUrlFlow;
35
61
  idempotencyKey?: string;
@@ -54,22 +80,62 @@ export declare class BeamClient {
54
80
  constructor(options?: BeamClientOptions);
55
81
  close(): Promise<void>;
56
82
  openTransferTerminalWaiter(transferId: string): Promise<TransferTerminalSignalWaiter>;
83
+ /**
84
+ * Creates a transfer from raw source and destination configs (`transfer.create`).
85
+ *
86
+ * Source and destination credentials must not be restricted to specific IP addresses or
87
+ * networks (for example Cloudflare R2 API-token client IP filtering, S3 bucket policies with
88
+ * `aws:SourceIp`, or VPC-only endpoints). Beam moves data through many workers on different
89
+ * networks, so restricted credentials make the transfer fail.
90
+ */
57
91
  createRawTransfer(input: RawTransferCreateInput): Promise<TransferCreateResponse>;
92
+ /**
93
+ * Signs provider sources and destinations, prepares the transfer, streams its signed routes
94
+ * and, unless `distribute` is false, starts it. Alias of {@link prepareProviderTransfer}.
95
+ *
96
+ * Source and destination credentials must not be restricted to specific IP addresses or
97
+ * networks (for example Cloudflare R2 API-token client IP filtering, S3 bucket policies with
98
+ * `aws:SourceIp`, or VPC-only endpoints). Beam moves data through many workers on different
99
+ * networks, so restricted credentials make the transfer fail.
100
+ */
58
101
  createTransfer(input: ProviderTransferCreateInput): Promise<TransferPrepareResponse>;
102
+ /**
103
+ * Takes over a prepared provider transfer in a new process, reusing its multipart uploads.
104
+ *
105
+ * Source and destination credentials must not be restricted to specific IP addresses or
106
+ * networks (for example Cloudflare R2 API-token client IP filtering, S3 bucket policies with
107
+ * `aws:SourceIp`, or VPC-only endpoints). Beam moves data through many workers on different
108
+ * networks, so restricted credentials make the transfer fail.
109
+ */
59
110
  resumeProviderTransfer(input: ProviderTransferResumeInput): Promise<TransferPrepareResponse>;
60
111
  transferStatus(transferId: string): Promise<TransferStatusInfo>;
61
112
  distributeTransfer(transferId: string): Promise<DistributeResponse>;
62
113
  private requestTransferCancellation;
63
114
  cancelTransfer(transferId: string): Promise<TransferCancelResponse>;
115
+ /**
116
+ * Asks BeamCore for the compact plan a transfer would use without creating it (`transfer.plan`).
117
+ *
118
+ * Source and destination credentials must not be restricted to specific IP addresses or
119
+ * networks (for example Cloudflare R2 API-token client IP filtering, S3 bucket policies with
120
+ * `aws:SourceIp`, or VPC-only endpoints). Beam moves data through many workers on different
121
+ * networks, so restricted credentials make the transfer fail.
122
+ */
64
123
  planTransfer(input: {
65
124
  sources: PlanningHttpSource[];
66
125
  destinations: PreparedDestination[];
67
126
  name?: string;
68
- testMode?: boolean;
69
127
  urlsExpiresAt?: string;
70
128
  signedUrlFlow?: SignedUrlFlow;
71
129
  chunkSize?: number;
72
130
  }): Promise<TransferPlanResponse>;
131
+ /**
132
+ * Prepares a transfer from already-signed HTTP sources and destinations (`transfer.prepare`).
133
+ *
134
+ * Source and destination credentials must not be restricted to specific IP addresses or
135
+ * networks (for example Cloudflare R2 API-token client IP filtering, S3 bucket policies with
136
+ * `aws:SourceIp`, or VPC-only endpoints). Beam moves data through many workers on different
137
+ * networks, so restricted credentials make the transfer fail.
138
+ */
73
139
  prepareTransfer(input: TransferPrepareInput): Promise<TransferPrepareResponse>;
74
140
  private prepareTransferWithRequestKey;
75
141
  attachSignedUrls(transferId: string, input: {
@@ -87,6 +153,15 @@ export declare class BeamClient {
87
153
  urlsExpiresAt?: string;
88
154
  autoDistribute?: boolean;
89
155
  }): Promise<AttachSignedUrlsResponse>;
156
+ /**
157
+ * Signs provider sources and destinations, prepares the transfer, streams its signed routes
158
+ * and, unless `distribute` is false, starts it.
159
+ *
160
+ * Source and destination credentials must not be restricted to specific IP addresses or
161
+ * networks (for example Cloudflare R2 API-token client IP filtering, S3 bucket policies with
162
+ * `aws:SourceIp`, or VPC-only endpoints). Beam moves data through many workers on different
163
+ * networks, so restricted credentials make the transfer fail.
164
+ */
90
165
  prepareProviderTransfer(input: ProviderTransferCreateInput): Promise<TransferPrepareResponse>;
91
166
  private executeProviderTransfer;
92
167
  /**
@@ -113,7 +188,21 @@ export declare class BeamClient {
113
188
  /** Stop a transfer's recovery and integrity signers; with `owner`, only if that owner installed them. */
114
189
  private stopRecoverySigner;
115
190
  private stopAllRecoverySigners;
191
+ /**
192
+ * Creates a raw transfer and distributes it.
193
+ *
194
+ * Source and destination credentials must not be restricted to specific IP addresses or
195
+ * networks (for example Cloudflare R2 API-token client IP filtering, S3 bucket policies with
196
+ * `aws:SourceIp`, or VPC-only endpoints). Beam moves data through many workers on different
197
+ * networks, so restricted credentials make the transfer fail.
198
+ */
116
199
  createAndDistribute(input: RawTransferCreateInput): Promise<TransferCreateResponse>;
200
+ /**
201
+ * Waits until the transfer completes. A failed transfer rejects with a
202
+ * {@link BeamTransferFailedError} carrying BeamCore's `error_message` verbatim, or with its
203
+ * {@link BeamStorageAccessError} subclass when the source or destination storage refused
204
+ * Beam's requests (`source_access_denied`, `destination_access_denied`).
205
+ */
117
206
  waitForTransfer(transferId: string, options?: {
118
207
  timeoutMs?: number;
119
208
  pollIntervalMs?: number;
package/dist/client.js CHANGED
@@ -1,4 +1,4 @@
1
- import { SdkPerformanceCollector } from "./performance.js";
1
+ import { SdkPerformanceCollector, SourceSignatureHistory, currentSdkPerformance, measureSdkPhase } from "./performance.js";
2
2
  import { signMultipartRecovery, abortMultipartUpload, createMultipartUpload, expiresAtIso, prepareProviderDestination, prepareProviderSource, releaseProviderClients, signAbortMultipartUpload, signCompleteMultipartUpload, signDestinationReadRange, signDestinationRoute, signFinalObjectHead, signListMultipartUpload, signSourceReadRange, signSourceChunk, boundedGrantExpiry, isHuggingFaceProvider } from "./provider-signing.js";
3
3
  import { describe as describeHuggingFace, hashSourceStream, huggingFaceCommit, huggingFaceCompleteLfsUpload, huggingFaceLfsBatch, huggingFacePreupload, huggingFaceVerifyLfsUpload, readSourceSample } from "./huggingface.js";
4
4
  import { BeamTransferControl, BEAM_DEFAULT_NATS_URL, compactSignedRoutes, isRecoverableRouteStreamError } from "./nats-control.js";
@@ -36,6 +36,54 @@ export class BeamProviderTransferError extends AggregateError {
36
36
  this.cause = input.cause;
37
37
  }
38
38
  }
39
+ const STORAGE_ACCESS_ERROR_CODES = [
40
+ "source_access_denied",
41
+ "destination_access_denied"
42
+ ];
43
+ /** A transfer that BeamCore reported as failed. */
44
+ export class BeamTransferFailedError extends Error {
45
+ transferId;
46
+ /** The transfer's `error_message` from BeamCore, verbatim; null when BeamCore sent none. */
47
+ errorMessage;
48
+ constructor(transferId, errorMessage) {
49
+ super(`Transfer ${transferId} failed: ${errorMessage ?? "unknown error"}`);
50
+ this.name = "BeamTransferFailedError";
51
+ this.transferId = transferId;
52
+ this.errorMessage = errorMessage;
53
+ }
54
+ }
55
+ /**
56
+ * The source or destination storage refused Beam's requests (`source_access_denied` or
57
+ * `destination_access_denied`). `errorMessage` carries BeamCore's explanation verbatim.
58
+ *
59
+ * Check that the credentials allow the operation on this bucket and path and are not restricted
60
+ * to specific IP addresses or networks (for example Cloudflare R2 API-token client IP filtering,
61
+ * S3 bucket policies with `aws:SourceIp`, or VPC-only endpoints). Beam moves data through many
62
+ * workers on different networks, so restricted credentials make the transfer fail.
63
+ */
64
+ export class BeamStorageAccessError extends BeamTransferFailedError {
65
+ code;
66
+ constructor(transferId, errorMessage, code) {
67
+ super(transferId, errorMessage);
68
+ this.name = "BeamStorageAccessError";
69
+ this.code = code;
70
+ }
71
+ }
72
+ /**
73
+ * Builds the error for a failed transfer status: a {@link BeamStorageAccessError} when the
74
+ * server message starts with a storage access code, otherwise a {@link BeamTransferFailedError}.
75
+ */
76
+ export function transferFailedError(transferId, errorMessage) {
77
+ const code = errorMessage === null ? undefined : storageAccessErrorCode(errorMessage);
78
+ return code && errorMessage !== null
79
+ ? new BeamStorageAccessError(transferId, errorMessage, code)
80
+ : new BeamTransferFailedError(transferId, errorMessage);
81
+ }
82
+ function storageAccessErrorCode(errorMessage) {
83
+ const separator = errorMessage.indexOf(":");
84
+ const prefix = (separator === -1 ? errorMessage : errorMessage.slice(0, separator)).trim();
85
+ return STORAGE_ACCESS_ERROR_CODES.find((code) => code === prefix);
86
+ }
39
87
  export class BeamApiError extends Error {
40
88
  status;
41
89
  body;
@@ -94,6 +142,14 @@ export class BeamClient {
94
142
  validateId(transferId, "transferId");
95
143
  return this.control.openTerminalSignalWaiter(transferId);
96
144
  }
145
+ /**
146
+ * Creates a transfer from raw source and destination configs (`transfer.create`).
147
+ *
148
+ * Source and destination credentials must not be restricted to specific IP addresses or
149
+ * networks (for example Cloudflare R2 API-token client IP filtering, S3 bucket policies with
150
+ * `aws:SourceIp`, or VPC-only endpoints). Beam moves data through many workers on different
151
+ * networks, so restricted credentials make the transfer fail.
152
+ */
97
153
  async createRawTransfer(input) {
98
154
  const transferId = await transferIdForIdempotencyKey(input.idempotencyKey);
99
155
  const body = compact({
@@ -106,7 +162,6 @@ export class BeamClient {
106
162
  merkle_root: input.merkleRoot,
107
163
  chunk_hashes: input.chunkHashes,
108
164
  callbacks: input.callbacks,
109
- test_mode: input.testMode || undefined,
110
165
  progressive_mode: input.progressiveMode || undefined,
111
166
  signed_url_flow: input.signedUrlFlow ?? "signed_url"
112
167
  });
@@ -115,9 +170,26 @@ export class BeamClient {
115
170
  idempotencyKey: `transfer:${transferId}:create`
116
171
  });
117
172
  }
173
+ /**
174
+ * Signs provider sources and destinations, prepares the transfer, streams its signed routes
175
+ * and, unless `distribute` is false, starts it. Alias of {@link prepareProviderTransfer}.
176
+ *
177
+ * Source and destination credentials must not be restricted to specific IP addresses or
178
+ * networks (for example Cloudflare R2 API-token client IP filtering, S3 bucket policies with
179
+ * `aws:SourceIp`, or VPC-only endpoints). Beam moves data through many workers on different
180
+ * networks, so restricted credentials make the transfer fail.
181
+ */
118
182
  createTransfer(input) {
119
183
  return this.prepareProviderTransfer(input);
120
184
  }
185
+ /**
186
+ * Takes over a prepared provider transfer in a new process, reusing its multipart uploads.
187
+ *
188
+ * Source and destination credentials must not be restricted to specific IP addresses or
189
+ * networks (for example Cloudflare R2 API-token client IP filtering, S3 bucket policies with
190
+ * `aws:SourceIp`, or VPC-only endpoints). Beam moves data through many workers on different
191
+ * networks, so restricted credentials make the transfer fail.
192
+ */
121
193
  async resumeProviderTransfer(input) {
122
194
  validateId(input.transferId, "transferId");
123
195
  return this.executeProviderTransfer(input, input);
@@ -159,12 +231,19 @@ export class BeamClient {
159
231
  this.stopRecoverySigner(transferId);
160
232
  return result;
161
233
  }
234
+ /**
235
+ * Asks BeamCore for the compact plan a transfer would use without creating it (`transfer.plan`).
236
+ *
237
+ * Source and destination credentials must not be restricted to specific IP addresses or
238
+ * networks (for example Cloudflare R2 API-token client IP filtering, S3 bucket policies with
239
+ * `aws:SourceIp`, or VPC-only endpoints). Beam moves data through many workers on different
240
+ * networks, so restricted credentials make the transfer fail.
241
+ */
162
242
  async planTransfer(input) {
163
243
  const result = await this.control.request("transfer.plan", compact({
164
244
  sources: input.sources,
165
245
  destinations: input.destinations,
166
246
  name: input.name,
167
- test_mode: input.testMode || undefined,
168
247
  chunk_size: input.chunkSize,
169
248
  urls_expires_at: input.urlsExpiresAt,
170
249
  signed_url_flow: input.signedUrlFlow ?? "signed_url"
@@ -173,6 +252,14 @@ export class BeamClient {
173
252
  validateCompactTransferPlan(result.plan_descriptor, result.signed_url_flow);
174
253
  return result;
175
254
  }
255
+ /**
256
+ * Prepares a transfer from already-signed HTTP sources and destinations (`transfer.prepare`).
257
+ *
258
+ * Source and destination credentials must not be restricted to specific IP addresses or
259
+ * networks (for example Cloudflare R2 API-token client IP filtering, S3 bucket policies with
260
+ * `aws:SourceIp`, or VPC-only endpoints). Beam moves data through many workers on different
261
+ * networks, so restricted credentials make the transfer fail.
262
+ */
176
263
  prepareTransfer(input) {
177
264
  return this.prepareTransferWithRequestKey(input);
178
265
  }
@@ -187,7 +274,6 @@ export class BeamClient {
187
274
  sources: input.sources,
188
275
  destinations: input.destinations,
189
276
  name: input.name,
190
- test_mode: input.testMode || undefined,
191
277
  chunk_size: input.chunkSize,
192
278
  urls_expires_at: input.urlsExpiresAt,
193
279
  signed_url_flow: input.signedUrlFlow ?? "signed_url"
@@ -274,6 +360,15 @@ export class BeamClient {
274
360
  releaseInitialStream();
275
361
  }
276
362
  }
363
+ /**
364
+ * Signs provider sources and destinations, prepares the transfer, streams its signed routes
365
+ * and, unless `distribute` is false, starts it.
366
+ *
367
+ * Source and destination credentials must not be restricted to specific IP addresses or
368
+ * networks (for example Cloudflare R2 API-token client IP filtering, S3 bucket policies with
369
+ * `aws:SourceIp`, or VPC-only endpoints). Beam moves data through many workers on different
370
+ * networks, so restricted credentials make the transfer fail.
371
+ */
277
372
  async prepareProviderTransfer(input) {
278
373
  return this.executeProviderTransfer(input);
279
374
  }
@@ -300,10 +395,9 @@ export class BeamClient {
300
395
  destinationsById.set(preparedDestination.destination_id, destination);
301
396
  });
302
397
  await input.throwIfCancelled?.();
303
- const discoveryStarted = performance.now();
304
- const preparedSources = await Promise.all(retainedRecoveryInput.sources.map((source, index) => prepareProviderSource(source, { index, expiresIn, fetchImpl: this.fetchImpl, signal: input.signal })));
398
+ const discoveryTelemetry = new SdkPerformanceCollector();
399
+ const preparedSources = await discoveryTelemetry.measure("sdk.discovery", () => Promise.all(retainedRecoveryInput.sources.map((source, index) => prepareProviderSource(source, { index, expiresIn, fetchImpl: this.fetchImpl, signal: input.signal }))));
305
400
  await input.throwIfCancelled?.();
306
- const discoveryMs = performance.now() - discoveryStarted;
307
401
  const huggingFace = await this.planHuggingFaceUploads({
308
402
  sources: retainedRecoveryInput.sources,
309
403
  preparedSources,
@@ -317,7 +411,6 @@ export class BeamClient {
317
411
  sources: preparedSources,
318
412
  destinations: preparedDestinations,
319
413
  name: input.name,
320
- testMode: input.testMode,
321
414
  chunkSize: huggingFace.chunkSize ?? input.chunkSize,
322
415
  signedUrlFlow: requestedSignedUrlFlow,
323
416
  ...(resume ? { transferId: resume.transferId } : { idempotencyKey: input.idempotencyKey, routeGenerationId: input.routeGenerationId })
@@ -353,13 +446,18 @@ export class BeamClient {
353
446
  const assertOwnership = () => input.signal?.throwIfAborted();
354
447
  const routeStreamLock = new AsyncMutex();
355
448
  const releaseInitialStream = await routeStreamLock.acquire();
449
+ const sourceSignatureHistory = new SourceSignatureHistory(!resume);
356
450
  const streamPreparedRoutes = async (routeGenerationId, recoveryReplay) => {
357
- const telemetry = new SdkPerformanceCollector();
358
- if (!recoveryReplay)
359
- telemetry.observe("sdk.discovery", discoveryMs);
451
+ const telemetry = new SdkPerformanceCollector(sourceSignatureHistory);
452
+ telemetry.seedDiscovery(discoveryTelemetry);
453
+ telemetry.gauge("signing_configured_limit", this.routeSigningConcurrency);
454
+ telemetry.gauge("multipart_configured_limit", this.multipartControlConcurrency);
455
+ telemetry.gauge("signing_limit", this.routeSigningConcurrency);
456
+ telemetry.gauge("multipart_limit", this.multipartControlConcurrency);
360
457
  const pendingRoutes = new Set();
361
458
  let signingConcurrency = this.routeSigningConcurrency;
362
459
  let signedInWindow = 0;
460
+ let activeSigning = 0;
363
461
  let signingWindowStartedAt = performance.now();
364
462
  let routeStream = null;
365
463
  let routeStreamBeginAttempted = false;
@@ -420,14 +518,14 @@ export class BeamClient {
420
518
  assertOwnership();
421
519
  recoveryMultipartUploads.set(state.manifest.multipart_group_id, state);
422
520
  validateMultipartGroupManifest([state.manifest], prepared.transfer_id);
423
- await input.onMultipartGroupReady?.(multipartGroupIdentity(prepared.transfer_id, state.manifest));
521
+ await telemetry.measure("sdk.multipart_callback", async () => input.onMultipartGroupReady?.(multipartGroupIdentity(prepared.transfer_id, state.manifest)));
424
522
  await routeStream.addManifestGroups([state.manifest]);
425
523
  multipartGroupWaiters.get(state.manifest.multipart_group_id)?.resolve(state);
426
524
  },
427
525
  onGroupFailed: (groupId, error) => multipartGroupWaiters.get(groupId)?.reject(error)
428
526
  }));
429
527
  const streamNextCompletedRoute = async () => {
430
- const settled = await Promise.race([...pendingRoutes].map((pending) => pending.then((route) => ({ pending, route }))));
528
+ const settled = await telemetry.measure("sdk.producer_wait", () => Promise.race([...pendingRoutes].map((pending) => pending.then((route) => ({ pending, route })))));
431
529
  pendingRoutes.delete(settled.pending);
432
530
  assertOwnership();
433
531
  await routeStream.addRoute(settled.route);
@@ -436,6 +534,8 @@ export class BeamClient {
436
534
  const elapsedMs = performance.now() - signingWindowStartedAt;
437
535
  if (!this.routeSigningConcurrencyOverridden && elapsedMs > 4_000 && signingConcurrency < 256) {
438
536
  signingConcurrency = Math.min(256, signingConcurrency * 2);
537
+ telemetry.increment("concurrency_changes");
538
+ telemetry.gauge("signing_limit", signingConcurrency);
439
539
  }
440
540
  signedInWindow = 0;
441
541
  signingWindowStartedAt = performance.now();
@@ -445,10 +545,9 @@ export class BeamClient {
445
545
  await throwIfCancelled?.(prepared.transfer_id);
446
546
  // This promise belongs to this chunk and signing generation only. Its
447
547
  // immutable credential/range identity and actual expiry cannot be renewed.
448
- const sourceGrant = signSourceChunk({ source: sourcesById.get(chunk.source_id), fallbackUrl: chunk.source_url, chunk, expiresIn, fetchImpl: this.fetchImpl });
548
+ let sourceUses = 0;
549
+ const sourceGrant = telemetry.measure("sdk.source_signing", () => signSourceChunk({ source: sourcesById.get(chunk.source_id), fallbackUrl: chunk.source_url, chunk, expiresIn, fetchImpl: this.fetchImpl })).then(grant => { telemetry.sourceCreated(chunk.chunk_index); return grant; });
449
550
  void sourceGrant.catch(() => { });
450
- telemetry.counters.source_signatures++;
451
- telemetry.counters.source_reuses += Math.max(0, chunk.destinations.length - 1);
452
551
  for (const target of chunk.destinations) {
453
552
  const pendingRoute = (async () => {
454
553
  await throwIfCancelled?.(prepared.transfer_id);
@@ -465,14 +564,16 @@ export class BeamClient {
465
564
  const multipartGroupId = multipartGroupStateKey(prepared.transfer_id, target.destination_id, chunk.source_id, finalObjectKey);
466
565
  const upload = isDirectPutDestination(destination)
467
566
  ? undefined
468
- : await multipartGroupWaiters.get(multipartGroupId)?.promise;
567
+ : await telemetry.measure("sdk.multipart_ready_wait", async () => multipartGroupWaiters.get(multipartGroupId)?.promise);
469
568
  if (!isDirectPutDestination(destination) && !upload)
470
569
  throw new Error(`multipart group manifest is missing for ${chunk.source_id}:${target.destination_id}`);
570
+ telemetry.gauge("signing_active_peak", ++activeSigning);
471
571
  return telemetry.measure("sdk.signing", () => this.signProviderRoute({
472
572
  chunk,
473
573
  target,
474
574
  source,
475
- sourceGrant,
575
+ sourceGrant: telemetry.measure("sdk.source_grant_wait", () => sourceGrant).then(grant => { if (sourceUses++ > 0)
576
+ telemetry.counters.source_reuses++; return grant; }),
476
577
  destination,
477
578
  expiresIn,
478
579
  ...(upload ? { upload } : {}),
@@ -483,11 +584,12 @@ export class BeamClient {
483
584
  partNumber: typeof metadata.part_number === "number"
484
585
  ? metadata.part_number
485
586
  : multipartPartNumber(chunk.source_chunk_index),
486
- }));
587
+ })).finally(() => { activeSigning--; });
487
588
  })();
488
589
  pendingRoutes.add(pendingRoute);
590
+ telemetry.gauge("signing_pending_peak", pendingRoutes.size);
489
591
  if (pendingRoutes.size >= signingConcurrency)
490
- await streamNextCompletedRoute();
592
+ await telemetry.measure("sdk.signing_queue", streamNextCompletedRoute);
491
593
  }
492
594
  }
493
595
  while (pendingRoutes.size)
@@ -1127,6 +1229,14 @@ export class BeamClient {
1127
1229
  for (const transferId of [...this.recoverySigners.keys()])
1128
1230
  this.stopRecoverySigner(transferId);
1129
1231
  }
1232
+ /**
1233
+ * Creates a raw transfer and distributes it.
1234
+ *
1235
+ * Source and destination credentials must not be restricted to specific IP addresses or
1236
+ * networks (for example Cloudflare R2 API-token client IP filtering, S3 bucket policies with
1237
+ * `aws:SourceIp`, or VPC-only endpoints). Beam moves data through many workers on different
1238
+ * networks, so restricted credentials make the transfer fail.
1239
+ */
1130
1240
  async createAndDistribute(input) {
1131
1241
  const transfer = await this.createRawTransfer(input);
1132
1242
  if (transfer.success) {
@@ -1134,6 +1244,12 @@ export class BeamClient {
1134
1244
  }
1135
1245
  return transfer;
1136
1246
  }
1247
+ /**
1248
+ * Waits until the transfer completes. A failed transfer rejects with a
1249
+ * {@link BeamTransferFailedError} carrying BeamCore's `error_message` verbatim, or with its
1250
+ * {@link BeamStorageAccessError} subclass when the source or destination storage refused
1251
+ * Beam's requests (`source_access_denied`, `destination_access_denied`).
1252
+ */
1137
1253
  async waitForTransfer(transferId, options = {}) {
1138
1254
  const timeoutMs = options.timeoutMs ?? 300_000;
1139
1255
  const pollIntervalMs = options.pollIntervalMs ?? 15_000;
@@ -1166,7 +1282,7 @@ export class BeamClient {
1166
1282
  return status;
1167
1283
  }
1168
1284
  if (status.status === "failed") {
1169
- throw new Error(`Transfer failed: ${status.error_message ?? "unknown error"}`);
1285
+ throw transferFailedError(transferId, status.error_message);
1170
1286
  }
1171
1287
  if (status.status === "cancelled") {
1172
1288
  throw new Error("Transfer cancelled");
@@ -1252,6 +1368,7 @@ class RouteStreamSender {
1252
1368
  routeCount = 0;
1253
1369
  sendTail = Promise.resolve();
1254
1370
  sendError = null;
1371
+ sending = false;
1255
1372
  constructor(control, options) {
1256
1373
  this.control = control;
1257
1374
  this.options = options;
@@ -1259,6 +1376,7 @@ class RouteStreamSender {
1259
1376
  this.telemetry = options.telemetry ?? new SdkPerformanceCollector();
1260
1377
  }
1261
1378
  async begin() {
1379
+ this.telemetry.startDelaySampling();
1262
1380
  await this.control.request("transfer.route_stream.begin", compact({
1263
1381
  transfer_id: this.options.transferId,
1264
1382
  route_generation_id: this.options.routeGenerationId,
@@ -1275,6 +1393,7 @@ class RouteStreamSender {
1275
1393
  });
1276
1394
  }
1277
1395
  async addManifestGroups(groups) {
1396
+ const started = performance.now();
1278
1397
  if (!groups.length)
1279
1398
  return;
1280
1399
  await Promise.all(groups.map(async (group) => {
@@ -1290,8 +1409,12 @@ class RouteStreamSender {
1290
1409
  idempotencyKey: `transfer:${this.options.transferId}:route-stream:${this.streamId}:manifest:${identity}`
1291
1410
  });
1292
1411
  }));
1412
+ this.telemetry.observe("sdk.manifest_ack", performance.now() - started);
1413
+ this.telemetry.mark("first_manifest_ms");
1293
1414
  }
1294
1415
  async addRoute(route) {
1416
+ this.telemetry.mark("first_route_ms");
1417
+ const assemblyStarted = performance.now();
1295
1418
  const deliveryIndex = signedRouteDeliveryIndex(route);
1296
1419
  if (deliveryIndex === undefined) {
1297
1420
  throw new Error("route delivery_index is required");
@@ -1305,6 +1428,8 @@ class RouteStreamSender {
1305
1428
  this.seenDeliveryIndices.add(deliveryIndex);
1306
1429
  this.routeCount += 1;
1307
1430
  this.batch.push({ ...route, delivery_index: deliveryIndex });
1431
+ this.telemetry.gauge("buffered_batches_peak", 1 + Number(this.sending));
1432
+ this.telemetry.observe("sdk.route_assembly", performance.now() - assemblyStarted);
1308
1433
  if (this.batch.length >= ROUTE_STREAM_BATCH_ROUTES) {
1309
1434
  await this.enqueueFlush();
1310
1435
  }
@@ -1314,12 +1439,14 @@ class RouteStreamSender {
1314
1439
  this.seenDeliveryIndices.size !== this.options.totalRoutes) {
1315
1440
  throw new Error(`route stream has incomplete or duplicate delivery indices: received ${this.routeCount} of ${this.options.totalRoutes} routes`);
1316
1441
  }
1442
+ this.telemetry.mark("final_flush_ms");
1317
1443
  await this.enqueueFlush();
1318
1444
  await this.sendTail;
1319
1445
  if (this.sendError)
1320
1446
  throw this.sendError;
1321
1447
  this.telemetry.observe("sdk.preparation", performance.now() - this.started);
1322
- const sdkPerformance = this.telemetry.snapshot();
1448
+ this.telemetry.finish();
1449
+ const sdkPerformance = this.telemetry.snapshot(this.control.supportsPerformanceV2?.(this.options.transferId) ?? false);
1323
1450
  this.options.onDiagnostics?.(sdkPerformance);
1324
1451
  return this.control.request("transfer.route_stream.complete", {
1325
1452
  sdk_performance: sdkPerformance,
@@ -1341,6 +1468,7 @@ class RouteStreamSender {
1341
1468
  if (this.sendError)
1342
1469
  throw this.sendError;
1343
1470
  const routes = this.batch.splice(0, this.batch.length);
1471
+ const encodingStarted = performance.now();
1344
1472
  const chunks = this.control.splitRoutesForPayload("transfer.route_stream.batch", {
1345
1473
  transfer_id: this.options.transferId,
1346
1474
  route_generation_id: this.options.routeGenerationId,
@@ -1348,19 +1476,27 @@ class RouteStreamSender {
1348
1476
  batch_id: `${this.streamId}:estimate`,
1349
1477
  batch_index: this.batchIndex
1350
1478
  }, routes);
1479
+ this.telemetry.observe("sdk.batch_encode", performance.now() - encodingStarted);
1480
+ this.sending = true;
1351
1481
  for (const chunk of chunks) {
1482
+ const queuedAt = performance.now();
1352
1483
  const batchIndex = this.batchIndex;
1353
1484
  this.batchIndex += 1;
1354
1485
  this.sendTail = this.sendTail.then(async () => {
1486
+ this.telemetry.observe("sdk.batch_queue", performance.now() - queuedAt);
1355
1487
  if (this.sendError)
1356
1488
  return;
1357
1489
  try {
1490
+ const checksumStarted = performance.now();
1358
1491
  const chunkChecksum = new RouteKeysChecksum();
1359
1492
  await chunkChecksum.addMany(chunk.map(routeKeyForSignedRoute));
1360
1493
  this.checksum.merge(chunkChecksum);
1361
1494
  const routeChecksum = chunkChecksum.value();
1362
1495
  const coordinateChecksum = await routeCoordinateChecksum(chunk);
1363
1496
  const batchId = `${this.streamId}:${batchIndex}:${coordinateChecksum}`;
1497
+ this.telemetry.observe("sdk.batch_checksum", performance.now() - checksumStarted);
1498
+ this.telemetry.gauge("batch_routes_max", chunk.length);
1499
+ this.telemetry.mark("first_batch_ms");
1364
1500
  this.telemetry.counters.route_batches++;
1365
1501
  await this.telemetry.measure("sdk.batch_ack", () => this.control.request("transfer.route_stream.batch", {
1366
1502
  transfer_id: this.options.transferId,
@@ -1381,8 +1517,10 @@ class RouteStreamSender {
1381
1517
  }
1382
1518
  });
1383
1519
  }
1520
+ this.sendTail = this.sendTail.finally(() => { this.sending = false; });
1384
1521
  }
1385
1522
  async abort() {
1523
+ this.telemetry.finish();
1386
1524
  this.sendError ??= new Error("route_stream_aborted");
1387
1525
  await this.sendTail;
1388
1526
  }
@@ -1672,7 +1810,12 @@ function restoreProviderMultipartIdentities(prepared, destinations, identities,
1672
1810
  }
1673
1811
  async function createMultipartGroupManifest(input) {
1674
1812
  const groups = input.prepared.plan_descriptor.sources.flatMap((source) => input.prepared.plan_descriptor.destinations.map((destination) => ({ source, destination })));
1813
+ const queuedAt = performance.now();
1814
+ const telemetry = currentSdkPerformance();
1815
+ let active = 0;
1675
1816
  const results = await mapOrderedWithConcurrency(groups, input.concurrency, async ({ source, destination }) => {
1817
+ telemetry?.observe("sdk.multipart_queue", performance.now() - queuedAt);
1818
+ telemetry?.gauge("multipart_active_peak", ++active);
1676
1819
  let multipartGroupId = null;
1677
1820
  try {
1678
1821
  const destinationConfig = input.destinationsById.get(destination.destination_id);
@@ -1690,12 +1833,12 @@ async function createMultipartGroupManifest(input) {
1690
1833
  const finalObjectMetadata = { "beam-transfer-id": input.prepared.transfer_id };
1691
1834
  input.signal?.throwIfAborted();
1692
1835
  const retainedUpload = input.multipartUploads.get(multipartGroupId);
1693
- const uploadId = retainedUpload?.uploadId ?? await createMultipartUpload({
1836
+ const uploadId = retainedUpload?.uploadId ?? await measureSdkPhase("sdk.multipart_provider", () => createMultipartUpload({
1694
1837
  destination: destinationConfig,
1695
1838
  objectKey: finalObjectKey,
1696
1839
  metadata: finalObjectMetadata,
1697
1840
  signal: input.signal
1698
- });
1841
+ }));
1699
1842
  const createdUpload = !retainedUpload;
1700
1843
  if (createdUpload) {
1701
1844
  input.multipartUploads.set(multipartGroupId, {
@@ -1758,6 +1901,9 @@ async function createMultipartGroupManifest(input) {
1758
1901
  input.onGroupFailed(multipartGroupId, error);
1759
1902
  return { ok: false, error };
1760
1903
  }
1904
+ finally {
1905
+ active--;
1906
+ }
1761
1907
  });
1762
1908
  const failure = results.find((result) => !result.ok);
1763
1909
  if (failure && !failure.ok) {
package/dist/models.d.ts CHANGED
@@ -1,16 +1,22 @@
1
1
  export type SignedUrlFlow = "signed_url";
2
2
  export interface SdkPerformanceSummary {
3
- schema_version: "sdk-performance/v1";
3
+ gauges?: Record<string, number>;
4
+ milestones?: Record<string, number>;
5
+ unmeasured?: string[];
6
+ detail_dropped?: number;
7
+ schema_version: "sdk-performance/v1" | "sdk-performance/v2";
4
8
  measurements: Array<{
5
- name: "sdk.discovery" | "sdk.multipart_create" | "sdk.signing" | "sdk.batch_ack" | "sdk.buffer_wait" | "sdk.preparation";
9
+ name: string;
6
10
  count: number;
7
11
  work_ms: number;
8
12
  max_ms: number;
13
+ histogram?: number[];
9
14
  }>;
10
15
  counters: {
11
16
  source_signatures: number;
12
17
  source_reuses: number;
13
18
  route_batches: number;
19
+ source_renewals?: number;
14
20
  };
15
21
  }
16
22
  export interface BeamClientOptions {
@@ -28,6 +34,14 @@ export interface BeamClientOptions {
28
34
  multipartControlConcurrency?: number;
29
35
  fetch?: typeof fetch;
30
36
  }
37
+ /**
38
+ * Raw transfer source.
39
+ *
40
+ * Source credentials must not be restricted to specific IP addresses or networks (for example
41
+ * Cloudflare R2 API-token client IP filtering, S3 bucket policies with `aws:SourceIp`, or
42
+ * VPC-only endpoints). Beam moves data through many workers on different networks, so
43
+ * restricted credentials make the transfer fail.
44
+ */
31
45
  export interface SourceConfig {
32
46
  type: string;
33
47
  bucket?: string;
@@ -41,6 +55,14 @@ export interface SourceConfig {
41
55
  url?: string;
42
56
  headers?: Record<string, string>;
43
57
  }
58
+ /**
59
+ * Raw transfer destination.
60
+ *
61
+ * Destination credentials must not be restricted to specific IP addresses or networks (for
62
+ * example Cloudflare R2 API-token client IP filtering, S3 bucket policies with `aws:SourceIp`, or
63
+ * VPC-only endpoints). Beam moves data through many workers on different networks, so
64
+ * restricted credentials make the transfer fail.
65
+ */
44
66
  export interface DestConfig {
45
67
  type: string;
46
68
  bucket?: string;
@@ -67,7 +89,6 @@ export interface TransferCreateRequest {
67
89
  merkle_root?: string;
68
90
  chunk_hashes?: string[];
69
91
  callbacks?: CallbackConfig[];
70
- test_mode?: boolean;
71
92
  progressive_mode?: boolean;
72
93
  signed_url_flow: SignedUrlFlow;
73
94
  }
@@ -80,10 +101,6 @@ export interface RawTransferCreateInput {
80
101
  merkleRoot?: string;
81
102
  chunkHashes?: string[];
82
103
  callbacks?: CallbackConfig[];
83
- /**
84
- * Maps to BeamCore test_mode.
85
- */
86
- testMode?: boolean;
87
104
  progressiveMode?: boolean;
88
105
  signedUrlFlow?: SignedUrlFlow;
89
106
  idempotencyKey?: string;
@@ -158,6 +175,21 @@ export interface IntegrityAuditChallenge {
158
175
  chunks: IntegrityAuditChallengeChunk[];
159
176
  }
160
177
  export interface TransferPerformance {
178
+ recovery_bandwidth?: {
179
+ excluded_tasks: number;
180
+ accepted_bytes: number;
181
+ };
182
+ deadline?: {
183
+ version: "assignment-deadline/v1";
184
+ decisions: number;
185
+ selected_min_ms: number;
186
+ selected_max_ms: number;
187
+ estimated_max_ms: number;
188
+ sample_count_max: number;
189
+ data_age_max_ms: number | null;
190
+ reasons: Record<string, number>;
191
+ };
192
+ sdk_detail?: SdkPerformanceSummary;
161
193
  schema_version: "transfer-performance/v1";
162
194
  runtime_epoch: string;
163
195
  coverage: "complete" | "restarted";
@@ -177,6 +209,7 @@ export interface TransferPerformance {
177
209
  source_signatures: number;
178
210
  source_reuses: number;
179
211
  route_batches: number;
212
+ source_renewals?: number;
180
213
  };
181
214
  detail_dropped: number;
182
215
  unmeasured?: string[];
@@ -232,7 +265,17 @@ export interface TransferTerminalSignalWaiter {
232
265
  wait(timeoutMs: number): Promise<TransferTerminalEvent | null>;
233
266
  close(): Promise<void>;
234
267
  }
268
+ /**
269
+ * Amazon S3 source or destination.
270
+ *
271
+ * Credentials must not be restricted to specific IP addresses or networks (for example
272
+ * Cloudflare R2 API-token client IP filtering, S3 bucket policies with `aws:SourceIp`, or
273
+ * VPC-only endpoints). Beam moves data through many workers on different networks, so
274
+ * restricted credentials make the transfer fail.
275
+ */
235
276
  export interface S3ProviderConfig {
277
+ /** Physical storage location; independent of the signing region. */
278
+ storage_location?: string;
236
279
  provider: "s3";
237
280
  id?: string;
238
281
  bucket: string;
@@ -243,7 +286,17 @@ export interface S3ProviderConfig {
243
286
  session_token?: string;
244
287
  endpoint_url?: string;
245
288
  }
289
+ /**
290
+ * Cloudflare R2 source or destination.
291
+ *
292
+ * Credentials must not be restricted to specific IP addresses or networks (for example
293
+ * Cloudflare R2 API-token client IP filtering, S3 bucket policies with `aws:SourceIp`, or
294
+ * VPC-only endpoints). Beam moves data through many workers on different networks, so
295
+ * restricted credentials make the transfer fail.
296
+ */
246
297
  export interface R2ProviderConfig {
298
+ /** Physical storage location; independent of the signing region. */
299
+ storage_location?: string;
247
300
  provider: "r2";
248
301
  id?: string;
249
302
  bucket: string;
@@ -253,7 +306,17 @@ export interface R2ProviderConfig {
253
306
  account_id?: string;
254
307
  endpoint_url?: string;
255
308
  }
309
+ /**
310
+ * S3-compatible source or destination (MinIO, Wasabi, Backblaze B2, DigitalOcean Spaces and others).
311
+ *
312
+ * Credentials must not be restricted to specific IP addresses or networks (for example
313
+ * Cloudflare R2 API-token client IP filtering, S3 bucket policies with `aws:SourceIp`, or
314
+ * VPC-only endpoints). Beam moves data through many workers on different networks, so
315
+ * restricted credentials make the transfer fail.
316
+ */
256
317
  export interface S3CompatibleProviderConfig {
318
+ /** Physical storage location; independent of the signing region. */
319
+ storage_location?: string;
257
320
  provider: string;
258
321
  driver?: "s3-compatible";
259
322
  id?: string;
@@ -267,7 +330,17 @@ export interface S3CompatibleProviderConfig {
267
330
  force_path_style?: boolean;
268
331
  account_id?: string;
269
332
  }
333
+ /**
334
+ * Hippius source or destination.
335
+ *
336
+ * Credentials must not be restricted to specific IP addresses or networks (for example
337
+ * Cloudflare R2 API-token client IP filtering, S3 bucket policies with `aws:SourceIp`, or
338
+ * VPC-only endpoints). Beam moves data through many workers on different networks, so
339
+ * restricted credentials make the transfer fail.
340
+ */
270
341
  export interface HippiusProviderConfig {
342
+ /** Physical storage location; independent of the signing region. */
343
+ storage_location?: string;
271
344
  provider: "hippius";
272
345
  id?: string;
273
346
  bucket: string;
@@ -276,7 +349,17 @@ export interface HippiusProviderConfig {
276
349
  base_url?: string;
277
350
  }
278
351
  export type HuggingFaceRepoType = "model" | "dataset" | "space" | "kernel" | "bucket";
352
+ /**
353
+ * Hugging Face Hub source or destination.
354
+ *
355
+ * The token and any credentials must not be restricted to specific IP addresses or networks (for example
356
+ * Cloudflare R2 API-token client IP filtering, S3 bucket policies with `aws:SourceIp`, or
357
+ * VPC-only endpoints). Beam moves data through many workers on different networks, so
358
+ * restricted credentials make the transfer fail.
359
+ */
279
360
  export interface HuggingFaceProviderConfig {
361
+ /** Physical storage location; independent of the signing region. */
362
+ storage_location?: string;
280
363
  provider: "huggingface";
281
364
  id?: string;
282
365
  /** Namespace and repo or bucket name separated by a slash, for example `org/dataset`. */
@@ -328,13 +411,19 @@ export declare const HuggingFaceProviderConfig: Readonly<{
328
411
  export interface ProviderTransferCreateInput {
329
412
  /** Ownership fence. Aborting stops signing/replay without cancelling a replacement owner. */
330
413
  signal?: AbortSignal;
414
+ /**
415
+ * Storage to read from. Source credentials must not be restricted to specific IP addresses or
416
+ * networks (for example Cloudflare R2 API-token client IP filtering, S3 bucket policies with
417
+ * `aws:SourceIp`, or VPC-only endpoints). Beam moves data through many workers on different
418
+ * networks, so restricted credentials make the transfer fail.
419
+ */
331
420
  sources: ProviderSourceConfig[];
332
- destinations: ProviderDestinationConfig[];
333
- name?: string;
334
421
  /**
335
- * Maps to BeamCore test_mode.
422
+ * Storage to write to. Destination credentials must not be restricted to specific IP addresses
423
+ * or networks, for the same reason as `sources`.
336
424
  */
337
- testMode?: boolean;
425
+ destinations: ProviderDestinationConfig[];
426
+ name?: string;
338
427
  expiresIn?: number;
339
428
  /**
340
429
  * Defaults to true. Set to false to only prepare and stream signed routes without distribution.
@@ -368,6 +457,11 @@ export interface ProviderMultipartGroupIdentity {
368
457
  expectedPartCount: number;
369
458
  expiresAt: string;
370
459
  }
460
+ /**
461
+ * HTTP source for planning and preparing. Its URL and headers must work from any network: Beam
462
+ * moves data through many workers on different networks, so IP- or network-restricted URLs make
463
+ * the transfer fail.
464
+ */
371
465
  export interface PlanningHttpSource {
372
466
  source_id: string;
373
467
  type: "http";
@@ -479,7 +573,6 @@ export interface TransferPrepareResponse {
479
573
  success: boolean;
480
574
  transfer_id: string;
481
575
  transfer_key?: string;
482
- test_mode?: boolean;
483
576
  chunk_size?: number;
484
577
  total_size?: number;
485
578
  total_sources?: number;
@@ -135,6 +135,7 @@ export declare class BeamTransferControl {
135
135
  private readonly recoveryOperations;
136
136
  private readonly recoveryRequestedEpoch;
137
137
  private readonly runtimeEpochs;
138
+ private readonly runtimeCapabilities;
138
139
  private helloTimer;
139
140
  constructor(options: TransferControlOptions);
140
141
  close(): Promise<void>;
@@ -155,6 +156,7 @@ export declare class BeamTransferControl {
155
156
  splitRoutesForPayload(messageType: TransferClientMessageType, basePayload: Record<string, unknown>, routes: SignedChunkRoute[]): SignedChunkRoute[][];
156
157
  private connection;
157
158
  private ensureHelloMonitor;
159
+ supportsPerformanceV2(transferId: string): boolean;
158
160
  private pollRuntimeHello;
159
161
  private observeRuntimeEpoch;
160
162
  private recoverLease;
@@ -1,3 +1,4 @@
1
+ import { currentSdkPerformance } from "./performance.js";
1
2
  import { encode, decode } from "@msgpack/msgpack";
2
3
  import { connect } from "nats";
3
4
  export const TRANSFER_CLIENT_CONTROL_SCHEMA_VERSION = "transfer-client-control/v7";
@@ -65,6 +66,7 @@ export class BeamTransferControl {
65
66
  recoveryOperations = new Map();
66
67
  recoveryRequestedEpoch = new Map();
67
68
  runtimeEpochs = new Map();
69
+ runtimeCapabilities = new Map();
68
70
  helloTimer = null;
69
71
  constructor(options) {
70
72
  this.apiKey = options.apiKey;
@@ -277,7 +279,12 @@ export class BeamTransferControl {
277
279
  producer: "sdk",
278
280
  payload
279
281
  };
282
+ const encodeStarted = performance.now();
280
283
  const bytes = encode(envelope);
284
+ if (messageType === "transfer.route_stream.batch") {
285
+ currentSdkPerformance()?.observe("sdk.batch_encode", performance.now() - encodeStarted);
286
+ currentSdkPerformance()?.gauge("batch_bytes_max", bytes.byteLength);
287
+ }
281
288
  if (bytes.byteLength > this.maxPayloadBytes) {
282
289
  throw new Error(`NATS lifecycle request is ${bytes.byteLength} bytes, above maxPayloadBytes=${this.maxPayloadBytes}`);
283
290
  }
@@ -488,10 +495,15 @@ export class BeamTransferControl {
488
495
  }, 5_000);
489
496
  this.helloTimer.unref?.();
490
497
  }
498
+ supportsPerformanceV2(transferId) {
499
+ const value = this.runtimeCapabilities.get(transferShardId(transferId, this.shardCount));
500
+ return !!value && value.until > Date.now() && value.capabilities.includes("sdk-performance/v2");
501
+ }
491
502
  async pollRuntimeHello(shardId) {
492
503
  try {
493
504
  try {
494
- await this.request("runtime.hello", { capabilities: ["integrity-signing/v1"] }, { idempotencyKey: `runtime:hello:${shardId}:integrity-signing`, shardId });
505
+ const hello = await this.request("runtime.hello", { capabilities: ["integrity-signing/v1", "sdk-performance/v2"] }, { idempotencyKey: `runtime:hello:${shardId}:integrity-signing`, shardId });
506
+ this.runtimeCapabilities.set(shardId, { until: Date.now() + 15_000, capabilities: hello.capabilities ?? [] });
495
507
  }
496
508
  catch {
497
509
  await this.request("runtime.hello", {}, { idempotencyKey: `runtime:hello:${shardId}`, shardId });
@@ -510,6 +522,7 @@ export class BeamTransferControl {
510
522
  return;
511
523
  if (previous.runtimeEpoch === runtimeEpoch && previous.transportEpoch === transportEpoch)
512
524
  return;
525
+ this.runtimeCapabilities.delete(shardId);
513
526
  if (previous.runtimeEpoch !== runtimeEpoch) {
514
527
  this.authToken = null;
515
528
  this.authTokenExpiresAt = 0;
@@ -1,12 +1,39 @@
1
1
  import type { SdkPerformanceSummary } from './models.js';
2
+ export declare const currentSdkPerformance: () => SdkPerformanceCollector | undefined;
3
+ export declare const measureSdkPhase: <T>(name: string, work: () => Promise<T>) => Promise<T>;
4
+ /** Bounded identities only: no URLs, credentials or grant bodies. */
5
+ export declare class SourceSignatureHistory {
6
+ complete: boolean;
7
+ private readonly bits;
8
+ constructor(complete?: boolean);
9
+ created(index: number): boolean;
10
+ }
2
11
  export declare class SdkPerformanceCollector {
12
+ private readonly sourceHistory?;
13
+ constructor(sourceHistory?: SourceSignatureHistory | undefined);
14
+ private sourceRenewals;
15
+ sourceCreated(index: number): void;
16
+ private readonly started;
17
+ private readonly details;
18
+ private readonly cpu;
19
+ private finished;
20
+ private delayTimer?;
3
21
  private readonly measurements;
22
+ private readonly gauges;
23
+ private readonly milestones;
24
+ private dropped;
4
25
  readonly counters: {
5
26
  source_signatures: number;
6
27
  source_reuses: number;
7
28
  route_batches: number;
8
29
  };
9
- observe(name: SdkPerformanceSummary['measurements'][number]['name'], milliseconds: number): void;
10
- measure<T>(name: SdkPerformanceSummary['measurements'][number]['name'], work: () => Promise<T>): Promise<T>;
11
- snapshot(): SdkPerformanceSummary;
30
+ observe(name: string, milliseconds: number): void;
31
+ gauge(name: string, value: number): void;
32
+ increment(name: string): void;
33
+ seedDiscovery(other: SdkPerformanceCollector): void;
34
+ mark(name: string): void;
35
+ measure<T>(name: string, work: () => Promise<T>): Promise<T>;
36
+ startDelaySampling(): void;
37
+ finish(): void;
38
+ snapshot(v2?: boolean): SdkPerformanceSummary;
12
39
  }
@@ -1,25 +1,124 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ const NAMES = ["sdk.discovery", "sdk.multipart_create", "sdk.signing", "sdk.batch_ack", "sdk.buffer_wait", "sdk.preparation", "sdk.source_signing", "sdk.destination_signing", "sdk.source_grant_wait", "sdk.multipart_ready_wait", "sdk.multipart_provider", "sdk.multipart_queue", "sdk.multipart_callback", "sdk.manifest_ack", "sdk.provider_client_setup", "sdk.metadata_request", "sdk.route_assembly", "sdk.batch_encode", "sdk.batch_checksum", "sdk.batch_queue", "sdk.signing_queue", "sdk.process_cpu", "sdk.event_loop_delay", "sdk.producer_wait"];
3
+ const BOUNDS = [0.1, .5, 1, 2, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000, 120000];
4
+ const GAUGES = new Set(["signing_configured_limit", "multipart_configured_limit", "signing_limit", "multipart_limit", "signing_active_peak", "signing_pending_peak", "multipart_active_peak", "buffered_batches_peak", "batch_routes_max", "batch_bytes_max", "concurrency_changes", "source_waiters", "provider_clients_created", "provider_clients_reused"]);
5
+ const MILESTONES = new Set(["first_manifest_ms", "first_route_ms", "first_batch_ms", "final_flush_ms", "prepared_ms"]);
6
+ const context = new AsyncLocalStorage();
7
+ let activeDelaySamplers = 0;
8
+ export const currentSdkPerformance = () => context.getStore();
9
+ export const measureSdkPhase = (name, work) => context.getStore()?.measure(name, work) ?? work();
10
+ /** Bounded identities only: no URLs, credentials or grant bodies. */
11
+ export class SourceSignatureHistory {
12
+ complete;
13
+ bits = new Uint8Array(16_384);
14
+ constructor(complete = true) {
15
+ this.complete = complete;
16
+ }
17
+ created(index) {
18
+ if (!Number.isSafeInteger(index) || index < 0 || index >= this.bits.length * 8) {
19
+ this.complete = false;
20
+ return false;
21
+ }
22
+ const byte = index >>> 3, mask = 1 << (index & 7);
23
+ const renewed = (this.bits[byte] & mask) !== 0;
24
+ this.bits[byte] |= mask;
25
+ return renewed;
26
+ }
27
+ }
1
28
  export class SdkPerformanceCollector {
29
+ sourceHistory;
30
+ constructor(sourceHistory) {
31
+ this.sourceHistory = sourceHistory;
32
+ }
33
+ sourceRenewals = 0;
34
+ sourceCreated(index) {
35
+ this.counters.source_signatures++;
36
+ if (this.sourceHistory?.created(index))
37
+ this.sourceRenewals++;
38
+ }
39
+ started = performance.now();
40
+ details = process.env.BEAM_SDK_PERFORMANCE_DETAILS !== 'false';
41
+ cpu = process.cpuUsage();
42
+ finished = false;
43
+ delayTimer;
2
44
  measurements = new Map();
45
+ gauges = {};
46
+ milestones = {};
47
+ dropped = 0;
3
48
  counters = { source_signatures: 0, source_reuses: 0, route_batches: 0 };
4
49
  observe(name, milliseconds) {
5
- if (!Number.isFinite(milliseconds) || milliseconds < 0)
50
+ const index = NAMES.indexOf(name);
51
+ if (index < 0 || (!this.details && index >= 6) || !Number.isFinite(milliseconds) || milliseconds < 0 || milliseconds > 86_400_000)
52
+ return;
53
+ const value = this.measurements.get(name) ?? { count: 0, work_ms: 0, max_ms: 0, histogram: Array(18).fill(0) };
54
+ if (value.count >= 1_000_000 || value.work_ms + milliseconds > 86_400_000) {
55
+ this.dropped++;
6
56
  return;
7
- const value = this.measurements.get(name) ?? { count: 0, work_ms: 0, max_ms: 0 };
57
+ }
8
58
  value.count++;
9
59
  value.work_ms += milliseconds;
10
60
  value.max_ms = Math.max(value.max_ms, milliseconds);
61
+ const bin = BOUNDS.findIndex(bound => milliseconds <= bound);
62
+ value.histogram[bin < 0 ? 17 : bin]++;
11
63
  this.measurements.set(name, value);
12
64
  }
65
+ gauge(name, value) {
66
+ if (this.details && GAUGES.has(name) && Number.isFinite(value) && value >= 0)
67
+ this.gauges[name] = Math.min(1_000_000_000, Math.max(this.gauges[name] ?? 0, value));
68
+ }
69
+ increment(name) { this.gauge(name, (this.gauges[name] ?? 0) + 1); }
70
+ seedDiscovery(other) {
71
+ for (const name of ["sdk.discovery", "sdk.metadata_request", "sdk.provider_client_setup"]) {
72
+ const value = other.measurements.get(name);
73
+ if (value)
74
+ this.measurements.set(name, { ...value, histogram: [...value.histogram] });
75
+ }
76
+ for (const name of ["provider_clients_created", "provider_clients_reused"])
77
+ if (other.gauges[name] !== undefined)
78
+ this.gauges[name] = other.gauges[name];
79
+ }
80
+ mark(name) {
81
+ if (this.details && MILESTONES.has(name) && this.milestones[name] === undefined)
82
+ this.milestones[name] = performance.now() - this.started;
83
+ }
13
84
  async measure(name, work) {
14
85
  const start = performance.now();
15
86
  try {
16
- return await work();
87
+ return await context.run(this, work);
17
88
  }
18
89
  finally {
19
90
  this.observe(name, performance.now() - start);
20
91
  }
21
92
  }
22
- snapshot() {
23
- return { schema_version: 'sdk-performance/v1', measurements: [...this.measurements].map(([name, value]) => ({ name: name, ...value })), counters: { ...this.counters } };
93
+ startDelaySampling() {
94
+ if (!this.details || this.delayTimer || this.finished || activeDelaySamplers >= 16)
95
+ return;
96
+ activeDelaySamplers++;
97
+ let last = performance.now();
98
+ this.delayTimer = setInterval(() => {
99
+ const now = performance.now();
100
+ this.observe('sdk.event_loop_delay', Math.max(0, now - last - 100));
101
+ last = now;
102
+ }, 100);
103
+ this.delayTimer.unref();
104
+ }
105
+ finish() {
106
+ if (this.finished)
107
+ return;
108
+ this.finished = true;
109
+ if (this.delayTimer) {
110
+ clearInterval(this.delayTimer);
111
+ this.delayTimer = undefined;
112
+ activeDelaySamplers--;
113
+ }
114
+ const used = process.cpuUsage(this.cpu);
115
+ this.observe('sdk.process_cpu', (used.user + used.system) / 1000);
116
+ this.mark('prepared_ms');
117
+ }
118
+ snapshot(v2 = true) {
119
+ const measurements = [...this.measurements].filter(([name]) => v2 || NAMES.slice(0, 6).includes(name))
120
+ .map(([name, value]) => ({ name, count: value.count, work_ms: value.work_ms, max_ms: value.max_ms, ...(v2 ? { histogram: [...value.histogram] } : {}) }));
121
+ return { schema_version: v2 ? 'sdk-performance/v2' : 'sdk-performance/v1', measurements, counters: { ...this.counters, ...(v2 && this.sourceHistory?.complete ? { source_renewals: this.sourceRenewals } : {}) },
122
+ ...(v2 ? { gauges: { ...this.gauges }, milestones: { ...this.milestones }, unmeasured: NAMES.slice(6).filter(name => !this.measurements.has(name)), detail_dropped: this.dropped } : {}) };
24
123
  }
25
124
  }
@@ -1,3 +1,4 @@
1
+ import { currentSdkPerformance, measureSdkPhase } from "./performance.js";
1
2
  import { AbortMultipartUploadCommand, CompleteMultipartUploadCommand, CreateMultipartUploadCommand, GetObjectCommand, HeadObjectCommand, ListPartsCommand, PutObjectCommand, S3Client, UploadPartCommand } from "@aws-sdk/client-s3";
2
3
  import { UploadPartCopyCommand, DeleteObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";
3
4
  import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
@@ -82,7 +83,7 @@ export async function prepareProviderSource(source, options = {}) {
82
83
  if (isS3CompatibleProvider(source)) {
83
84
  const endpoint = s3CompatibleEndpoint(source);
84
85
  const client = createS3CompatibleClient(source, endpoint);
85
- const head = await client.send(new HeadObjectCommand({ Bucket: source.bucket, Key: source.key }), { abortSignal: options.signal });
86
+ const head = await measureSdkPhase("sdk.metadata_request", () => client.send(new HeadObjectCommand({ Bucket: source.bucket, Key: source.key }), { abortSignal: options.signal }));
86
87
  const getUrl = await getSignedUrl(client, new GetObjectCommand({ Bucket: source.bucket, Key: source.key }), { expiresIn });
87
88
  return {
88
89
  source_id: source.id ?? `src_${index}`,
@@ -113,7 +114,7 @@ export async function prepareProviderSource(source, options = {}) {
113
114
  size,
114
115
  filename: filename(source.key),
115
116
  expires_at: expiresAtIso(expiresIn),
116
- metadata: { bucket: source.bucket, key: source.key, base_url: baseUrl }
117
+ metadata: compactMetadata({ bucket: source.bucket, key: source.key, base_url: baseUrl, storage_location: source.storage_location })
117
118
  };
118
119
  }
119
120
  if (isHuggingFaceProvider(source)) {
@@ -161,7 +162,7 @@ export async function prepareProviderSourceForPlan(source, options = {}) {
161
162
  provider: "hippius",
162
163
  size,
163
164
  filename: filename(source.key),
164
- metadata: { bucket: source.bucket, key: source.key, base_url: baseUrl }
165
+ metadata: compactMetadata({ bucket: source.bucket, key: source.key, base_url: baseUrl, storage_location: source.storage_location })
165
166
  };
166
167
  }
167
168
  if (isHuggingFaceProvider(source)) {
@@ -199,6 +200,7 @@ export function prepareProviderDestination(destination, options = {}) {
199
200
  metadata: {
200
201
  bucket: destination.bucket,
201
202
  key: destination.key,
203
+ storage_location: destination.storage_location,
202
204
  base_url: destination.base_url ?? "https://api.hippius.com"
203
205
  }
204
206
  };
@@ -336,14 +338,14 @@ export async function signDestinationRoute(input) {
336
338
  }
337
339
  const destinationExpiresAt = expiresAtIso(input.expiresIn);
338
340
  const [destUrl, sourceRoute] = await Promise.all([
339
- input.destUrl ?? signDestinationUrl({
341
+ input.destUrl ?? measureSdkPhase("sdk.destination_signing", () => signDestinationUrl({
340
342
  destination: input.destination,
341
343
  objectKey: targetObjectKey,
342
344
  uploadId: input.uploadId,
343
345
  partNumber: input.partNumber,
344
346
  expiresIn: input.expiresIn,
345
347
  fetchImpl: input.fetchImpl,
346
- }),
348
+ })),
347
349
  input.sourceGrant ?? signSourceChunk({
348
350
  source: input.source,
349
351
  fallbackUrl: input.chunk.source_url,
@@ -522,8 +524,11 @@ function s3ClientCacheKey(source, endpoint) {
522
524
  function createS3CompatibleClient(source, endpoint = s3CompatibleEndpoint(source)) {
523
525
  const cacheKey = s3ClientCacheKey(source, endpoint);
524
526
  const cached = s3ClientCache.get(source);
525
- if (cached?.identity === cacheKey)
527
+ if (cached?.identity === cacheKey) {
528
+ currentSdkPerformance()?.increment("provider_clients_reused");
526
529
  return cached.client;
530
+ }
531
+ const setupStarted = performance.now();
527
532
  const client = new S3Client({
528
533
  region: s3CompatibleRegion(source),
529
534
  endpoint,
@@ -536,6 +541,8 @@ function createS3CompatibleClient(source, endpoint = s3CompatibleEndpoint(source
536
541
  }
537
542
  });
538
543
  s3ClientCache.set(source, { identity: cacheKey, client });
544
+ currentSdkPerformance()?.increment("provider_clients_created");
545
+ currentSdkPerformance()?.observe("sdk.provider_client_setup", performance.now() - setupStarted);
539
546
  return client;
540
547
  }
541
548
  export function isS3CompatibleProvider(config) {
@@ -586,6 +593,7 @@ function s3CompatibleMetadata(source, endpoint = s3CompatibleEndpoint(source)) {
586
593
  bucket: source.bucket,
587
594
  key: source.key,
588
595
  region: s3CompatibleRegion(source),
596
+ storage_location: source.storage_location,
589
597
  endpoint_url: endpoint,
590
598
  account_id: "account_id" in source ? source.account_id : undefined
591
599
  });
@@ -599,6 +607,7 @@ export function isHuggingFaceProvider(config) {
599
607
  function huggingFaceMetadata(config, etag, commitHash) {
600
608
  return compactMetadata({
601
609
  driver: "huggingface",
610
+ storage_location: config.storage_location,
602
611
  repo_id: config.repo_id,
603
612
  repo_type: huggingFaceRepoType(config),
604
613
  revision: huggingFaceRevision(config),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beam-network/sdk",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "description": "TypeScript SDK for BEAM transfer creation and management.",
5
5
  "type": "module",
6
6
  "license": "MIT",