@sentio/runtime 4.3.5-rc.1 → 4.3.5-rc.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentio/runtime",
3
- "version": "4.3.5-rc.1",
3
+ "version": "4.3.5-rc.3",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
package/src/db-context.ts CHANGED
@@ -36,8 +36,69 @@ type ProcessStreamResponseV3Init = MessageInitShape<typeof ProcessStreamResponse
36
36
  type Request = NonNullable<MessageInitShape<typeof DBRequestSchema>['op']>
37
37
  type RequestType = NonNullable<Request['case']>
38
38
 
39
+ // Both ends of a stack carry the useful information, so a deep one is trimmed in the
40
+ // middle rather than at the tail: the top frames are where the late call was actually
41
+ // made (runtime frames are already trimmed off by captureStackTrace), and the bottom
42
+ // ones are the entry point that led there — which is the closest thing to naming the
43
+ // handler that the stack can offer, on the occasions it has not been lost.
44
+ const MAX_HEAD_FRAMES = 8
45
+ const MAX_TAIL_FRAMES = 4
46
+
47
+ // V8 keeps only the innermost `Error.stackTraceLimit` frames — 10 by default — so on a
48
+ // deep stack the entry point is discarded before we ever see it. Raised just for this
49
+ // capture, since keeping both ends is the entire point, and restored immediately after.
50
+ const STACK_CAPTURE_DEPTH = 50
51
+
39
52
  export const timeoutError = new Error('timeout')
40
53
 
54
+ // Name the entity and id, not just the op kind: "get BinanceAlphaPriceEntity 56-0x…"
55
+ // usually points straight at the call site, while a bare "get" rarely does.
56
+ function describeRequest(request: Request): string {
57
+ switch (request.case) {
58
+ case 'get':
59
+ return `get ${request.value.entity} ${request.value.id}`
60
+ case 'list':
61
+ return `list ${request.value.entity}`
62
+ case 'upsert':
63
+ case 'update':
64
+ case 'delete': {
65
+ const entities = [...new Set(request.value.entity ?? [])].join(',')
66
+ return `${request.case} ${entities} (${request.value.id?.length ?? 0} row(s))`
67
+ }
68
+ default:
69
+ // The oneof can be unset, in which case `case` is undefined.
70
+ return request.case ?? 'op'
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Keep both ends of a stack, dropping the middle when it is too long to inline.
76
+ *
77
+ * A diagnostic must not grow with the depth of what it describes, but truncating from one
78
+ * end throws away half of what makes it useful: the top frames are where the late call was
79
+ * made, the bottom ones the entry point that led there.
80
+ *
81
+ * Node's own internals are dropped first so the budget goes to frames the reader can act
82
+ * on — otherwise a deep call inside the test runner or the processor host fills the tail
83
+ * with `node:internal/...` and pushes the user's entry point into the omitted middle.
84
+ * V8-synthesised markers like `async Promise.all (index 3)` are not `node:` frames and are
85
+ * kept; they are often the single most informative line.
86
+ */
87
+ function boundedFrames(frames: string[]): string[] {
88
+ const ownCode = frames.filter((frame) => !frame.includes('node:'))
89
+ // If everything was internal there is nothing better to show.
90
+ const usable = ownCode.length ? ownCode : frames
91
+ if (usable.length <= MAX_HEAD_FRAMES + MAX_TAIL_FRAMES) {
92
+ return usable
93
+ }
94
+ const omitted = usable.length - MAX_HEAD_FRAMES - MAX_TAIL_FRAMES
95
+ return [
96
+ ...usable.slice(0, MAX_HEAD_FRAMES),
97
+ ` ... ${omitted} frame(s) omitted ...`,
98
+ ...usable.slice(-MAX_TAIL_FRAMES)
99
+ ]
100
+ }
101
+
41
102
  export interface IStoreContext {
42
103
  sendRequest(request: Request, timeoutSecs?: number): Promise<DBResponse>
43
104
 
@@ -64,7 +125,7 @@ export abstract class AbstractStoreContext implements IStoreContext {
64
125
  // Set once the process has finished and the context was closed. Guards
65
126
  // against a lingering batch timer emitting after the final result (which
66
127
  // would both lose the write and desync the reused processor stream).
67
- private closed = false
128
+ protected closed = false
68
129
 
69
130
  constructor(readonly processId: number) {}
70
131
 
@@ -78,6 +139,10 @@ export abstract class AbstractStoreContext implements IStoreContext {
78
139
  abstract doSend(resp: ProcessStreamResponseInit | ProcessStreamResponseV3Init): void
79
140
 
80
141
  sendRequest(request: Request, timeoutSecs?: number): Promise<DBResponse> {
142
+ if (this.closed) {
143
+ return Promise.reject(this.reportLateMessage(`store ${describeRequest(request)}`, this.sendRequest))
144
+ }
145
+
81
146
  if (STORE_BATCH_IDLE > 0 && STORE_BATCH_SIZE > 1 && request.case === 'upsert') {
82
147
  // batch upsert if possible
83
148
  return this.sendUpsertInBatch(request.value as DBRequest_DBUpsert)
@@ -169,6 +234,21 @@ export abstract class AbstractStoreContext implements IStoreContext {
169
234
  this.doSend({ value: { case: 'result', value: errorResult }, processId })
170
235
  }
171
236
 
237
+ /**
238
+ * Mark the process finished. Must be called synchronously *immediately before*
239
+ * the final result is emitted — not from a later `.finally()`.
240
+ *
241
+ * Once the result is on the stream the driver may hand that stream to another
242
+ * process, so nothing may follow it. The gap between emitting the result and
243
+ * close() running is several microtasks wide, which is more than enough for a
244
+ * detached continuation to wake up and slip a request through: without this the
245
+ * observed outbound order is ["result", "dbRequest"], exactly what the guards
246
+ * exist to prevent. close() still does the teardown afterwards.
247
+ */
248
+ finish() {
249
+ this.closed = true
250
+ }
251
+
172
252
  close() {
173
253
  this.closed = true
174
254
  // Drop any un-flushed batch and cancel its timer so it can never emit
@@ -266,6 +346,53 @@ export abstract class AbstractStoreContext implements IStoreContext {
266
346
  }
267
347
  }
268
348
 
349
+ /**
350
+ * Report a message the process tried to emit after it had already finished, and
351
+ * return the Error describing it.
352
+ *
353
+ * Such a message cannot be delivered: the final result was already sent, and the
354
+ * stream it would go out on has been handed back to the pool and may now belong to
355
+ * another process — writing to it makes the driver fail *that* process with ERR200
356
+ * "unexpected ProcessID". Dropping it is the only safe option.
357
+ *
358
+ * This always logs rather than relying solely on the rejection, because the late
359
+ * call is nearly always on a path that already swallows errors — the classic case
360
+ * being one `Promise.all` branch rejecting while its siblings keep running — so the
361
+ * rejection alone would be invisible. The Error is built here, at the call site, so
362
+ * its stack points into the handler code that failed to await.
363
+ *
364
+ * `boundary` is the runtime entry point the caller came through; its frame and
365
+ * everything below it is trimmed from the stack so the very first line the user
366
+ * reads is their own code rather than two frames of ours.
367
+ */
368
+ protected reportLateMessage(what: string, boundary?: (...args: never[]) => unknown): Error {
369
+ const summary =
370
+ `[sentio] ${what} was issued after process ${this.processId} had already finished, so it was ` +
371
+ `dropped. Something in the handler was still running after the handler returned — every store ` +
372
+ `write, store read and metric must be awaited before the handler completes. A common cause is ` +
373
+ `one Promise.all() branch rejecting while its siblings keep running: Promise.all rejects ` +
374
+ `immediately but does NOT cancel the others, so give each branch its own .catch() (or use ` +
375
+ `Promise.allSettled) to keep them inside the handler.`
376
+
377
+ const previousLimit = Error.stackTraceLimit
378
+ Error.stackTraceLimit = Math.max(previousLimit, STACK_CAPTURE_DEPTH)
379
+ const err = new Error(summary)
380
+ Error.captureStackTrace?.(err, boundary ?? this.reportLateMessage)
381
+ // Read `stack` before restoring: the frames are captured at construction, but forcing
382
+ // the (lazy) formatting here keeps this independent of when anyone reads it.
383
+ const captured = err.stack ?? ''
384
+ Error.stackTraceLimit = previousLimit
385
+
386
+ // Inline the frames into `message`. The datasource log view renders only the message,
387
+ // so a stack left in `err.stack` alone is invisible to the user who has to act on it.
388
+ const frames = boundedFrames(captured.split('\n').slice(1))
389
+ if (frames.length) {
390
+ err.message = `${summary}\nIssued at:\n${frames.join('\n')}`
391
+ }
392
+ console.error(err)
393
+ return err
394
+ }
395
+
269
396
  async awaitPendings() {
270
397
  // Flush any buffered upsert batch and wait for its ack before returning.
271
398
  // Callers use this to guarantee every write has been sent AND applied by
@@ -308,6 +435,13 @@ export class DataBindingContext extends AbstractStoreContext implements IDataBin
308
435
  }
309
436
 
310
437
  sendTemplateRequest(templates: Array<TemplateInstance>, unbind: boolean) {
438
+ if (this.closed) {
439
+ this.reportLateMessage(
440
+ `${unbind ? 'unbind' : 'bind'} of ${templates.length} template instance(s)`,
441
+ this.sendTemplateRequest
442
+ )
443
+ return
444
+ }
311
445
  this.subject.next({
312
446
  processId: this.processId,
313
447
  value: {
@@ -320,6 +454,10 @@ export class DataBindingContext extends AbstractStoreContext implements IDataBin
320
454
  })
321
455
  }
322
456
  sendTimeseriesRequest(timeseries: Array<TimeseriesResult>) {
457
+ if (this.closed) {
458
+ this.reportLateMessage(`${timeseries.length} timeseries record(s)`, this.sendTimeseriesRequest)
459
+ return
460
+ }
323
461
  this.subject.next({
324
462
  processId: this.processId,
325
463
  value: {
package/src/service-v3.ts CHANGED
@@ -181,6 +181,11 @@ export class ProcessorServiceImplV3 implements ServiceImpl<typeof ProcessorV3> {
181
181
  const otherResults = clone(ProcessResultSchema, result)
182
182
  otherResults.timeseriesResult = []
183
183
 
184
+ // Close the context to further messages in the same synchronous step that
185
+ // emits the result: after this point the stream may be recycled to another
186
+ // process, and waiting for the .finally() below would leave a microtask
187
+ // window in which a detached continuation could still append to it.
188
+ context.finish()
184
189
  subject.next({
185
190
  processId,
186
191
  value: {
@@ -191,6 +196,7 @@ export class ProcessorServiceImplV3 implements ServiceImpl<typeof ProcessorV3> {
191
196
  })
192
197
  .catch((e) => {
193
198
  console.error(e, e.stack)
199
+ context.finish() // same reasoning as the success path — error() emits a result too
194
200
  context.error(processId, e)
195
201
  process_binding_error.add(1)
196
202
  })
@@ -1 +0,0 @@
1
- {"version":3,"file":"plugin-7YBR3bUA.d.ts","names":[],"sources":["../src/db-context.ts","../src/plugin.ts"],"mappings":"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"}