velocious 1.0.664 → 1.0.666
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 +2 -2
- package/build/background-jobs/client.js +60 -14
- package/build/background-jobs/enqueue-acknowledgement-timeout-error.js +50 -0
- package/build/src/background-jobs/client.d.ts.map +1 -1
- package/build/src/background-jobs/client.js +58 -15
- package/build/src/background-jobs/enqueue-acknowledgement-timeout-error.d.ts +81 -0
- package/build/src/background-jobs/enqueue-acknowledgement-timeout-error.d.ts.map +1 -0
- package/build/src/background-jobs/enqueue-acknowledgement-timeout-error.js +47 -0
- package/build/src/sync/sync-publisher.d.ts +10 -3
- package/build/src/sync/sync-publisher.d.ts.map +1 -1
- package/build/src/sync/sync-publisher.js +67 -10
- package/build/sync/sync-publisher.js +77 -12
- package/package.json +1 -1
- package/src/background-jobs/client.js +60 -14
- package/src/background-jobs/enqueue-acknowledgement-timeout-error.js +50 -0
- package/src/sync/sync-publisher.js +77 -12
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import timeout from "awaitery/build/timeout.js"
|
|
3
|
+
import timeout, {TimeoutError} from "awaitery/build/timeout.js"
|
|
4
4
|
import configurationResolver from "../configuration-resolver.js"
|
|
5
|
+
import BackgroundJobEnqueueAcknowledgementTimeoutError from "./enqueue-acknowledgement-timeout-error.js"
|
|
5
6
|
import BackgroundJobsSocketRequest from "./socket-request.js"
|
|
6
7
|
import { DEFAULT_GENERATION_HANDSHAKE_TIMEOUT_MS, validateGenerationHandshakeTimeoutMs } from "./generation-handshake-timeout-error.js"
|
|
7
8
|
|
|
@@ -57,17 +58,36 @@ export default class BackgroundJobsClient {
|
|
|
57
58
|
...(producerInvocationId ? {producerInvocationId} : {}),
|
|
58
59
|
...(producerProof ? {producerProof} : {})
|
|
59
60
|
}
|
|
60
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Creates safe observations for one attempt without retaining request data.
|
|
63
|
+
* @param {object} args - Attempt identity.
|
|
64
|
+
* @param {"initial" | "owned_replay"} args.attemptKind - Initial attempt or owned replay.
|
|
65
|
+
* @param {number} args.attemptNumber - One-based attempt number.
|
|
66
|
+
* @returns {import("./enqueue-acknowledgement-timeout-error.js").BackgroundJobEnqueueAttempt} - Mutable attempt observations.
|
|
67
|
+
*/
|
|
68
|
+
const newAttemptObservation = ({attemptKind, attemptNumber}) => ({
|
|
69
|
+
acknowledgementWaitElapsedMs: 0,
|
|
70
|
+
attemptElapsedMs: 0,
|
|
71
|
+
attemptKind,
|
|
72
|
+
attemptNumber,
|
|
73
|
+
explicitlyRejected: false,
|
|
74
|
+
generationFenced: false,
|
|
75
|
+
requestSent: false
|
|
76
|
+
})
|
|
61
77
|
/**
|
|
62
78
|
* Sends one enqueue attempt. An owned caller may replay this exact message
|
|
63
79
|
* once when transport acknowledgement remains ambiguous after send.
|
|
64
|
-
* @param {
|
|
80
|
+
* @param {import("./enqueue-acknowledgement-timeout-error.js").BackgroundJobEnqueueAttempt} attemptObservation - Mutable attempt observations.
|
|
81
|
+
* @param {Readonly<Array<import("./enqueue-acknowledgement-timeout-error.js").BackgroundJobEnqueueAttempt>>} previousAttempts - Earlier timed-out attempts.
|
|
65
82
|
* @returns {Promise<string>} - Job id.
|
|
66
83
|
*/
|
|
67
|
-
const enqueueAttempt = async (
|
|
84
|
+
const enqueueAttempt = async (attemptObservation, previousAttempts) => {
|
|
85
|
+
const attemptStartedAtMs = Date.now()
|
|
68
86
|
const request = await this._request()
|
|
69
87
|
const requestAbortController = new AbortController()
|
|
70
88
|
const timeoutErrorMessage = `Background job enqueue acknowledgement timed out after ${this.enqueueTimeoutMs}ms`
|
|
89
|
+
/** @type {number | undefined} */
|
|
90
|
+
let requestSentAtMs
|
|
71
91
|
/**
|
|
72
92
|
* Resolves the pre-send phase when the mutation has entered the socket.
|
|
73
93
|
* @type {() => void}
|
|
@@ -99,10 +119,9 @@ export default class BackgroundJobsClient {
|
|
|
99
119
|
signal: requestAbortController.signal,
|
|
100
120
|
onConnect: (jsonSocket) => {
|
|
101
121
|
jsonSocket.send(message)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
122
|
+
attemptObservation.generationFenced = Boolean(request.generationId)
|
|
123
|
+
attemptObservation.requestSent = true
|
|
124
|
+
requestSentAtMs = Date.now()
|
|
106
125
|
markRequestSent()
|
|
107
126
|
},
|
|
108
127
|
onMessage: ({message, resolve, reject}) => {
|
|
@@ -112,7 +131,7 @@ export default class BackgroundJobsClient {
|
|
|
112
131
|
}
|
|
113
132
|
|
|
114
133
|
if (message?.type === "enqueue-error") {
|
|
115
|
-
|
|
134
|
+
attemptObservation.explicitlyRejected = true
|
|
116
135
|
reject(new Error(message.error || "Failed to enqueue job"))
|
|
117
136
|
}
|
|
118
137
|
}
|
|
@@ -120,16 +139,43 @@ export default class BackgroundJobsClient {
|
|
|
120
139
|
|
|
121
140
|
await withEnqueueTimeout(async () => await Promise.race([requestSent, requestPromise]))
|
|
122
141
|
|
|
123
|
-
|
|
142
|
+
try {
|
|
143
|
+
return await withEnqueueTimeout(async () => await requestPromise)
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (!(error instanceof TimeoutError)) throw error
|
|
146
|
+
if (requestSentAtMs === undefined) throw new Error("Background job enqueue acknowledgement wait started before the request was sent", {cause: error})
|
|
147
|
+
|
|
148
|
+
const timedOutAtMs = Date.now()
|
|
149
|
+
// The fired timer proves its logical deadline even when the adjustable wall clock reports a shorter interval.
|
|
150
|
+
const acknowledgementWaitElapsedMs = Math.max(this.enqueueTimeoutMs, timedOutAtMs - requestSentAtMs)
|
|
151
|
+
|
|
152
|
+
attemptObservation.acknowledgementWaitElapsedMs = acknowledgementWaitElapsedMs
|
|
153
|
+
attemptObservation.attemptElapsedMs = Math.max(acknowledgementWaitElapsedMs, timedOutAtMs - attemptStartedAtMs)
|
|
154
|
+
|
|
155
|
+
throw new BackgroundJobEnqueueAcknowledgementTimeoutError({
|
|
156
|
+
acknowledgementTimeoutMs: this.enqueueTimeoutMs,
|
|
157
|
+
attemptHistory: [...previousAttempts, attemptObservation],
|
|
158
|
+
cause: error,
|
|
159
|
+
generationId: request.generationId,
|
|
160
|
+
jobName,
|
|
161
|
+
producerInvocationId,
|
|
162
|
+
producerProofPresent: Boolean(producerProof)
|
|
163
|
+
})
|
|
164
|
+
}
|
|
124
165
|
}
|
|
125
166
|
|
|
167
|
+
const initialAttempt = newAttemptObservation({attemptKind: "initial", attemptNumber: 1})
|
|
168
|
+
|
|
126
169
|
try {
|
|
127
|
-
return await enqueueAttempt(
|
|
170
|
+
return await enqueueAttempt(initialAttempt, [])
|
|
128
171
|
} catch (error) {
|
|
129
|
-
if (!producerInvocationId || !producerProof || !
|
|
130
|
-
}
|
|
172
|
+
if (!producerInvocationId || !producerProof || !initialAttempt.generationFenced || !initialAttempt.requestSent || initialAttempt.explicitlyRejected) throw error
|
|
131
173
|
|
|
132
|
-
|
|
174
|
+
const previousAttempts = error instanceof BackgroundJobEnqueueAcknowledgementTimeoutError ? error.attemptHistory : []
|
|
175
|
+
const replayAttempt = newAttemptObservation({attemptKind: "owned_replay", attemptNumber: 2})
|
|
176
|
+
|
|
177
|
+
return await enqueueAttempt(replayAttempt, previousAttempts)
|
|
178
|
+
}
|
|
133
179
|
}
|
|
134
180
|
|
|
135
181
|
/**
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {TimeoutError} from "awaitery/build/timeout.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {object} BackgroundJobEnqueueAttempt
|
|
7
|
+
* @property {number} acknowledgementWaitElapsedMs - Time spent waiting for the acknowledgement after send.
|
|
8
|
+
* @property {number} attemptElapsedMs - Total attempt time including connection and generation fencing.
|
|
9
|
+
* @property {"initial" | "owned_replay"} attemptKind - Initial attempt or the one eligible owned replay.
|
|
10
|
+
* @property {number} attemptNumber - One-based attempt number.
|
|
11
|
+
* @property {boolean} explicitlyRejected - Whether the main explicitly rejected the enqueue.
|
|
12
|
+
* @property {boolean} generationFenced - Whether the configured generation accepted the connection before send.
|
|
13
|
+
* @property {boolean} requestSent - Whether the enqueue request entered the socket.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Safe typed failure for an ambiguous post-send enqueue acknowledgement timeout. */
|
|
17
|
+
export default class BackgroundJobEnqueueAcknowledgementTimeoutError extends TimeoutError {
|
|
18
|
+
/**
|
|
19
|
+
* Builds an enqueue acknowledgement timeout error without request payload or connection details.
|
|
20
|
+
* @param {object} args - Safe timeout context.
|
|
21
|
+
* @param {number} args.acknowledgementTimeoutMs - Configured post-send acknowledgement deadline.
|
|
22
|
+
* @param {Array<BackgroundJobEnqueueAttempt>} args.attemptHistory - Safe observations for timed-out attempts.
|
|
23
|
+
* @param {TimeoutError} [args.cause] - Original Awaitery timeout.
|
|
24
|
+
* @param {string} [args.generationId] - Accepted release generation identity.
|
|
25
|
+
* @param {string} args.jobName - Resolved job class name.
|
|
26
|
+
* @param {string} [args.producerInvocationId] - Owned enqueue invocation identity.
|
|
27
|
+
* @param {boolean} args.producerProofPresent - Whether the request carried an internal producer proof.
|
|
28
|
+
*/
|
|
29
|
+
constructor({acknowledgementTimeoutMs, attemptHistory, cause, generationId, jobName, producerInvocationId, producerProofPresent}) {
|
|
30
|
+
super(`Background job enqueue acknowledgement timed out after ${acknowledgementTimeoutMs}ms`, cause ? {cause} : undefined)
|
|
31
|
+
|
|
32
|
+
this.name = "BackgroundJobEnqueueAcknowledgementTimeoutError"
|
|
33
|
+
/** @type {"BACKGROUND_JOB_ENQUEUE_ACKNOWLEDGEMENT_TIMEOUT"} */
|
|
34
|
+
this.code = "BACKGROUND_JOB_ENQUEUE_ACKNOWLEDGEMENT_TIMEOUT"
|
|
35
|
+
this.acknowledgementTimeoutMs = acknowledgementTimeoutMs
|
|
36
|
+
this.attemptHistory = Object.freeze(attemptHistory.map((attempt) => Object.freeze({
|
|
37
|
+
acknowledgementWaitElapsedMs: attempt.acknowledgementWaitElapsedMs,
|
|
38
|
+
attemptElapsedMs: attempt.attemptElapsedMs,
|
|
39
|
+
attemptKind: attempt.attemptKind,
|
|
40
|
+
attemptNumber: attempt.attemptNumber,
|
|
41
|
+
explicitlyRejected: attempt.explicitlyRejected,
|
|
42
|
+
generationFenced: attempt.generationFenced,
|
|
43
|
+
requestSent: attempt.requestSent
|
|
44
|
+
})))
|
|
45
|
+
this.generationId = generationId
|
|
46
|
+
this.jobName = jobName
|
|
47
|
+
this.producerInvocationId = producerInvocationId
|
|
48
|
+
this.producerProofPresent = producerProofPresent
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -4,6 +4,8 @@ import Configuration from "../configuration.js"
|
|
|
4
4
|
import Logger from "../logger.js"
|
|
5
5
|
import {scalarModelPrimaryKeyValue} from "../utils/model-primary-key.js"
|
|
6
6
|
import restArgsError from "../utils/rest-args-error.js"
|
|
7
|
+
import sha256Hex from "../utils/sha256-hex.js"
|
|
8
|
+
import stableJsonStringify from "../utils/stable-json.js"
|
|
7
9
|
|
|
8
10
|
import {declaredSyncScopeAttributes} from "./sync-scope-attributes.js"
|
|
9
11
|
import {deliverDeclaredBroadcasts, upsertSyncRow} from "./sync-change-fanout.js"
|
|
@@ -211,7 +213,8 @@ export default class SyncPublisher {
|
|
|
211
213
|
|
|
212
214
|
await record.connection().afterCommit(async () => {
|
|
213
215
|
try {
|
|
214
|
-
const
|
|
216
|
+
const scopeColumnNames = resourceConfig.scopePlan.map(({columnName}) => columnName)
|
|
217
|
+
const syncRow = await this.upsertPublishedSyncRow(attributes, operationScope, scopeColumnNames)
|
|
215
218
|
|
|
216
219
|
await this.broadcaster()({
|
|
217
220
|
body: {
|
|
@@ -362,22 +365,50 @@ export default class SyncPublisher {
|
|
|
362
365
|
/**
|
|
363
366
|
* Upserts the published server-origin sync row for a resource identity:
|
|
364
367
|
* server-origin rows carry a null actor column (no device to echo the
|
|
365
|
-
* change back to), so repeated server changes to one resource
|
|
366
|
-
* re-sequence one feed row.
|
|
368
|
+
* change back to), so repeated server changes to one complete resource and
|
|
369
|
+
* scope identity reuse and re-sequence one feed row. A database advisory
|
|
370
|
+
* lock serializes reconciliation plus the shared upsert because unique
|
|
371
|
+
* constraints containing nullable actor/scope columns do not enforce this
|
|
372
|
+
* identity portably across supported databases. Reconciliation retains the
|
|
373
|
+
* row with the newest feed sequence (then lowest id for a deterministic tie)
|
|
374
|
+
* and removes older matching server-origin rows before applying the current
|
|
375
|
+
* mutation.
|
|
367
376
|
* @param {Record<string, ReturnType<typeof JSON.parse>>} attributes - Snapshotted sync row attributes.
|
|
368
377
|
* @param {ReturnType<typeof JSON.parse>} syncModel - Operation-bound or static Sync model interface.
|
|
378
|
+
* @param {string[]} scopeColumnNames - Persisted scope columns participating in the complete identity.
|
|
369
379
|
* @returns {Promise<ReturnType<typeof JSON.parse>>} Upserted sync row.
|
|
370
380
|
*/
|
|
371
|
-
async upsertPublishedSyncRow(attributes, syncModel = this.config.syncModel) {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
381
|
+
async upsertPublishedSyncRow(attributes, syncModel = this.config.syncModel, scopeColumnNames = []) {
|
|
382
|
+
/** @type {Record<string, ReturnType<typeof JSON.parse>>} */
|
|
383
|
+
const identity = {
|
|
384
|
+
[this.config.actorForeignKeyColumn]: null,
|
|
385
|
+
resource_id: attributes.resource_id,
|
|
386
|
+
resource_type: attributes.resource_type
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
for (const columnName of scopeColumnNames) {
|
|
390
|
+
if (!Object.hasOwn(attributes, columnName)) {
|
|
391
|
+
throw new Error(`Published sync row identity is missing the scope column ${columnName}`)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
identity[columnName] = attributes[columnName]
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return await this.config.syncModel.withAdvisoryLock(syncPublisherIdentityLockName(identity), async () => {
|
|
398
|
+
const matchingSyncs = await syncModel
|
|
399
|
+
.where(identity)
|
|
400
|
+
.toArray()
|
|
401
|
+
|
|
402
|
+
matchingSyncs.sort(comparePublishedSyncRowsByRecency)
|
|
403
|
+
|
|
404
|
+
const [existingSync, ...duplicateSyncs] = matchingSyncs
|
|
405
|
+
|
|
406
|
+
for (const duplicateSync of duplicateSyncs) {
|
|
407
|
+
await duplicateSync.destroy()
|
|
408
|
+
}
|
|
379
409
|
|
|
380
|
-
|
|
410
|
+
return await upsertSyncRow({attributes, existingSync, syncModel})
|
|
411
|
+
}, {dedicatedConnection: true})
|
|
381
412
|
}
|
|
382
413
|
|
|
383
414
|
/**
|
|
@@ -434,6 +465,40 @@ export default class SyncPublisher {
|
|
|
434
465
|
}
|
|
435
466
|
}
|
|
436
467
|
|
|
468
|
+
/**
|
|
469
|
+
* Returns a deterministic, MySQL-safe advisory-lock name for one complete
|
|
470
|
+
* server-origin publisher identity. Stable JSON preserves null actor/scope
|
|
471
|
+
* components distinctly from strings, and the truncated SHA-256 digest keeps
|
|
472
|
+
* the final name below MySQL/MariaDB's 64-character `GET_LOCK` limit.
|
|
473
|
+
* @param {Record<string, ReturnType<typeof JSON.parse>>} identity - Complete sync-row identity in column form.
|
|
474
|
+
* @returns {string} Advisory-lock name.
|
|
475
|
+
*/
|
|
476
|
+
function syncPublisherIdentityLockName(identity) {
|
|
477
|
+
const hash = sha256Hex(stableJsonStringify(identity)).slice(0, 32)
|
|
478
|
+
|
|
479
|
+
return `vsp:${hash}`
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Orders matching published rows by the feed's public recency contract so
|
|
484
|
+
* legacy duplicates have one deterministic survivor. Server sequences are
|
|
485
|
+
* positive and monotonic; a legacy null sequence is older than any assigned
|
|
486
|
+
* sequence, and the immutable row id breaks otherwise-equal ties.
|
|
487
|
+
* @param {ReturnType<typeof JSON.parse>} left - First matching sync row.
|
|
488
|
+
* @param {ReturnType<typeof JSON.parse>} right - Second matching sync row.
|
|
489
|
+
* @returns {number} Sort comparison with the canonical survivor first.
|
|
490
|
+
*/
|
|
491
|
+
function comparePublishedSyncRowsByRecency(left, right) {
|
|
492
|
+
const leftSequence = left.serverSequence()
|
|
493
|
+
const rightSequence = right.serverSequence()
|
|
494
|
+
|
|
495
|
+
if (leftSequence === null && rightSequence !== null) return 1
|
|
496
|
+
if (leftSequence !== null && rightSequence === null) return -1
|
|
497
|
+
if (leftSequence !== rightSequence) return rightSequence - leftSequence
|
|
498
|
+
|
|
499
|
+
return left.id().localeCompare(right.id())
|
|
500
|
+
}
|
|
501
|
+
|
|
437
502
|
/**
|
|
438
503
|
* Resolves a model class's active publish declaration from `static sync`.
|
|
439
504
|
* Opted-out (`publish: false`) and undeclared models resolve to null; every
|