@tearleads/client-sdk 0.1.33 → 0.1.34
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/data/blobs/transferPool.js +53 -0
- package/data/blobs/transferPool.js.map +1 -0
- package/data/documents/blob/shared/types.d.ts +2 -2
- package/data/documents/blob/shared/types.js.map +1 -1
- package/data/sync/coordinatorLaneRun.js +154 -0
- package/data/sync/coordinatorLaneRun.js.map +1 -0
- package/data/sync/coordinatorPump.js +72 -169
- package/data/sync/coordinatorPump.js.map +1 -1
- package/data/sync/coordinatorState.d.ts +6 -0
- package/data/sync/coordinatorState.js +3 -1
- package/data/sync/coordinatorState.js.map +1 -1
- package/data/sync/syncCoordinator.js +4 -3
- package/data/sync/syncCoordinator.js.map +1 -1
- package/data/sync/syncLaneConfig.d.ts +2 -0
- package/data/sync/syncLaneConfig.js.map +1 -1
- package/internal/api-client/ApiClient.d.ts +2 -2
- package/internal/api-client/ApiClient.js +4 -4
- package/internal/api-client/ApiClient.js.map +1 -1
- package/internal/api-client/routes/blobs/get.d.ts +2 -2
- package/internal/api-client/routes/blobs/get.js +24 -21
- package/internal/api-client/routes/blobs/get.js.map +1 -1
- package/internal/api-client/routes/blobs/multipartUpload.js +16 -9
- package/internal/api-client/routes/blobs/multipartUpload.js.map +1 -1
- package/internal/api-client/routes/blobs/storageFetchOptions.js +11 -0
- package/internal/api-client/routes/blobs/storageFetchOptions.js.map +1 -0
- package/package.json +1 -1
- package/stores/documents/documentStore/attachmentHydration.js +33 -0
- package/stores/documents/documentStore/attachmentHydration.js.map +1 -1
- package/stores/documents/documentStore/remoteWork.js +1 -1
- package/stores/documents/documentStore/remoteWork.js.map +1 -1
- package/stores/documents/documentStore/sync.js +3 -0
- package/stores/documents/documentStore/sync.js.map +1 -1
- package/sync/reconciliation/documentContentPull.js +7 -3
- package/sync/reconciliation/documentContentPull.js.map +1 -1
- package/sync/reconciliation/idleBackfill.js +14 -2
- package/sync/reconciliation/idleBackfill.js.map +1 -1
- package/sync/reconciliation/knownContainerSweep.js +5 -2
- package/sync/reconciliation/knownContainerSweep.js.map +1 -1
- package/sync/reconciliation/reconcileContainer.js +3 -3
- package/sync/reconciliation/reconcileContainer.js.map +1 -1
- package/sync/reconciliation/service.js +9 -4
- package/sync/reconciliation/service.js.map +1 -1
- package/sync/reconciliation/serviceTypes.d.ts +5 -3
- package/sync/reconciliation/serviceTypes.js.map +1 -1
- package/sync/reconciliation/triggers.d.ts +2 -1
- package/sync/reconciliation/triggers.js +5 -2
- package/sync/reconciliation/triggers.js.map +1 -1
- package/workflows/blobs/hydrate.d.ts +4 -2
- package/workflows/blobs/hydrate.js +21 -7
- package/workflows/blobs/hydrate.js.map +1 -1
- package/workflows/blobs/multipartUpload.js +57 -30
- package/workflows/blobs/multipartUpload.js.map +1 -1
- package/workflows/blobs/readAttachmentTransfer.js +42 -0
- package/workflows/blobs/readAttachmentTransfer.js.map +1 -0
- package/workflows/documents/syncLane.d.ts +1 -0
- package/workflows/documents/syncLane.js +1 -0
- package/workflows/documents/syncLane.js.map +1 -1
- package/workflows/registration/persistRegistrationBootstrap.js +3 -0
- package/workflows/registration/persistRegistrationBootstrap.js.map +1 -1
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** Bound byte I/O across documents sharing a client, including multipart parts. */
|
|
2
|
+
export const BLOB_TRANSFER_CONCURRENCY = 4;
|
|
3
|
+
const TRANSFER_IDLE_TIMEOUT_MS = 120_000;
|
|
4
|
+
const pools = new WeakMap();
|
|
5
|
+
export async function runBlobTransfer(client, transfer, timeoutMs = TRANSFER_IDLE_TIMEOUT_MS) {
|
|
6
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
|
7
|
+
throw new RangeError("Blob transfer timeout must be positive and finite");
|
|
8
|
+
let pool = pools.get(client);
|
|
9
|
+
if (!pool) {
|
|
10
|
+
pool = { active: 0, waiting: [] };
|
|
11
|
+
pools.set(client, pool);
|
|
12
|
+
}
|
|
13
|
+
if (pool.active >= BLOB_TRANSFER_CONCURRENCY) {
|
|
14
|
+
await new Promise((resolve) => pool.waiting.push(resolve));
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
pool.active += 1;
|
|
18
|
+
}
|
|
19
|
+
const controller = new AbortController();
|
|
20
|
+
let timer;
|
|
21
|
+
const progress = () => {
|
|
22
|
+
controller.signal.throwIfAborted();
|
|
23
|
+
clearTimeout(timer);
|
|
24
|
+
timer = setTimeout(() => controller.abort(new Error("Blob transfer timed out")), timeoutMs);
|
|
25
|
+
};
|
|
26
|
+
progress();
|
|
27
|
+
try {
|
|
28
|
+
// A native bridge or custom transport may ignore abort. Bound its logical
|
|
29
|
+
// ownership anyway; callers must check the signal before accepting results.
|
|
30
|
+
return await new Promise((resolve, reject) => {
|
|
31
|
+
const aborted = () => reject(controller.signal.reason);
|
|
32
|
+
controller.signal.addEventListener("abort", aborted, { once: true });
|
|
33
|
+
void Promise.resolve()
|
|
34
|
+
.then(() => transfer(controller.signal, progress))
|
|
35
|
+
.then((value) => {
|
|
36
|
+
controller.signal.removeEventListener("abort", aborted);
|
|
37
|
+
resolve(value);
|
|
38
|
+
}, (error) => {
|
|
39
|
+
controller.signal.removeEventListener("abort", aborted);
|
|
40
|
+
reject(error);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
const next = pool.waiting.shift();
|
|
47
|
+
if (next)
|
|
48
|
+
next();
|
|
49
|
+
else
|
|
50
|
+
pool.active -= 1;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=transferPool.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"transferPool.js","sourceRoot":"","sources":["transferPool.ts"],"names":[],"mappings":"AAAA,mFAAmF;AACnF,MAAM,CAAC,MAAM,yBAAyB,GAAG,CAAC,CAAC;AAC3C,MAAM,wBAAwB,GAAG,OAAO,CAAC;AAKzC,MAAM,KAAK,GAAG,IAAI,OAAO,EAAwB,CAAC;AAElD,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,MAAc,EACd,QAAmE,EACnE,SAAS,GAAG,wBAAwB;IAEpC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,SAAS,CAAC,IAAI,SAAS,IAAI,CAAC;QAC/C,MAAM,IAAI,UAAU,CAAC,mDAAmD,CAAC,CAAC;IAC5E,IAAI,IAAI,GAAG,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IAC7B,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,IAAI,GAAG,EAAE,MAAM,EAAE,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC;QAClC,KAAK,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;IAC1B,CAAC;IACD,IAAI,IAAI,CAAC,MAAM,IAAI,yBAAyB,EAAE,CAAC;QAC7C,MAAM,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC;IACnE,CAAC;SAAM,CAAC;QACN,IAAI,CAAC,MAAM,IAAI,CAAC,CAAC;IACnB,CAAC;IACD,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;IACzC,IAAI,KAAgD,CAAC;IACrD,MAAM,QAAQ,GAAG,GAAG,EAAE;QACpB,UAAU,CAAC,MAAM,CAAC,cAAc,EAAE,CAAC;QACnC,YAAY,CAAC,KAAK,CAAC,CAAC;QACpB,KAAK,GAAG,UAAU,CAChB,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,yBAAyB,CAAC,CAAC,EAC5D,SAAS,CACV,CAAC;IACJ,CAAC,CAAC;IACF,QAAQ,EAAE,CAAC;IACX,IAAI,CAAC;QACH,0EAA0E;QAC1E,4EAA4E;QAC5E,OAAO,MAAM,IAAI,OAAO,CAAI,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;YAC9C,MAAM,OAAO,GAAG,GAAG,EAAE,CAAC,MAAM,CAAC,UAAU,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;YACvD,UAAU,CAAC,MAAM,CAAC,gBAAgB,CAAC,OAAO,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;YACrE,KAAK,OAAO,CAAC,OAAO,EAAE;iBACnB,IAAI,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,UAAU,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;iBACjD,IAAI,CACH,CAAC,KAAK,EAAE,EAAE;gBACR,UAAU,CAAC,MAAM,CAAC,mBAAmB,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;gBACxD,OAAO,CAAC,KAAK,CAAC,CAAC;YACjB,CAAC,EACD,CAAC,KAAc,EAAE,EAAE;gBACjB,UAAU,CAAC,MAAM,CAAC,mBAAmB,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;gBACxD,MAAM,CAAC,KAAK,CAAC,CAAC;YAChB,CAAC,CACF,CAAC;QACN,CAAC,CAAC,CAAC;IACL,CAAC;YAAS,CAAC;QACT,YAAY,CAAC,KAAK,CAAC,CAAC;QACpB,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;QAClC,IAAI,IAAI;YAAE,IAAI,EAAE,CAAC;;YACZ,IAAI,CAAC,MAAM,IAAI,CAAC,CAAC;IACxB,CAAC;AACH,CAAC","sourcesContent":["/** Bound byte I/O across documents sharing a client, including multipart parts. */\nexport const BLOB_TRANSFER_CONCURRENCY = 4;\nconst TRANSFER_IDLE_TIMEOUT_MS = 120_000;\ninterface TransferPool {\n active: number;\n waiting: Array<() => void>;\n}\nconst pools = new WeakMap<object, TransferPool>();\n\nexport async function runBlobTransfer<T>(\n client: object,\n transfer: (signal: AbortSignal, progress: () => void) => Promise<T>,\n timeoutMs = TRANSFER_IDLE_TIMEOUT_MS,\n): Promise<T> {\n if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)\n throw new RangeError(\"Blob transfer timeout must be positive and finite\");\n let pool = pools.get(client);\n if (!pool) {\n pool = { active: 0, waiting: [] };\n pools.set(client, pool);\n }\n if (pool.active >= BLOB_TRANSFER_CONCURRENCY) {\n await new Promise<void>((resolve) => pool.waiting.push(resolve));\n } else {\n pool.active += 1;\n }\n const controller = new AbortController();\n let timer: ReturnType<typeof setTimeout> | undefined;\n const progress = () => {\n controller.signal.throwIfAborted();\n clearTimeout(timer);\n timer = setTimeout(\n () => controller.abort(new Error(\"Blob transfer timed out\")),\n timeoutMs,\n );\n };\n progress();\n try {\n // A native bridge or custom transport may ignore abort. Bound its logical\n // ownership anyway; callers must check the signal before accepting results.\n return await new Promise<T>((resolve, reject) => {\n const aborted = () => reject(controller.signal.reason);\n controller.signal.addEventListener(\"abort\", aborted, { once: true });\n void Promise.resolve()\n .then(() => transfer(controller.signal, progress))\n .then(\n (value) => {\n controller.signal.removeEventListener(\"abort\", aborted);\n resolve(value);\n },\n (error: unknown) => {\n controller.signal.removeEventListener(\"abort\", aborted);\n reject(error);\n },\n );\n });\n } finally {\n clearTimeout(timer);\n const next = pool.waiting.shift();\n if (next) next();\n else pool.active -= 1;\n }\n}\n"]}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RequestFailureKind, UploadMultipartBlobPartBytesRequest } from "../../../../internal/api-client/index.js";
|
|
1
|
+
import type { RequestFailureKind, RequestResultOptions, UploadMultipartBlobPartBytesRequest } from "../../../../internal/api-client/index.js";
|
|
2
2
|
import type { BlobContentKeyTarget, BlobEnvelopeHeaderRecord, BlobEnvelopeRecord, WriteHeader } from "../../../../internal/crypto/index.js";
|
|
3
3
|
import type { BlobAttachmentBindRequest, BlobAttachmentDetachRequest, BlobContentKeyBundleRequest, CompleteMultipartBlobStageRequest, InitiateMultipartBlobStageRequest } from "../../../../internal/validators/request/index.js";
|
|
4
4
|
import type { BlobAttachmentBindResponse, BlobAttachmentDetachResponse, BlobAttachmentSummary, CompleteMultipartBlobStageResponse, DocumentWriterProjectionResponse, InitiateMultipartBlobStageResponse, MultipartBlobStageStatusResponse, UploadMultipartBlobPartResponse } from "../../../../internal/validators/response/index.js";
|
|
@@ -31,7 +31,7 @@ export interface BlobAttachmentApi {
|
|
|
31
31
|
getDocumentWriterProjection(documentId: string): Promise<DocumentWriterProjectionResponse | null>;
|
|
32
32
|
getMultipartBlobStage(stageId: string): Promise<MultipartBlobStageStatusResponse | null>;
|
|
33
33
|
initiateMultipartBlobStage(input: InitiateMultipartBlobStageRequest): Promise<InitiateMultipartBlobStageResponse | null>;
|
|
34
|
-
uploadMultipartBlobPartBytes(stageId: string, partNumber: number, input: UploadMultipartBlobPartBytesRequest): Promise<UploadMultipartBlobPartResponse | null>;
|
|
34
|
+
uploadMultipartBlobPartBytes(stageId: string, partNumber: number, input: UploadMultipartBlobPartBytesRequest, options?: RequestResultOptions): Promise<UploadMultipartBlobPartResponse | null>;
|
|
35
35
|
}
|
|
36
36
|
export interface BlobAttachmentDetachApi {
|
|
37
37
|
detachBlobAttachment(blobId: string, bindingId: string, input: BlobAttachmentDetachRequest, options?: BlobAttachmentRequestOptions): Promise<BlobAttachmentDetachResponse | null>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":"AAoCA,MAAM,CAAC,MAAM,mCAAmC,GAC9C,wCAAwC,CAAC;AAC3C,MAAM,CAAC,MAAM,8BAA8B,GACzC,mCAAmC,CAAC;AACtC,MAAM,CAAC,MAAM,gCAAgC,GAC3C,qCAAqC,CAAC;AACxC,MAAM,CAAC,MAAM,6BAA6B,GACxC,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,yCAAyC,CAAC,CAAC;AACtE,MAAM,CAAC,MAAM,YAAY,GAAG,IAAI,WAAW,EAAE,CAAC","sourcesContent":["import type {\n RequestFailureKind,\n RequestResultOptions,\n UploadMultipartBlobPartBytesRequest,\n} from \"@tearleads/api-client\";\nimport type {\n BlobContentKeyTarget,\n BlobEnvelopeHeaderRecord,\n BlobEnvelopeRecord,\n WriteHeader,\n} from \"@tearleads/crypto\";\nimport type {\n BlobAttachmentBindRequest,\n BlobAttachmentDetachRequest,\n BlobContentKeyBundleRequest,\n CompleteMultipartBlobStageRequest,\n InitiateMultipartBlobStageRequest,\n} from \"@tearleads/validators/request\";\nimport type {\n BlobAttachmentBindResponse,\n BlobAttachmentDetachResponse,\n BlobAttachmentSummary,\n CompleteMultipartBlobStageResponse,\n DocumentWriterProjectionResponse,\n InitiateMultipartBlobStageResponse,\n MultipartBlobStageStatusResponse,\n UploadMultipartBlobPartResponse,\n} from \"@tearleads/validators/response\";\nimport type { BlobByteSourceInput, BlobBytes } from \"../../../blobContracts\";\nimport type {\n ProjectionUserKeyResolver,\n ReferencedPrincipalPolicyWarmer,\n} from \"../../../keyingProjectionVerification\";\nimport type { ExecSql } from \"../../../sqlite/sqlSchema\";\nimport type { DocumentCreateAuthor } from \"../../shared/types\";\n\nexport const BLOB_CONTENT_RECORD_KEY_INFO_DOMAIN =\n \"tearleads.blob.content-record-key-info\";\nexport const BLOB_CONTENT_RECORD_AAD_DOMAIN =\n \"tearleads.blob.content-record-aad\";\nexport const BLOB_CONTENT_RECORD_NONCE_DOMAIN =\n \"tearleads.blob.content-record-nonce\";\nexport const BLOB_CONTENT_RECORD_HKDF_SALT: Uint8Array<ArrayBuffer> =\n new TextEncoder().encode(\"tearleads.blob.content-record-hkdf-salt\");\nexport const TEXT_ENCODER = new TextEncoder();\n\nexport interface BlobAttachmentRequestOptions {\n readonly expectedPaymentRequiredOrganizationId?: string | undefined;\n}\n\nexport interface BlobAttachmentApi {\n bindBlobAttachment(\n blobId: string,\n input: BlobAttachmentBindRequest,\n options?: BlobAttachmentRequestOptions,\n ): Promise<BlobAttachmentBindResponse | null>;\n clearWriterProjectionCaches?(): void;\n evictDocumentWriterProjection?(documentId: string): void;\n getRequestFailure?(input: {\n method: \"DELETE\" | \"GET\" | \"POST\" | \"PUT\";\n path: string;\n }): {\n readonly code?: string | undefined;\n readonly kind: RequestFailureKind;\n readonly message: string;\n readonly status: number | null;\n } | null;\n completeMultipartBlobStage(\n stageId: string,\n input: CompleteMultipartBlobStageRequest,\n ): Promise<CompleteMultipartBlobStageResponse | null>;\n getDocumentWriterProjection(\n documentId: string,\n ): Promise<DocumentWriterProjectionResponse | null>;\n getMultipartBlobStage(\n stageId: string,\n ): Promise<MultipartBlobStageStatusResponse | null>;\n initiateMultipartBlobStage(\n input: InitiateMultipartBlobStageRequest,\n ): Promise<InitiateMultipartBlobStageResponse | null>;\n uploadMultipartBlobPartBytes(\n stageId: string,\n partNumber: number,\n input: UploadMultipartBlobPartBytesRequest,\n options?: RequestResultOptions,\n ): Promise<UploadMultipartBlobPartResponse | null>;\n}\n\nexport interface BlobAttachmentDetachApi {\n detachBlobAttachment(\n blobId: string,\n bindingId: string,\n input: BlobAttachmentDetachRequest,\n options?: BlobAttachmentRequestOptions,\n ): Promise<BlobAttachmentDetachResponse | null>;\n getDocumentWriterProjection(\n documentId: string,\n ): Promise<DocumentWriterProjectionResponse | null>;\n}\n\nexport type { BlobContentKeyTarget };\n\n/**\n * Aliases of the crypto parser's own types. The parser is shared with the API,\n * so restating these shapes here would let the two drift apart silently.\n */\nexport type BlobEncryptedBytesRecord = BlobEnvelopeRecord;\n\n/**\n * The envelope as the header establishes it. Streaming readers hold this\n * before any ciphertext is read, so it has no `chunks`: an empty array there\n * would read as an empty object rather than an unread one.\n */\nexport type BlobEncryptedBytesHeader = BlobEnvelopeHeaderRecord;\n\nexport type BlobEncryptedChunk = BlobEnvelopeRecord[\"chunks\"][number];\n\nexport interface BlobSourceSnapshot {\n readonly byteLength: number;\n readonly chunkSha256: readonly string[];\n readonly chunkSize: number;\n readonly sha256: string;\n}\n\nexport interface BlobEncryptionPlan {\n readonly byteLength: number;\n readonly chunkSize: number;\n readonly metadataHash: string;\n readonly partCount: number;\n readonly plaintextByteLength: number;\n readonly plaintextSha256: string;\n readonly sha256: string;\n encryptPart(index: number): Promise<BlobBytes>;\n getPartByteLength(index: number): number;\n}\n\nexport interface DocumentManifestIdentity {\n documentId: string;\n manifestHash: string;\n organizationId: string;\n}\n\nexport interface BlobAttachmentMaterial {\n blobAccessManifestHash: string;\n contentKeyBundle: BlobContentKeyBundleRequest;\n encrypted: BlobEncryptionPlan;\n manifestIdentity: DocumentManifestIdentity;\n targetHash: string;\n targets: BlobContentKeyTarget[];\n writerProjection: DocumentWriterProjectionResponse;\n}\n\n/**\n * Progress for an in-flight multipart blob upload.\n */\nexport interface MultipartUploadProgress {\n readonly bytesTotal: number;\n readonly bytesUploaded: number;\n readonly partsCompleted: number;\n readonly partsTotal: number;\n}\n\nexport type MultipartUploadProgressListener = (\n progress: MultipartUploadProgress,\n) => void;\n\n/**\n * Fires once a multipart stage has been opened (or resumed), with its id and\n * plaintext chunk size. Callers persist both so a later attempt can reproduce\n * the same encrypted parts and resume instead of orphaning the partial upload.\n */\nexport type MultipartStageResolvedListener = (input: {\n readonly partSize: number;\n readonly stageId: string;\n}) => void | Promise<void>;\n\nexport interface UploadDocumentAttachmentInput {\n apiClient: BlobAttachmentApi;\n author: DocumentCreateAuthor;\n blobId?: string | undefined;\n bindingId?: string | undefined;\n bytes: BlobByteSourceInput;\n contentKey?: Uint8Array | undefined;\n contentKeyEpoch?: number | undefined;\n documentId: string;\n eventId?: string | undefined;\n execSql: ExecSql;\n expectedBindingId: string | null;\n isRemoteSyncBlocked?: ((organizationId: string) => boolean) | undefined;\n // A persisted nonce seed. The plaintext digest derives the envelope IV from\n // it, then each chunk derives a unique IV. Same-source retries reproduce the\n // encrypted parts; changed bytes cannot reuse their AES-GCM nonces.\n nonceSeed?: Uint8Array | undefined;\n multipart?: MultipartBlobUploadOptions | undefined;\n onMultipartProgress?: MultipartUploadProgressListener | undefined;\n onStageResolved?: MultipartStageResolvedListener | undefined;\n resolveProjectionUserKey: ProjectionUserKeyResolver;\n signedAt?: string | undefined;\n slotId: string;\n targetSecretKey: Uint8Array;\n warmReferencedPrincipalPolicies?: ReferencedPrincipalPolicyWarmer | undefined;\n writerProjection?: DocumentWriterProjectionResponse | undefined;\n}\n\nexport interface MultipartBlobUploadOptions {\n /** Exactly 5 MiB per plaintext encryption chunk; transport parts include envelope overhead. */\n readonly partSize: number;\n // The id of a previously opened stage to resume. When set, the upload refetches\n // that stage and skips parts the server already has instead of opening a new\n // one; if the stage is gone (expired), it falls back to a fresh stage.\n readonly resumeStageId?: string | undefined;\n readonly uploadConcurrency?: number | undefined;\n}\n\nexport interface UploadDocumentAttachmentResult {\n blobId: string;\n bindingId: string;\n request: BlobAttachmentBindRequest;\n response: BlobAttachmentBindResponse;\n sha256: string;\n byteLength: number;\n writeHeader: WriteHeader;\n writeHeaderHash: string;\n writerProjection: DocumentWriterProjectionResponse;\n}\n\nexport interface DetachDocumentAttachmentInput {\n apiClient: BlobAttachmentDetachApi;\n author: DocumentCreateAuthor;\n bindingId: string;\n blobId: string;\n documentId: string;\n eventId?: string | undefined;\n execSql: ExecSql;\n isRemoteSyncBlocked?: ((organizationId: string) => boolean) | undefined;\n resolveProjectionUserKey: ProjectionUserKeyResolver;\n signedAt?: string | undefined;\n slotId: string;\n stillCurrent?: (() => boolean) | undefined;\n warmReferencedPrincipalPolicies?: ReferencedPrincipalPolicyWarmer | undefined;\n writerProjection?: DocumentWriterProjectionResponse | undefined;\n}\n\nexport interface DetachDocumentAttachmentResult {\n request: BlobAttachmentDetachRequest;\n response: BlobAttachmentDetachResponse;\n writerProjection: DocumentWriterProjectionResponse;\n}\n\nexport interface DecryptDocumentAttachmentBlobInput {\n binding: BlobAttachmentSummary;\n encryptedBytes: BlobBytes;\n expectedDocumentId: string;\n expectedSlotId: string;\n execSql: ExecSql;\n resolveProjectionUserKey: ProjectionUserKeyResolver;\n targetSecretKey: Uint8Array;\n stillCurrent?: (() => boolean) | undefined;\n warmReferencedPrincipalPolicies?: ReferencedPrincipalPolicyWarmer | undefined;\n writerProjection: DocumentWriterProjectionResponse;\n}\n"]}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describeSyncLaneError, publishSyncCoordinatorSnapshot, } from "./coordinatorState.js";
|
|
2
|
+
import { createSyncTimestamp } from "./syncTelemetry.js";
|
|
3
|
+
const SYNC_LANE_WATCHDOG_MS = 120_000;
|
|
4
|
+
const FAILED_LANE_REARM_BACKOFF_MS = 1000;
|
|
5
|
+
// Keyed on the registered config so a discarded lane is collectable.
|
|
6
|
+
const lastReportedFailureByConfig = new WeakMap();
|
|
7
|
+
// Local-only dedup key. describeSyncLaneError joins messages without their
|
|
8
|
+
// types, so the name keeps a swallowed DatabaseUnavailableError distinct from
|
|
9
|
+
// a plain Error carrying the same text; the cause chain keeps a re-wrapped
|
|
10
|
+
// failure distinct from its origin.
|
|
11
|
+
function describeSyncLaneFailureSignature(error) {
|
|
12
|
+
const name = error instanceof Error ? error.name : typeof error;
|
|
13
|
+
return JSON.stringify([name, describeSyncLaneError(error)]);
|
|
14
|
+
}
|
|
15
|
+
// Observability only: a failing lane re-arms every FAILED_LANE_REARM_BACKOFF_MS
|
|
16
|
+
// for the rest of the session, and mobile WebViews are never reloaded, so an
|
|
17
|
+
// unrecoverable lane would report once a second. The signature is deliberately
|
|
18
|
+
// never cleared on a completed run: alternating success and failure would
|
|
19
|
+
// re-admit the same report every pass and evict the surrounding diagnostic
|
|
20
|
+
// trail from the host's bounded log.
|
|
21
|
+
function reportUnexpectedSyncLaneFailure(state, error) {
|
|
22
|
+
const report = state.config.reportUnexpectedError;
|
|
23
|
+
if (!report)
|
|
24
|
+
return;
|
|
25
|
+
const signature = describeSyncLaneFailureSignature(error);
|
|
26
|
+
if (lastReportedFailureByConfig.get(state.config) === signature)
|
|
27
|
+
return;
|
|
28
|
+
lastReportedFailureByConfig.set(state.config, signature);
|
|
29
|
+
try {
|
|
30
|
+
void Promise.resolve(report(error)).catch(() => undefined);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Hosts may throw synchronously or return a rejected promise.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async function runSyncLane(state) {
|
|
37
|
+
try {
|
|
38
|
+
await state.config.run();
|
|
39
|
+
return { status: "completed" };
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (state.config.shouldIgnoreError?.(error)) {
|
|
43
|
+
return { status: "completed" };
|
|
44
|
+
}
|
|
45
|
+
reportUnexpectedSyncLaneFailure(state, error);
|
|
46
|
+
if (state.config.onUnexpectedError) {
|
|
47
|
+
state.config.onUnexpectedError(error);
|
|
48
|
+
return { status: "failed", error };
|
|
49
|
+
}
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// Console-only on purpose. This runs only when the lane's OWN error handler
|
|
54
|
+
// already threw, so it must never route through reportUnexpectedSyncLaneFailure
|
|
55
|
+
// as well: re-entering a handler that just threw would reject runResultPromise
|
|
56
|
+
// from inside its own onRejected callback, leaving `running` true and
|
|
57
|
+
// `activeRunToken` set, which wedges the lane for the rest of the session.
|
|
58
|
+
function reportUnexpectedSyncLaneError(state, error) {
|
|
59
|
+
console.error(`Failed to run sync lane ${state.key}:`, error);
|
|
60
|
+
}
|
|
61
|
+
function recordSyncLaneRunResult(lane, runResult) {
|
|
62
|
+
lane.lastActionAt = createSyncTimestamp();
|
|
63
|
+
if (runResult.status === "failed") {
|
|
64
|
+
lane.errorCount += 1;
|
|
65
|
+
lane.lastAction = "failed";
|
|
66
|
+
lane.lastError = describeSyncLaneError(runResult.error);
|
|
67
|
+
lane.lastFailedAt = lane.lastActionAt;
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
lane.lastAction = "completed";
|
|
71
|
+
lane.lastCompletedAt = lane.lastActionAt;
|
|
72
|
+
lane.lastError = null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Replace the watchdog failure with the late result, then release queued work.
|
|
76
|
+
function settleAbandonedSyncLaneRun(coordinatorState, lane, runToken, runResult, wake) {
|
|
77
|
+
if (lane.activeRunToken !== runToken) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
lane.activeRunToken = null;
|
|
81
|
+
lane.runAbandoned = false;
|
|
82
|
+
recordSyncLaneRunResult(lane, runResult);
|
|
83
|
+
publishSyncCoordinatorSnapshot(coordinatorState);
|
|
84
|
+
if (!coordinatorState.disposed && lane.requested) {
|
|
85
|
+
wake();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function startSyncLaneRun(coordinatorState, lane) {
|
|
89
|
+
lane.requested = false;
|
|
90
|
+
lane.notBeforeAtMs = null;
|
|
91
|
+
lane.running = true;
|
|
92
|
+
lane.runCount += 1;
|
|
93
|
+
lane.lastAction = "started";
|
|
94
|
+
lane.lastActionAt = createSyncTimestamp();
|
|
95
|
+
lane.lastStartedAt = lane.lastActionAt;
|
|
96
|
+
lane.runAbandoned = false;
|
|
97
|
+
const runToken = {};
|
|
98
|
+
lane.activeRunToken = runToken;
|
|
99
|
+
publishSyncCoordinatorSnapshot(coordinatorState);
|
|
100
|
+
// Keep follow-up requests even on failure to preserve phase ordering; see
|
|
101
|
+
// docs/client-sync-ordering.md.
|
|
102
|
+
// The fallback also catches errors thrown by the lane's error handlers.
|
|
103
|
+
const runResultPromise = runSyncLane(lane).then((result) => result, (error) => {
|
|
104
|
+
reportUnexpectedSyncLaneError(lane, error);
|
|
105
|
+
return { status: "failed", error };
|
|
106
|
+
});
|
|
107
|
+
return { runToken, runResultPromise };
|
|
108
|
+
}
|
|
109
|
+
export async function runCoordinatorLane(coordinatorState, lane, wake) {
|
|
110
|
+
const { runToken, runResultPromise } = startSyncLaneRun(coordinatorState, lane);
|
|
111
|
+
// Clamp overrides to at least 1ms so every run has a chance to complete.
|
|
112
|
+
const watchdogMs = Math.max(1, lane.config.watchdogMs ?? SYNC_LANE_WATCHDOG_MS);
|
|
113
|
+
let watchdogTimer = null;
|
|
114
|
+
const raceOutcome = await Promise.race([
|
|
115
|
+
runResultPromise.then((result) => ({
|
|
116
|
+
kind: "settled",
|
|
117
|
+
result,
|
|
118
|
+
})),
|
|
119
|
+
new Promise((resolve) => {
|
|
120
|
+
watchdogTimer = setTimeout(() => resolve({ kind: "timed-out" }), watchdogMs);
|
|
121
|
+
}),
|
|
122
|
+
]);
|
|
123
|
+
if (watchdogTimer !== null) {
|
|
124
|
+
clearTimeout(watchdogTimer);
|
|
125
|
+
}
|
|
126
|
+
lane.running = false;
|
|
127
|
+
let runResult;
|
|
128
|
+
if (raceOutcome.kind === "settled") {
|
|
129
|
+
lane.activeRunToken = null;
|
|
130
|
+
runResult = raceOutcome.result;
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
lane.runAbandoned = true;
|
|
134
|
+
runResult = {
|
|
135
|
+
status: "failed",
|
|
136
|
+
error: new Error(`Sync lane watchdog: run exceeded ${watchdogMs}ms; continuing with other lanes`),
|
|
137
|
+
};
|
|
138
|
+
void runResultPromise.then((result) => settleAbandonedSyncLaneRun(coordinatorState, lane, runToken, result, wake));
|
|
139
|
+
}
|
|
140
|
+
recordSyncLaneRunResult(lane, runResult);
|
|
141
|
+
publishSyncCoordinatorSnapshot(coordinatorState);
|
|
142
|
+
if (runResult.status === "failed" &&
|
|
143
|
+
lane.requested &&
|
|
144
|
+
raceOutcome.kind === "settled") {
|
|
145
|
+
// A fresh hint cannot bypass a failed lane's cooldown. Other document
|
|
146
|
+
// lanes remain runnable; structural retries still preserve the barrier.
|
|
147
|
+
lane.backingOff = true;
|
|
148
|
+
setTimeout(() => {
|
|
149
|
+
lane.backingOff = false;
|
|
150
|
+
wake();
|
|
151
|
+
}, FAILED_LANE_REARM_BACKOFF_MS);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
//# sourceMappingURL=coordinatorLaneRun.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"coordinatorLaneRun.js","sourceRoot":"","sources":["coordinatorLaneRun.ts"],"names":[],"mappings":"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","sourcesContent":["import type {\n DomainSyncCoordinatorState,\n SyncLaneState,\n} from \"./coordinatorState\";\nimport {\n describeSyncLaneError,\n publishSyncCoordinatorSnapshot,\n} from \"./coordinatorState\";\nimport type { SyncLaneConfig } from \"./syncLaneConfig\";\nimport { createSyncTimestamp } from \"./syncTelemetry\";\n\nconst SYNC_LANE_WATCHDOG_MS = 120_000;\nconst FAILED_LANE_REARM_BACKOFF_MS = 1000;\n\ntype SyncLaneRunResult =\n | { status: \"completed\" }\n | { status: \"failed\"; error: unknown };\n\n// Keyed on the registered config so a discarded lane is collectable.\nconst lastReportedFailureByConfig = new WeakMap<SyncLaneConfig, string>();\n\n// Local-only dedup key. describeSyncLaneError joins messages without their\n// types, so the name keeps a swallowed DatabaseUnavailableError distinct from\n// a plain Error carrying the same text; the cause chain keeps a re-wrapped\n// failure distinct from its origin.\nfunction describeSyncLaneFailureSignature(error: unknown): string {\n const name = error instanceof Error ? error.name : typeof error;\n return JSON.stringify([name, describeSyncLaneError(error)]);\n}\n\n// Observability only: a failing lane re-arms every FAILED_LANE_REARM_BACKOFF_MS\n// for the rest of the session, and mobile WebViews are never reloaded, so an\n// unrecoverable lane would report once a second. The signature is deliberately\n// never cleared on a completed run: alternating success and failure would\n// re-admit the same report every pass and evict the surrounding diagnostic\n// trail from the host's bounded log.\nfunction reportUnexpectedSyncLaneFailure(\n state: SyncLaneState,\n error: unknown,\n): void {\n const report = state.config.reportUnexpectedError;\n if (!report) return;\n const signature = describeSyncLaneFailureSignature(error);\n if (lastReportedFailureByConfig.get(state.config) === signature) return;\n lastReportedFailureByConfig.set(state.config, signature);\n try {\n void Promise.resolve(report(error)).catch(() => undefined);\n } catch {\n // Hosts may throw synchronously or return a rejected promise.\n }\n}\n\nasync function runSyncLane(state: SyncLaneState): Promise<SyncLaneRunResult> {\n try {\n await state.config.run();\n return { status: \"completed\" };\n } catch (error: unknown) {\n if (state.config.shouldIgnoreError?.(error)) {\n return { status: \"completed\" };\n }\n reportUnexpectedSyncLaneFailure(state, error);\n\n if (state.config.onUnexpectedError) {\n state.config.onUnexpectedError(error);\n return { status: \"failed\", error };\n }\n\n throw error;\n }\n}\n\n// Console-only on purpose. This runs only when the lane's OWN error handler\n// already threw, so it must never route through reportUnexpectedSyncLaneFailure\n// as well: re-entering a handler that just threw would reject runResultPromise\n// from inside its own onRejected callback, leaving `running` true and\n// `activeRunToken` set, which wedges the lane for the rest of the session.\nfunction reportUnexpectedSyncLaneError(state: SyncLaneState, error: unknown) {\n console.error(`Failed to run sync lane ${state.key}:`, error);\n}\n\nfunction recordSyncLaneRunResult(\n lane: SyncLaneState,\n runResult: SyncLaneRunResult,\n): void {\n lane.lastActionAt = createSyncTimestamp();\n if (runResult.status === \"failed\") {\n lane.errorCount += 1;\n lane.lastAction = \"failed\";\n lane.lastError = describeSyncLaneError(runResult.error);\n lane.lastFailedAt = lane.lastActionAt;\n } else {\n lane.lastAction = \"completed\";\n lane.lastCompletedAt = lane.lastActionAt;\n lane.lastError = null;\n }\n}\n\n// Replace the watchdog failure with the late result, then release queued work.\nfunction settleAbandonedSyncLaneRun(\n coordinatorState: DomainSyncCoordinatorState,\n lane: SyncLaneState,\n runToken: object,\n runResult: SyncLaneRunResult,\n wake: () => void,\n): void {\n if (lane.activeRunToken !== runToken) {\n return;\n }\n lane.activeRunToken = null;\n lane.runAbandoned = false;\n recordSyncLaneRunResult(lane, runResult);\n publishSyncCoordinatorSnapshot(coordinatorState);\n if (!coordinatorState.disposed && lane.requested) {\n wake();\n }\n}\n\nfunction startSyncLaneRun(\n coordinatorState: DomainSyncCoordinatorState,\n lane: SyncLaneState,\n) {\n lane.requested = false;\n lane.notBeforeAtMs = null;\n lane.running = true;\n lane.runCount += 1;\n lane.lastAction = \"started\";\n lane.lastActionAt = createSyncTimestamp();\n lane.lastStartedAt = lane.lastActionAt;\n lane.runAbandoned = false;\n const runToken = {};\n lane.activeRunToken = runToken;\n publishSyncCoordinatorSnapshot(coordinatorState);\n\n // Keep follow-up requests even on failure to preserve phase ordering; see\n // docs/client-sync-ordering.md.\n // The fallback also catches errors thrown by the lane's error handlers.\n const runResultPromise: Promise<SyncLaneRunResult> = runSyncLane(lane).then(\n (result) => result,\n (error: unknown) => {\n reportUnexpectedSyncLaneError(lane, error);\n return { status: \"failed\", error };\n },\n );\n\n return { runToken, runResultPromise };\n}\n\nexport async function runCoordinatorLane(\n coordinatorState: DomainSyncCoordinatorState,\n lane: SyncLaneState,\n wake: () => void,\n): Promise<void> {\n const { runToken, runResultPromise } = startSyncLaneRun(\n coordinatorState,\n lane,\n );\n // Clamp overrides to at least 1ms so every run has a chance to complete.\n const watchdogMs = Math.max(\n 1,\n lane.config.watchdogMs ?? SYNC_LANE_WATCHDOG_MS,\n );\n let watchdogTimer: ReturnType<typeof setTimeout> | null = null;\n const raceOutcome = await Promise.race([\n runResultPromise.then((result) => ({\n kind: \"settled\" as const,\n result,\n })),\n new Promise<{ kind: \"timed-out\" }>((resolve) => {\n watchdogTimer = setTimeout(\n () => resolve({ kind: \"timed-out\" }),\n watchdogMs,\n );\n }),\n ]);\n if (watchdogTimer !== null) {\n clearTimeout(watchdogTimer);\n }\n\n lane.running = false;\n let runResult: SyncLaneRunResult;\n if (raceOutcome.kind === \"settled\") {\n lane.activeRunToken = null;\n runResult = raceOutcome.result;\n } else {\n lane.runAbandoned = true;\n runResult = {\n status: \"failed\",\n error: new Error(\n `Sync lane watchdog: run exceeded ${watchdogMs}ms; continuing with other lanes`,\n ),\n };\n void runResultPromise.then((result) =>\n settleAbandonedSyncLaneRun(\n coordinatorState,\n lane,\n runToken,\n result,\n wake,\n ),\n );\n }\n recordSyncLaneRunResult(lane, runResult);\n publishSyncCoordinatorSnapshot(coordinatorState);\n\n if (\n runResult.status === \"failed\" &&\n lane.requested &&\n raceOutcome.kind === \"settled\"\n ) {\n // A fresh hint cannot bypass a failed lane's cooldown. Other document\n // lanes remain runnable; structural retries still preserve the barrier.\n lane.backingOff = true;\n setTimeout(() => {\n lane.backingOff = false;\n wake();\n }, FAILED_LANE_REARM_BACKOFF_MS);\n }\n}\n"]}
|
|
@@ -1,85 +1,46 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { runCoordinatorLane } from "./coordinatorLaneRun.js";
|
|
2
|
+
import { hasPendingLaneWork, publishSyncCoordinatorSnapshot, } from "./coordinatorState.js";
|
|
2
3
|
import { compareSyncLaneOrder, createSyncTimestamp } from "./syncTelemetry.js";
|
|
3
4
|
const DEFAULT_SYNC_IDLE_INTERVAL_MS = 10;
|
|
4
5
|
const DEFAULT_SYNC_IDLE_QUIET_MS = 0;
|
|
5
6
|
const DEFAULT_SYNC_IDLE_TIMEOUT_MS = 500;
|
|
6
|
-
|
|
7
|
-
// so lanes that keep requesting work cannot starve timers or other tasks.
|
|
8
|
-
const FAILED_LANE_REARM_BACKOFF_MS = 1000;
|
|
9
|
-
// Allow normal convergence bursts to finish without a timer delay.
|
|
7
|
+
const DOCUMENT_LANE_CONCURRENCY = 4;
|
|
10
8
|
const SYNC_PUMP_MACROTASK_YIELD_INTERVAL = 16;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const SYNC_LANE_WATCHDOG_MS = 120_000;
|
|
14
|
-
// Keyed on the registered config so a discarded lane is collectable.
|
|
15
|
-
const lastReportedFailureByConfig = new WeakMap();
|
|
16
|
-
// Local-only dedup key. describeSyncLaneError joins messages without their
|
|
17
|
-
// types, so the name keeps a swallowed DatabaseUnavailableError distinct from
|
|
18
|
-
// a plain Error carrying the same text; the cause chain keeps a re-wrapped
|
|
19
|
-
// failure distinct from its origin.
|
|
20
|
-
function describeSyncLaneFailureSignature(error) {
|
|
21
|
-
const name = error instanceof Error ? error.name : typeof error;
|
|
22
|
-
return JSON.stringify([name, describeSyncLaneError(error)]);
|
|
9
|
+
function delay(ms) {
|
|
10
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
23
11
|
}
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
// unrecoverable lane would report once a second. The signature is deliberately
|
|
27
|
-
// never cleared on a completed run: alternating success and failure would
|
|
28
|
-
// re-admit the same report every pass and evict the surrounding diagnostic
|
|
29
|
-
// trail from the host's bounded log.
|
|
30
|
-
function reportUnexpectedSyncLaneFailure(state, error) {
|
|
31
|
-
const report = state.config.reportUnexpectedError;
|
|
32
|
-
if (!report)
|
|
33
|
-
return;
|
|
34
|
-
const signature = describeSyncLaneFailureSignature(error);
|
|
35
|
-
if (lastReportedFailureByConfig.get(state.config) === signature)
|
|
36
|
-
return;
|
|
37
|
-
lastReportedFailureByConfig.set(state.config, signature);
|
|
12
|
+
// Priority is a scheduling hint, never permission to abandon active workers.
|
|
13
|
+
function readLanePriority(lane) {
|
|
38
14
|
try {
|
|
39
|
-
|
|
15
|
+
const priority = lane.config.priority?.() ?? 0;
|
|
16
|
+
return Number.isFinite(priority) ? priority : 0;
|
|
40
17
|
}
|
|
41
18
|
catch {
|
|
42
|
-
|
|
19
|
+
return 0;
|
|
43
20
|
}
|
|
44
21
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return { status: "completed" };
|
|
22
|
+
function compareRequestedLanes(left, right) {
|
|
23
|
+
if ((left.config.phase ?? "document") !== (right.config.phase ?? "document")) {
|
|
24
|
+
return compareSyncLaneOrder(left, right);
|
|
49
25
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
reportUnexpectedSyncLaneFailure(state, error);
|
|
55
|
-
if (state.config.onUnexpectedError) {
|
|
56
|
-
state.config.onUnexpectedError(error);
|
|
57
|
-
return { status: "failed", error };
|
|
58
|
-
}
|
|
59
|
-
throw error;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
// Console-only on purpose. This runs only when the lane's OWN error handler
|
|
63
|
-
// already threw, so it must never route through reportUnexpectedSyncLaneFailure
|
|
64
|
-
// as well: re-entering a handler that just threw would reject runResultPromise
|
|
65
|
-
// from inside its own onRejected callback, leaving `running` true and
|
|
66
|
-
// `activeRunToken` set, which wedges the lane for the rest of the session.
|
|
67
|
-
function reportUnexpectedSyncLaneError(state, error) {
|
|
68
|
-
console.error(`Failed to run sync lane ${state.key}:`, error);
|
|
69
|
-
}
|
|
70
|
-
function delay(ms) {
|
|
71
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
26
|
+
return (readLanePriority(left) - readLanePriority(right) ||
|
|
27
|
+
left.lastRunOrder - right.lastRunOrder ||
|
|
28
|
+
compareSyncLaneOrder(left, right));
|
|
72
29
|
}
|
|
73
30
|
// An abandoned run still owns its lane. Its eventual settlement will restart
|
|
74
31
|
// the pump; trying to restart earlier would spin without runnable work.
|
|
75
32
|
function isRequestedLaneAvailable(lane) {
|
|
76
33
|
return lane.pumpDriven && lane.requested && lane.activeRunToken === null;
|
|
77
34
|
}
|
|
78
|
-
function
|
|
35
|
+
function hasRunnableLaneWork(laneValues) {
|
|
36
|
+
const lanes = Array.from(laneValues);
|
|
37
|
+
const structuralPending = lanes.some((lane) => lane.config.phase === "structural" && isRequestedLaneAvailable(lane));
|
|
79
38
|
const now = Date.now();
|
|
80
39
|
for (const lane of lanes) {
|
|
81
|
-
if (
|
|
82
|
-
(lane
|
|
40
|
+
if ((!structuralPending || lane.config.phase === "structural") &&
|
|
41
|
+
isRequestedLaneAvailable(lane) &&
|
|
42
|
+
(lane.notBeforeAtMs === null || lane.notBeforeAtMs <= now) &&
|
|
43
|
+
!lane.backingOff) {
|
|
83
44
|
return true;
|
|
84
45
|
}
|
|
85
46
|
}
|
|
@@ -90,123 +51,65 @@ function selectNextRequestedLane(lanes) {
|
|
|
90
51
|
const now = Date.now();
|
|
91
52
|
for (const lane of lanes) {
|
|
92
53
|
if (!isRequestedLaneAvailable(lane) ||
|
|
93
|
-
(lane.notBeforeAtMs !== null && lane.notBeforeAtMs > now)
|
|
54
|
+
(lane.notBeforeAtMs !== null && lane.notBeforeAtMs > now) ||
|
|
55
|
+
lane.backingOff) {
|
|
94
56
|
continue;
|
|
95
57
|
}
|
|
96
|
-
if (!selectedLane ||
|
|
58
|
+
if (!selectedLane || compareRequestedLanes(lane, selectedLane) < 0) {
|
|
97
59
|
selectedLane = lane;
|
|
98
60
|
}
|
|
99
61
|
}
|
|
100
62
|
return selectedLane;
|
|
101
63
|
}
|
|
102
|
-
function
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
lane
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const runToken = {};
|
|
140
|
-
lane.activeRunToken = runToken;
|
|
141
|
-
publishSyncCoordinatorSnapshot(coordinatorState);
|
|
142
|
-
// Keep follow-up requests even on failure to preserve phase ordering; see
|
|
143
|
-
// docs/client-sync-ordering.md.
|
|
144
|
-
// The fallback also catches errors thrown by the lane's error handlers.
|
|
145
|
-
const runResultPromise = runSyncLane(lane).then((result) => result, (error) => {
|
|
146
|
-
reportUnexpectedSyncLaneError(lane, error);
|
|
147
|
-
return { status: "failed", error };
|
|
148
|
-
});
|
|
149
|
-
return { runToken, runResultPromise };
|
|
150
|
-
}
|
|
151
|
-
async function runRequestedSyncLanes(coordinatorState) {
|
|
152
|
-
let runsSinceMacrotaskYield = 0;
|
|
153
|
-
// Disposal can happen during a run. Finish it before selecting more work.
|
|
154
|
-
while (!coordinatorState.disposed) {
|
|
155
|
-
const lane = selectNextRequestedLane(coordinatorState.lanes.values());
|
|
156
|
-
if (!lane) {
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
const { runToken, runResultPromise } = startSyncLaneRun(coordinatorState, lane);
|
|
160
|
-
// Clamp overrides to at least 1ms so every run has a chance to complete.
|
|
161
|
-
const watchdogMs = Math.max(1, lane.config.watchdogMs ?? SYNC_LANE_WATCHDOG_MS);
|
|
162
|
-
let watchdogTimer = null;
|
|
163
|
-
const raceOutcome = await Promise.race([
|
|
164
|
-
runResultPromise.then((result) => ({
|
|
165
|
-
kind: "settled",
|
|
166
|
-
result,
|
|
167
|
-
})),
|
|
168
|
-
new Promise((resolve) => {
|
|
169
|
-
watchdogTimer = setTimeout(() => resolve({ kind: "timed-out" }), watchdogMs);
|
|
170
|
-
}),
|
|
171
|
-
]);
|
|
172
|
-
if (watchdogTimer !== null) {
|
|
173
|
-
clearTimeout(watchdogTimer);
|
|
174
|
-
}
|
|
175
|
-
lane.running = false;
|
|
176
|
-
let runResult;
|
|
177
|
-
if (raceOutcome.kind === "settled") {
|
|
178
|
-
lane.activeRunToken = null;
|
|
179
|
-
runResult = raceOutcome.result;
|
|
180
|
-
}
|
|
181
|
-
else {
|
|
182
|
-
lane.runAbandoned = true;
|
|
183
|
-
runResult = {
|
|
184
|
-
status: "failed",
|
|
185
|
-
error: new Error(`Sync lane watchdog: run exceeded ${watchdogMs}ms; continuing with other lanes`),
|
|
186
|
-
};
|
|
187
|
-
void runResultPromise.then((result) => settleAbandonedSyncLaneRun(coordinatorState, lane, runToken, result));
|
|
188
|
-
}
|
|
189
|
-
recordSyncLaneRunResult(lane, runResult);
|
|
190
|
-
publishSyncCoordinatorSnapshot(coordinatorState);
|
|
191
|
-
runsSinceMacrotaskYield += 1;
|
|
192
|
-
if (runResult.status === "failed" &&
|
|
193
|
-
lane.requested &&
|
|
194
|
-
raceOutcome.kind === "settled") {
|
|
195
|
-
// Timed-out runs cannot run again until they settle, so only ordinary
|
|
196
|
-
// failures need backoff. Other lanes can proceed immediately on timeout.
|
|
197
|
-
await delay(FAILED_LANE_REARM_BACKOFF_MS);
|
|
198
|
-
runsSinceMacrotaskYield = 0;
|
|
199
|
-
}
|
|
200
|
-
else if (runsSinceMacrotaskYield >= SYNC_PUMP_MACROTASK_YIELD_INTERVAL) {
|
|
201
|
-
// Awaiting resolved promises only yields microtasks. A timer also lets
|
|
202
|
-
// the event loop advance when lanes keep requesting each other.
|
|
203
|
-
await delay(0);
|
|
204
|
-
runsSinceMacrotaskYield = 0;
|
|
64
|
+
async function runRequestedSyncLanes(state) {
|
|
65
|
+
const running = new Map();
|
|
66
|
+
let startsSinceYield = 0;
|
|
67
|
+
while (!state.disposed || running.size > 0) {
|
|
68
|
+
// Install the wake-up before selecting or starting work. New requests can
|
|
69
|
+
// fill unused workers even while another document is waiting on the network.
|
|
70
|
+
const wakeup = new Promise((resolve) => {
|
|
71
|
+
state.wakePump = resolve;
|
|
72
|
+
});
|
|
73
|
+
let lane = state.disposed
|
|
74
|
+
? null
|
|
75
|
+
: selectNextRequestedLane(state.lanes.values());
|
|
76
|
+
const structuralPending = Array.from(state.lanes.values()).some((candidate) => candidate.pumpDriven &&
|
|
77
|
+
candidate.config.phase === "structural" &&
|
|
78
|
+
(candidate.running ||
|
|
79
|
+
(candidate.requested && candidate.activeRunToken === null)));
|
|
80
|
+
const structuralRunning = Array.from(running.keys()).some((candidate) => candidate.config.phase === "structural");
|
|
81
|
+
if (lane &&
|
|
82
|
+
(running.size >= DOCUMENT_LANE_CONCURRENCY ||
|
|
83
|
+
structuralRunning ||
|
|
84
|
+
(lane.config.phase === "structural"
|
|
85
|
+
? running.size > 0
|
|
86
|
+
: structuralPending)))
|
|
87
|
+
lane = null;
|
|
88
|
+
if (lane) {
|
|
89
|
+
const startedLane = lane;
|
|
90
|
+
lane.lastRunOrder = ++state.nextRunOrder;
|
|
91
|
+
const run = runCoordinatorLane(state, lane, () => scheduleCoordinatorPump(state)).finally(() => {
|
|
92
|
+
running.delete(startedLane);
|
|
93
|
+
state.wakePump?.();
|
|
94
|
+
});
|
|
95
|
+
running.set(lane, run);
|
|
96
|
+
if (++startsSinceYield >= SYNC_PUMP_MACROTASK_YIELD_INTERVAL) {
|
|
97
|
+
await delay(0);
|
|
98
|
+
startsSinceYield = 0;
|
|
99
|
+
}
|
|
100
|
+
continue;
|
|
205
101
|
}
|
|
102
|
+
if (running.size === 0)
|
|
103
|
+
break;
|
|
104
|
+
await wakeup;
|
|
206
105
|
}
|
|
106
|
+
state.wakePump = null;
|
|
207
107
|
}
|
|
208
108
|
function scheduleCoordinatorPump(coordinatorState) {
|
|
209
|
-
if (coordinatorState.
|
|
109
|
+
if (coordinatorState.disposed)
|
|
110
|
+
return;
|
|
111
|
+
if (coordinatorState.pump) {
|
|
112
|
+
coordinatorState.wakePump?.();
|
|
210
113
|
return;
|
|
211
114
|
}
|
|
212
115
|
coordinatorState.pump = Promise.resolve()
|
|
@@ -215,7 +118,7 @@ function scheduleCoordinatorPump(coordinatorState) {
|
|
|
215
118
|
coordinatorState.pump = null;
|
|
216
119
|
publishSyncCoordinatorSnapshot(coordinatorState);
|
|
217
120
|
if (!coordinatorState.disposed &&
|
|
218
|
-
|
|
121
|
+
hasRunnableLaneWork(coordinatorState.lanes.values())) {
|
|
219
122
|
scheduleCoordinatorPump(coordinatorState);
|
|
220
123
|
}
|
|
221
124
|
});
|