-
Notifications
You must be signed in to change notification settings - Fork 1
Add lease transaction statistics #22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
590863f
a501e92
3794364
7ba674c
c30fa81
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,6 +1,6 @@ | ||||||||||||
| import _ from 'lodash'; | ||||||||||||
| import ms from 'ms'; | ||||||||||||
| import NodeFire from 'nodefire'; | ||||||||||||
| import NodeFire, {type TransactionMetadata} from 'nodefire'; | ||||||||||||
| import * as timers from 'safe-timers'; | ||||||||||||
| import { | ||||||||||||
| FireleaseStats, QueueSourceStats, QueueStats, type QueueSourceMode | ||||||||||||
|
|
@@ -14,6 +14,7 @@ const QUEUE_CHECK_TIMEOUT = ms('15s'); | |||||||||||
| const QUEUE_SIZE_HYSTERESIS = 0.15; | ||||||||||||
| const QUEUE_SIZE_MISMATCH_THRESHOLD = 100; | ||||||||||||
| const DEMOTION_JITTER = ms('30s'); | ||||||||||||
| const LEASE_TRANSACTION_DURATION_ALPHA = 0.1; | ||||||||||||
|
|
||||||||||||
| declare const RETRY_DIRECTIVE: unique symbol; | ||||||||||||
|
|
||||||||||||
|
|
@@ -29,6 +30,10 @@ export interface Lease { | |||||||||||
| extendLeasePromise?: Promise<void>; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| export type AcquiredLease = Lease & { | ||||||||||||
| expiry: number, time: number, attempts: number, initial: number, readonly firstAcquisition?: true | ||||||||||||
| }; | ||||||||||||
|
|
||||||||||||
| export interface LeaseItem { | ||||||||||||
| _lease?: Lease; | ||||||||||||
| [key: string]: any; | ||||||||||||
|
|
@@ -39,7 +44,7 @@ export interface RetryDirective { | |||||||||||
| } | ||||||||||||
|
|
||||||||||||
| export interface WorkerItem extends LeaseItem { | ||||||||||||
| _lease: Lease & {expiry: number}; | ||||||||||||
| _lease: AcquiredLease; | ||||||||||||
| readonly $ref: NodeFire; | ||||||||||||
| readonly $leaseTimeRemaining: number; | ||||||||||||
| } | ||||||||||||
|
|
@@ -69,13 +74,19 @@ export interface FireleaseError extends Error { | |||||||||||
| level?: FireleaseErrorLevel; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| export type LeaseTransactionOutcome = 'acquired' | 'contended' | 'failed'; | ||||||||||||
| export type CaptureLeaseTransactionMetrics = ( | ||||||||||||
| outcome: LeaseTransactionOutcome, tries: number, duration: number | ||||||||||||
| ) => undefined; | ||||||||||||
|
|
||||||||||||
| export interface QueueOptions { | ||||||||||||
| maxConcurrent?: number; | ||||||||||||
| bufferSize?: number; | ||||||||||||
| minLease?: Duration; | ||||||||||||
| maxLease?: Duration; | ||||||||||||
| healthyPingLatency?: Duration; | ||||||||||||
| preprocess?: (item: LeaseItem) => LeaseItem; | ||||||||||||
| captureLeaseTransactionMetrics?: CaptureLeaseTransactionMetrics; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| export type PingReport = FireleaseStats; | ||||||||||||
|
|
@@ -121,6 +132,7 @@ interface NormalizedQueueOptions { | |||||||||||
| maxLease: number; | ||||||||||||
| healthyPingLatency: number; | ||||||||||||
| preprocess?: (item: LeaseItem) => LeaseItem; | ||||||||||||
| captureLeaseTransactionMetrics?: CaptureLeaseTransactionMetrics; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| const queues: Queue[] = []; | ||||||||||||
|
|
@@ -261,12 +273,16 @@ class Task { | |||||||||||
| async process() { | ||||||||||||
| let startTimestamp = 0; | ||||||||||||
| let acquired = false; | ||||||||||||
| let contended = false; | ||||||||||||
| let reschedule = true; | ||||||||||||
| let firstAcquisition = false; | ||||||||||||
| this.working = true; | ||||||||||||
| this.phase = 'lease'; | ||||||||||||
| const transactionPromise = this.ref.transaction(itemValue => { | ||||||||||||
| const item = itemValue as LeaseItem | null; | ||||||||||||
| acquired = false; | ||||||||||||
| contended = false; | ||||||||||||
| firstAcquisition = false; | ||||||||||||
| if (tasks[this.key] !== this || this.removed) return; | ||||||||||||
| if (!item || this.ref.key === PING_KEY) { | ||||||||||||
| acquired = true; | ||||||||||||
|
|
@@ -276,9 +292,11 @@ class Task { | |||||||||||
| // console.log('txn ', this.ref.key, 'lease', item._lease, 'now', startTimestamp); | ||||||||||||
| // Check if another process beat us to it. | ||||||||||||
| if (item._lease?.expiry && item._lease.expiry > startTimestamp) { | ||||||||||||
| contended = true; | ||||||||||||
| return item; | ||||||||||||
| } | ||||||||||||
| acquired = true; | ||||||||||||
| firstAcquisition = _.isNil(item._lease?.initial); | ||||||||||||
| item._lease ??= {}; | ||||||||||||
| item._lease.time = this.queue.constrainLeaseDuration((item._lease.time ?? 0) * 2); | ||||||||||||
| item._lease.expiry = startTimestamp + item._lease.time; | ||||||||||||
|
|
@@ -287,14 +305,22 @@ class Task { | |||||||||||
| item._lease.busy = true; | ||||||||||||
| return this.queue.callPreprocess(item); | ||||||||||||
| }, {detectStuck: 5, prefetchValue: false, timeout: ms('15s')}); | ||||||||||||
| let transactionCompleted = false; | ||||||||||||
| try { | ||||||||||||
| const item = await transactionPromise; | ||||||||||||
| transactionCompleted = true; | ||||||||||||
| if (acquired && item !== null && this.ref.key !== PING_KEY) { | ||||||||||||
| if (!_.isObject(item)) throw new Error(`item not an object: ${item}`); | ||||||||||||
| this.recordLeaseTransaction('acquired', transactionPromise.transaction); | ||||||||||||
| if (firstAcquisition) Object.defineProperty(item._lease, 'firstAcquisition', {value: true}); | ||||||||||||
|
Comment on lines
+313
to
+314
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Safety check that a leased task is a real object was accidentally deleted The guard that verified an acquired task is a proper object was replaced (by the Mechanism: replaced assertion in the acquired branch of Task.processBefore this PR the acquired branch read: In commit 3794364 that line was overwritten with the
Suggested change
Was this helpful? React with 👍 or 👎 to provide feedback.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This has not triggered in living memory so I think it's fine to leave the case to an internal error. Keeping the check there messed up the types for the |
||||||||||||
| this.queue.stats.tasksAcquired++; | ||||||||||||
| await this.run(item as WorkerItem, startTimestamp); | ||||||||||||
| } else if (contended) { | ||||||||||||
| this.recordLeaseTransaction('contended', transactionPromise.transaction); | ||||||||||||
| } | ||||||||||||
| } catch (error) { | ||||||||||||
| if (!transactionCompleted && this.ref.key !== PING_KEY) { | ||||||||||||
| this.recordLeaseTransaction('failed', transactionPromise.transaction); | ||||||||||||
| } | ||||||||||||
|
Comment on lines
+321
to
+323
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔍 Metric recording inside the failure handler can escape and leak a concurrency slot
Was this helpful? React with 👍 or 👎 to provide feedback.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Made the entire metric-recording path defensive. Missing NodeFire transaction metadata now falls back to zero, and errors from recording or invoking the metric callback are reported through a nested guard so even a throwing |
||||||||||||
| reschedule = false; | ||||||||||||
| // Hardcoded retry -- hard to do anything smarter, since we failed to update the task in | ||||||||||||
| // Firebase. | ||||||||||||
|
|
@@ -315,6 +341,37 @@ class Task { | |||||||||||
| } | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| recordLeaseTransaction( | ||||||||||||
| outcome: LeaseTransactionOutcome, transaction: TransactionMetadata | undefined | ||||||||||||
| ) { | ||||||||||||
| try { | ||||||||||||
| const tries = transaction?.tries ?? 0; | ||||||||||||
| const transactionDuration = transaction?.duration ?? 0; | ||||||||||||
| const leaseStats = this.source.stats.leaseTransactions; | ||||||||||||
| const priorAttempts = leaseStats.acquired + leaseStats.contended + leaseStats.failed; | ||||||||||||
| leaseStats[outcome] += 1; | ||||||||||||
| leaseStats.tries += tries; | ||||||||||||
| leaseStats.duration = priorAttempts === 0 ? | ||||||||||||
| transactionDuration : | ||||||||||||
| leaseStats.duration * (1 - LEASE_TRANSACTION_DURATION_ALPHA) + | ||||||||||||
| transactionDuration * LEASE_TRANSACTION_DURATION_ALPHA; | ||||||||||||
| this.queue.options.captureLeaseTransactionMetrics?.(outcome, tries, transactionDuration); | ||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's adjust the callback type to exclude
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tightened |
||||||||||||
| } catch (error) { | ||||||||||||
| try { | ||||||||||||
| const metricError: FireleaseError = _.isError(error) ? error : new Error(String(error)); | ||||||||||||
| metricError.firelease = _.assign( | ||||||||||||
| metricError.firelease ?? {}, {itemKey: this.key, phase: 'lease-metric'}); | ||||||||||||
| settings.captureError(metricError); | ||||||||||||
| } catch (captureError) { | ||||||||||||
| try { | ||||||||||||
| console.error('Error capturing lease transaction metric error:', captureError); | ||||||||||||
| } catch { | ||||||||||||
| // Metric recording must never interrupt task processing. | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| async run(item: WorkerItem, startTimestamp: number) { | ||||||||||||
| Object.defineProperty(item, '$ref', {value: this.ref}); | ||||||||||||
| Object.defineProperty(item, '$leaseTimeRemaining', {get: () => { | ||||||||||||
|
|
@@ -395,7 +452,7 @@ class Task { | |||||||||||
| if (currentItem._lease) delete currentItem._lease.busy; | ||||||||||||
| return currentItem; | ||||||||||||
| }, {prefetchValue: false}) as LeaseItem | null | undefined; | ||||||||||||
| if (item2) item._lease = item2._lease as Lease & {expiry: number}; | ||||||||||||
| if (item2) item._lease = item2._lease as AcquiredLease; | ||||||||||||
| } catch (postProcessingError) { | ||||||||||||
| this.handlePostProcessingError(postProcessingError); | ||||||||||||
| } | ||||||||||||
|
|
@@ -873,7 +930,7 @@ class QueueSource { | |||||||||||
| } | ||||||||||||
|
|
||||||||||||
| recordPingResult(startedAt: number, succeeded: boolean) { | ||||||||||||
| const latency = performance.now() - startedAt; | ||||||||||||
| const latency = Math.round(performance.now() - startedAt); | ||||||||||||
| this.stats.latency = latency; | ||||||||||||
| this.stats.healthy = succeeded && latency < this.queue.options.healthyPingLatency; | ||||||||||||
|
Comment on lines
+933
to
935
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a successful ping finishes just below an integer Useful? React with 👍 / 👎. |
||||||||||||
| this.stats.pingTimestamp = Date.now(); | ||||||||||||
|
|
@@ -1069,9 +1126,15 @@ class Queue { | |||||||||||
| * control (e.g., webhooks). | ||||||||||||
| * healthyPingLatency: {number | string} the maximum response latency to pings that is | ||||||||||||
| * considered "healthy" for this queue. | ||||||||||||
| * captureLeaseTransactionMetrics: {function(string, number, number)} a callback invoked | ||||||||||||
| * after each acquired, contended, or failed task lease transaction with its outcome, | ||||||||||||
| * NodeFire transaction tries, and duration in milliseconds. The callback must be | ||||||||||||
| * synchronous. | ||||||||||||
| * @param {function(Object):RETRY | number | string | undefined} worker The worker function that | ||||||||||||
| * handles enqueued tasks. It will be given a task object as argument, with a special $ref | ||||||||||||
| * attribute set to the Nodefire ref of that task. The worker can perform arbitrary | ||||||||||||
| * attribute set to the Nodefire ref of that task. On a task's first acquisition its _lease | ||||||||||||
| * also has a non-enumerable firstAcquisition property set to true; it is not saved to | ||||||||||||
| * Firebase and is absent on subsequent acquisitions. The worker can perform arbitrary | ||||||||||||
| * computation whose duration should not exceed the queue's minLease value. It can | ||||||||||||
| * manipulate the task itself in Firebase as well, e.g. to delete it (to get at-most-once | ||||||||||||
| * queue semantics) or otherwise modify it. The worker can return any of the following: | ||||||||||||
|
|
||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a worker retries by returning an allowed complete
Leaseobject withoutinitial(for example,{expiry: retryAt}), post-processing replaces_leaseand drops the previous marker. On the next lease this check evaluates true, so_lease.firstAcquisitionis exposed again even though the task has already run, causing workers that use the new flag to repeat first-run behavior.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's no way to preserve this history without persisting it and I'd rather not add another field at this time. It doesn't matter for our current usage since we only use
firstAcquisitionin conjunction withcreated, and the latter gets cleared or appropriately reset wheneverinitialis.