@cosmicdrift/kumiko-framework 0.383.1 → 0.385.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/agents/examples/custom-fields-basic.md +13 -3
- package/agents/examples/designer-demo.md +6 -2
- package/agents/examples/i18n.md +36 -1
- package/agents/features.md +4 -0
- package/dist/api/auth-rate-limiters.d.ts +8 -0
- package/dist/api/auth-rate-limiters.js +105 -0
- package/dist/api/auth-routes-context.d.ts +34 -0
- package/dist/api/auth-routes-context.js +192 -0
- package/dist/api/auth-routes-invite.d.ts +2 -0
- package/dist/api/auth-routes-invite.js +171 -0
- package/dist/api/auth-routes-login.d.ts +2 -0
- package/dist/api/auth-routes-login.js +103 -0
- package/dist/api/auth-routes-mfa.d.ts +4 -0
- package/dist/api/auth-routes-mfa.js +211 -0
- package/dist/api/auth-routes-session.d.ts +2 -0
- package/dist/api/auth-routes-session.js +144 -0
- package/dist/api/auth-routes-shared.d.ts +33 -0
- package/dist/api/auth-routes-shared.js +81 -0
- package/dist/api/auth-routes-signup.d.ts +2 -0
- package/dist/api/auth-routes-signup.js +75 -0
- package/dist/api/auth-routes-token-flows.d.ts +4 -0
- package/dist/api/auth-routes-token-flows.js +91 -0
- package/dist/api/auth-routes-types.d.ts +76 -0
- package/dist/api/auth-routes-types.js +1 -0
- package/dist/api/auth-routes.d.ts +5 -85
- package/dist/api/auth-routes.js +20 -1095
- package/dist/config/index.d.ts +1 -1
- package/dist/crypto/pii-ciphertext-format.js +1 -1
- package/dist/db/blind-index-cleanup.js +1 -1
- package/dist/db/dialect.js +1 -1
- package/dist/db/distinct-on.d.ts +9 -0
- package/dist/db/distinct-on.js +28 -0
- package/dist/db/event-store-executor-write.js +2 -2
- package/dist/db/migrate-runner.js +4 -4
- package/dist/db/pg-error.js +0 -12
- package/dist/db/queries/event-consumer.js +3 -3
- package/dist/db/queries/postgres-array-literal.js +2 -2
- package/dist/db/query.js +48 -20
- package/dist/db/tenant-db.js +1 -1
- package/dist/engine/boot-validator/i18n-keys.d.ts +2 -0
- package/dist/engine/boot-validator/i18n-keys.js +15 -1
- package/dist/engine/boot-validator/index.d.ts +4 -0
- package/dist/engine/boot-validator/index.js +3 -1
- package/dist/engine/boot-validator/pii-retention.js +6 -7
- package/dist/engine/boot-validator/screens-actions.d.ts +67 -0
- package/dist/engine/boot-validator/screens-actions.js +488 -0
- package/dist/engine/boot-validator/screens-dashboard.d.ts +15 -0
- package/dist/engine/boot-validator/screens-dashboard.js +467 -0
- package/dist/engine/boot-validator/screens-forms.d.ts +24 -0
- package/dist/engine/boot-validator/screens-forms.js +516 -0
- package/dist/engine/boot-validator/screens-lists.d.ts +47 -0
- package/dist/engine/boot-validator/screens-lists.js +319 -0
- package/dist/engine/boot-validator/screens.d.ts +4 -21
- package/dist/engine/boot-validator/screens.js +26 -1799
- package/dist/engine/build-app-schema.js +1 -1
- package/dist/engine/factories.d.ts +3 -3
- package/dist/engine/steps/read-find-one.js +1 -1
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/event-store/provenance-append.js +1 -1
- package/dist/files/file-ref-entity.d.ts +6 -126
- package/dist/files/file-ref-table.d.ts +6 -126
- package/dist/files/file-routes.js +39 -16
- package/dist/i18n/required-surface-keys.d.ts +22 -2
- package/dist/i18n/required-surface-keys.js +91 -3
- package/dist/jobs/job-runner-helpers.d.ts +45 -0
- package/dist/jobs/job-runner-helpers.js +281 -0
- package/dist/jobs/job-runner-schedulers.d.ts +23 -0
- package/dist/jobs/job-runner-schedulers.js +106 -0
- package/dist/jobs/job-runner-types.d.ts +65 -0
- package/dist/jobs/job-runner-types.js +2 -0
- package/dist/jobs/job-runner.d.ts +4 -75
- package/dist/jobs/job-runner.js +7 -384
- package/dist/pipeline/dispatch-shared.js +2 -2
- package/dist/pipeline/event-dispatcher-admin.d.ts +15 -8
- package/dist/pipeline/event-dispatcher-admin.js +28 -7
- package/dist/pipeline/event-dispatcher.d.ts +2 -2
- package/dist/pipeline/event-dispatcher.js +3 -4
- package/dist/pipeline/index.d.ts +2 -2
- package/dist/pipeline/index.js +1 -1
- package/dist/schema-cli.js +5 -0
- package/package.json +3 -3
- package/src/changes.json +39 -0
package/dist/jobs/job-runner.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/// <reference types="temporal-polyfill/global" preserve="true" />
|
|
2
2
|
import { escapeHatchFor } from "@cosmicdrift/kumiko-types/handlers";
|
|
3
|
-
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
4
3
|
import { DelayedError, Queue, UnrecoverableError, Worker } from "bullmq";
|
|
5
4
|
import { Redis } from "ioredis";
|
|
6
5
|
import { requestContext } from "../api/request-context.js";
|
|
@@ -9,398 +8,22 @@ import { createDerivativesContext } from "../derivatives/derivatives-context.js"
|
|
|
9
8
|
import { bridgeStub } from "../engine/bridge-stub.js";
|
|
10
9
|
import { qnScope } from "../engine/qualified-name.js";
|
|
11
10
|
import { createSystemUser } from "../engine/system-user.js";
|
|
12
|
-
import { SYSTEM_TENANT_ID
|
|
11
|
+
import { SYSTEM_TENANT_ID } from "../engine/types/index.js";
|
|
13
12
|
import { InternalError } from "../errors/index.js";
|
|
14
|
-
import { isKumikoError } from "../errors/kumiko-error.js";
|
|
15
13
|
import { createTenantFileContext } from "../files/file-handle.js";
|
|
16
14
|
import { createFallbackLogger, scrubLogText } from "../logging/index.js";
|
|
17
|
-
import { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, createMetricsForFactory, emitJobLastSuccess,
|
|
15
|
+
import { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, createMetricsForFactory, emitJobLastSuccess, flushEscapeHatchAudits, getFallbackTracer, registerStandardMetrics, withEscapeHatchAuditScope, } from "../observability/index.js";
|
|
18
16
|
import { configAccessorFactoryOf } from "../pipeline/config-accessor-factory.js";
|
|
19
17
|
import { createDistributedLock } from "../pipeline/distributed-lock.js";
|
|
20
18
|
import { RedisKeys } from "../pipeline/redis-keys.js";
|
|
21
19
|
import { createGatedIdentitySwitch } from "../pipeline/system-identity-switch.js";
|
|
22
|
-
import {
|
|
23
|
-
import { buildPersonalDataGate, isPersonalDataGated, parseWriteOrigin, } from "../pipeline/write-origin.js";
|
|
24
|
-
import { createTzContext, isValidIanaTimeZone } from "../time/index.js";
|
|
20
|
+
import { buildPersonalDataGate, parseWriteOrigin } from "../pipeline/write-origin.js";
|
|
25
21
|
import { cronRepeatOptions } from "./cron-repeat-options.js";
|
|
22
|
+
import { BOOT_GATE_SLOW_WARN_MS, BOOT_REDIS_TIMEOUT_MS, buildRetryBullOpts, COMPLETED_JOB_RETENTION_AGE_SEC, captureTraceContext, createCrossTenantReads, createJobLogger, DEFAULT_MAX_DEFERS, DEFER_BACKOFF_MAX_DOUBLINGS, DEFER_COUNT_KEY, DEFER_DELAY_CAP_MS, FAILED_JOB_RETENTION_AGE_SEC, HANDLER_DEFER_COUNT_KEY, isJobTriggerEvent, jobSubjectKey, maxRetryWindowMs, missingDbProxy, parseRedisOpts, payloadMatchesTriggerFilter, priorAttemptsOf, readTraceContext, resolveJobTz, STOP_QUEUE_READY_TIMEOUT_MS, stampGatedWriteOrigin, startQueueDepthPolling, TRACE_CONTEXT_KEY, tenantFailureMessageKey, timeoutReject, } from "./job-runner-helpers.js";
|
|
23
|
+
import { ACTIVE_TENANT_IDS_QUERY_NAME, bootJobIdForJobName, DEFAULT_QUEUE_NAME_PREFIX, markSchedulersSeen, ORPHAN_SCHEDULER_GRACE_MS, perTenantChildJobId, queueNameFor, removeOrphanJobSchedulers, schedulerIdForJobName, } from "./job-runner-schedulers.js";
|
|
26
24
|
import { resolveMaxPerTenant } from "./max-per-tenant.js";
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
// malformed `_triggerEvent` only costs an idempotency-key convenience — the
|
|
30
|
-
// run proceeds without it rather than failing over what would be a framework
|
|
31
|
-
// bug, not untrusted input.
|
|
32
|
-
function isJobTriggerEvent(value) {
|
|
33
|
-
if (!isPlainObject(value))
|
|
34
|
-
return false;
|
|
35
|
-
if (typeof value["id"] !== "string")
|
|
36
|
-
return false;
|
|
37
|
-
const headers = value["headers"];
|
|
38
|
-
if (typeof headers !== "object" || headers === null)
|
|
39
|
-
return false;
|
|
40
|
-
return Object.values(headers).every((v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean");
|
|
41
|
-
}
|
|
42
|
-
// A payload's own `_writeOrigin` is never trusted; only the ambient gated origin is stamped.
|
|
43
|
-
function stampGatedWriteOrigin(data) {
|
|
44
|
-
delete data["_writeOrigin"];
|
|
45
|
-
const origin = requestContext.get()?.writeOrigin;
|
|
46
|
-
if (origin && isPersonalDataGated(origin)) {
|
|
47
|
-
data["_writeOrigin"] = origin;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
// Queue-name convention: <prefix>-<lane>. The prefix is fixed in prod
|
|
51
|
-
// ("kumiko-jobs") — it must match between enqueuers and consumers, and an
|
|
52
|
-
// accidental drift would silently drop jobs. Tests override via
|
|
53
|
-
// `queueNamePrefix` for per-run isolation (stale jobs from a prior run
|
|
54
|
-
// don't leak into a new test because the queue name includes a timestamp).
|
|
55
|
-
const DEFAULT_QUEUE_NAME_PREFIX = "kumiko-jobs";
|
|
56
|
-
function queueNameFor(prefix, lane) {
|
|
57
|
-
return `${prefix}-${lane}`;
|
|
58
|
-
}
|
|
59
|
-
// perTenant jobs need a source of truth for "which tenants are active" —
|
|
60
|
-
// see createJobRunner's defaultGetActiveTenantIds below. The tenant feature
|
|
61
|
-
// (bundled-features package) registers a query under this exact name when
|
|
62
|
-
// mounted. framework must not depend on bundled-features (wrong direction —
|
|
63
|
-
// bundled-features already depends on framework), so this is a name-only
|
|
64
|
-
// coupling: createJobRunner looks the handler up in the registry by string
|
|
65
|
-
// instead of importing it.
|
|
66
|
-
const ACTIVE_TENANT_IDS_QUERY_NAME = "tenant:query:active-tenant-ids";
|
|
67
|
-
/**
|
|
68
|
-
* BullMQ job ids are `repeat:<schedulerId>:<millis>`. Colons inside the
|
|
69
|
-
* scheduler id push the segment count to ≥5, which BullMQ's legacy heuristic
|
|
70
|
-
* treated as old repeatables — spawning a new scheduler entry every tick and
|
|
71
|
-
* leaking permanent `repeat:*` hashes (taskforcesh/bullmq#3828, fw#1603 /
|
|
72
|
-
* publicstatus Redis OOM). Strip `.` and `:` from the job QN.
|
|
73
|
-
*/
|
|
74
|
-
export function schedulerIdForJobName(jobName) {
|
|
75
|
-
return `scheduler-${jobName.replace(/[.:]/g, "-")}`;
|
|
76
|
-
}
|
|
77
|
-
// Same colon-in-BullMQ-id hazard as schedulerIdForJobName (fw#1603/#1604) —
|
|
78
|
-
// a QN like "publicstatus:job:uptime-probe" must not leave ":" in the id.
|
|
79
|
-
export function bootJobIdForJobName(jobName) {
|
|
80
|
-
return `boot-${jobName.replace(/[.:]/g, "-")}`;
|
|
81
|
-
}
|
|
82
|
-
const SCHEDULER_PAGE_SIZE = 100;
|
|
83
|
-
// Rolling deploys run old and new pods against the same Redis for a while; an
|
|
84
|
-
// old runner restarting in that window must not delete the scheduler of a
|
|
85
|
-
// cron job the new version just added.
|
|
86
|
-
export const ORPHAN_SCHEDULER_GRACE_MS = 60 * 60 * 1000;
|
|
87
|
-
function schedulerSeenKey(queue) {
|
|
88
|
-
return queue.toKey("kumiko-scheduler-seen");
|
|
89
|
-
}
|
|
90
|
-
// "Seen" has to mean a live runner holds the scheduler, so runners re-stamp
|
|
91
|
-
// their own ids periodically; a single stamp at start() would let a runner
|
|
92
|
-
// restarting during a mixed-version window longer than the grace period delete
|
|
93
|
-
// a scheduler a healthy newer pod still owns.
|
|
94
|
-
async function markSchedulersSeen(queue, schedulerIds) {
|
|
95
|
-
// skip: no cron scheduler on this lane, nothing to stamp
|
|
96
|
-
if (schedulerIds.size === 0)
|
|
97
|
-
return;
|
|
98
|
-
const stamp = String(Date.now());
|
|
99
|
-
const entries = Object.fromEntries([...schedulerIds].map((id) => [id, stamp]));
|
|
100
|
-
await (await queue.client).hset(schedulerSeenKey(queue), entries);
|
|
101
|
-
}
|
|
102
|
-
// A cron job dropped from the app (or an older scheduler-id format) leaves its
|
|
103
|
-
// scheduler in Redis, where it keeps firing jobs the worker no longer knows
|
|
104
|
-
// ("Unknown job"). Best-effort: a Redis hiccup here must not stop the start.
|
|
105
|
-
async function removeOrphanJobSchedulers(queue, expectedSchedulerIds, lane, log) {
|
|
106
|
-
try {
|
|
107
|
-
// Collect first: removing while paging would shift the indices.
|
|
108
|
-
const existingKeys = [];
|
|
109
|
-
for (let start = 0;; start += SCHEDULER_PAGE_SIZE) {
|
|
110
|
-
const page = await queue.getJobSchedulers(start, start + SCHEDULER_PAGE_SIZE - 1);
|
|
111
|
-
for (const scheduler of page)
|
|
112
|
-
existingKeys.push(scheduler.key);
|
|
113
|
-
if (page.length < SCHEDULER_PAGE_SIZE)
|
|
114
|
-
break;
|
|
115
|
-
}
|
|
116
|
-
const client = await queue.client;
|
|
117
|
-
const seenAt = await client.hgetall(schedulerSeenKey(queue));
|
|
118
|
-
const now = Date.now();
|
|
119
|
-
for (const key of existingKeys) {
|
|
120
|
-
if (expectedSchedulerIds.has(key))
|
|
121
|
-
continue;
|
|
122
|
-
const lastSeen = Number(seenAt[key]);
|
|
123
|
-
if (key in seenAt && now - lastSeen < ORPHAN_SCHEDULER_GRACE_MS)
|
|
124
|
-
continue;
|
|
125
|
-
await queue.removeJobScheduler(key);
|
|
126
|
-
await client.hdel(schedulerSeenKey(queue), key);
|
|
127
|
-
log.info("removed orphan job scheduler", { schedulerId: key, lane });
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
catch (err) {
|
|
131
|
-
log.warn("orphan job scheduler cleanup failed", {
|
|
132
|
-
lane,
|
|
133
|
-
error: err instanceof Error ? err.message : String(err),
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
// Deterministic per-tenant child job id, derived from the perTenant
|
|
138
|
-
// wrapper's own BullMQ job id. A cron tick's wrapper id is
|
|
139
|
-
// `repeat:<schedulerId>:<millis>` (stable for that tick, including across
|
|
140
|
-
// BullMQ's own retry/stalled-job redelivery of the *same* job); a manual
|
|
141
|
-
// dispatch's wrapper id is BullMQ's auto-generated id, equally stable
|
|
142
|
-
// across retries of that job. Reusing it means a wrapper that runs twice
|
|
143
|
-
// for the same trigger (retry after failure, a Redis drop, an instance
|
|
144
|
-
// restarting mid-run) re-derives the *same* child ids, and BullMQ's
|
|
145
|
-
// existing-jobId add() no-ops the second batch instead of creating
|
|
146
|
-
// duplicates — safe only as long as the child job hash outlives the
|
|
147
|
-
// wrapper's retry window, enforced at construction against
|
|
148
|
-
// COMPLETED_JOB_RETENTION_AGE_SEC (see the invariant check in
|
|
149
|
-
// createJobRunner). Same colon-in-BullMQ-id hazard as schedulerIdForJobName
|
|
150
|
-
// (fw#1603/#1604) — the wrapper id already contains ":", so strip
|
|
151
|
-
// separators from both halves before joining instead of interpolating raw.
|
|
152
|
-
function perTenantChildJobId(wrapperJobId, tenantId) {
|
|
153
|
-
const sanitize = (value) => value.replace(/[.:]/g, "-");
|
|
154
|
-
return `child-${sanitize(wrapperJobId)}-${sanitize(tenantId)}`;
|
|
155
|
-
}
|
|
156
|
-
function createJobLogger(logs, baseContext = {}) {
|
|
157
|
-
function push(level, msg, data) {
|
|
158
|
-
const merged = { ...baseContext, ...data };
|
|
159
|
-
const message = Object.keys(merged).length > 0 ? `${msg} ${JSON.stringify(merged)}` : msg;
|
|
160
|
-
logs.push({ level, message, timestamp: Temporal.Now.instant() });
|
|
161
|
-
}
|
|
162
|
-
const logger = {
|
|
163
|
-
info(msg, data) {
|
|
164
|
-
push("info", msg, data);
|
|
165
|
-
},
|
|
166
|
-
warn(msg, data) {
|
|
167
|
-
push("warn", msg, data);
|
|
168
|
-
},
|
|
169
|
-
error(msg, data) {
|
|
170
|
-
push("error", msg, data);
|
|
171
|
-
},
|
|
172
|
-
// No-op by design, not missing coverage: JobLogEntry.level (and the
|
|
173
|
-
// persisted run-completed/-failed event schema in bundled-features/
|
|
174
|
-
// jobs/events.ts) only has info|warn|error — a job's debug() calls have
|
|
175
|
-
// nowhere durable to land.
|
|
176
|
-
debug() { },
|
|
177
|
-
child(context) {
|
|
178
|
-
return createJobLogger(logs, { ...baseContext, ...context });
|
|
179
|
-
},
|
|
180
|
-
};
|
|
181
|
-
return logger;
|
|
182
|
-
}
|
|
183
|
-
// Single source of the stored subject string: the writer (jobSubjectKey) and
|
|
184
|
-
// the tenant failures query both go through it. Matching is exact — field names
|
|
185
|
-
// and JSON types must equal what the writer stored (`"1"` is not `1`).
|
|
186
|
-
export function serializeJobSubject(subject) {
|
|
187
|
-
return JSON.stringify(Object.keys(subject)
|
|
188
|
-
.sort()
|
|
189
|
-
.map((field) => [field, subject[field] ?? null]));
|
|
190
|
-
}
|
|
191
|
-
// Stable identity for the declared payload fields: sorted keys, so two runs
|
|
192
|
-
// with the same subject values produce the same string and the later one
|
|
193
|
-
// replaces the earlier record. Non-primitives would serialize into something
|
|
194
|
-
// no caller can reconstruct for a lookup, so they fail the run loudly.
|
|
195
|
-
function jobSubjectKey(jobName, payload, fields, declaration) {
|
|
196
|
-
if (fields === undefined || fields.length === 0)
|
|
197
|
-
return null;
|
|
198
|
-
const subject = {};
|
|
199
|
-
for (const field of fields) {
|
|
200
|
-
const value = payload[field] ?? null;
|
|
201
|
-
if (value !== null && typeof value === "object") {
|
|
202
|
-
throw new Error(`Job "${jobName}": ${declaration}.subjectFields["${field}"] must be a primitive, got ${typeof value}`);
|
|
203
|
-
}
|
|
204
|
-
subject[field] = value;
|
|
205
|
-
}
|
|
206
|
-
return serializeJobSubject(subject);
|
|
207
|
-
}
|
|
208
|
-
async function resolveJobTz(config, context) {
|
|
209
|
-
const tenantTz = config ? await config(TENANT_TIMEZONE_CONFIG_KEY) : undefined;
|
|
210
|
-
return createTzContext({
|
|
211
|
-
...(context.geoTzProvider !== undefined && { geoTz: context.geoTzProvider }),
|
|
212
|
-
...(context.clock !== undefined && { clock: context.clock }),
|
|
213
|
-
tenant: typeof tenantTz === "string" && isValidIanaTimeZone(tenantTz) ? tenantTz : "UTC",
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
// Jobs that never touch ctx.db run fine on a runner built without one; a job
|
|
217
|
-
// that does gets a named error instead of a bare TypeError on undefined.
|
|
218
|
-
function missingDbProxy(jobName) {
|
|
219
|
-
// @cast-boundary db-operator — every access throws, so no TenantDb member is ever returned
|
|
220
|
-
return new Proxy({}, {
|
|
221
|
-
get() {
|
|
222
|
-
throw new InternalError({
|
|
223
|
-
message: `[jobs] job runner constructed without a db connection — ctx.db is unavailable for "${jobName}"`,
|
|
224
|
-
});
|
|
225
|
-
},
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
function createCrossTenantReads(systemModeDb, reason, report) {
|
|
229
|
-
return {
|
|
230
|
-
selectMany(table, where, options) {
|
|
231
|
-
report("cross-tenant-read", reason);
|
|
232
|
-
return systemModeDb.selectMany(table, where, options);
|
|
233
|
-
},
|
|
234
|
-
fetchOne(table, where) {
|
|
235
|
-
report("cross-tenant-read", reason);
|
|
236
|
-
return systemModeDb.fetchOne(table, where);
|
|
237
|
-
},
|
|
238
|
-
count(table, where) {
|
|
239
|
-
report("cross-tenant-read", reason);
|
|
240
|
-
return systemModeDb.count(table, where);
|
|
241
|
-
},
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
|
-
function priorAttemptsOf(data) {
|
|
245
|
-
const value = data["_priorAttempts"];
|
|
246
|
-
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : 0;
|
|
247
|
-
}
|
|
248
|
-
// Only a translation key ever travels to the tenant: the thrown error's own
|
|
249
|
-
// i18nKey when it carries one, otherwise the key declared at the job. The
|
|
250
|
-
// error message itself can echo provider or payload content and stays on the
|
|
251
|
-
// run row and in the run log.
|
|
252
|
-
function tenantFailureMessageKey(err, declaration) {
|
|
253
|
-
// skip: job did not opt in — nothing tenant-visible to record
|
|
254
|
-
if (!declaration)
|
|
255
|
-
return null;
|
|
256
|
-
if (isKumikoError(err) && err.i18nKey.length > 0)
|
|
257
|
-
return err.i18nKey;
|
|
258
|
-
return declaration.messageKey;
|
|
259
|
-
}
|
|
260
|
-
// Serialized trace context lives under this key in the BullMQ job data.
|
|
261
|
-
// Leading underscore matches the existing internal-meta convention
|
|
262
|
-
// (_triggeredById, _tenantId, _payload).
|
|
263
|
-
const TRACE_CONTEXT_KEY = "_traceContext";
|
|
264
|
-
const DEFER_COUNT_KEY = "_deferCount";
|
|
265
|
-
// Separate from the maxPerTenant counter so tenant-limit deferrals do not eat into maxDefers.
|
|
266
|
-
const HANDLER_DEFER_COUNT_KEY = "_handlerDeferCount";
|
|
267
|
-
const DEFAULT_MAX_DEFERS = 100;
|
|
268
|
-
const DEFER_DELAY_CAP_MS = 30_000;
|
|
269
|
-
const DEFER_BACKOFF_MAX_DOUBLINGS = 5;
|
|
270
|
-
function readTraceContext(data) {
|
|
271
|
-
const raw = data[TRACE_CONTEXT_KEY];
|
|
272
|
-
if (!raw || typeof raw !== "object")
|
|
273
|
-
return undefined;
|
|
274
|
-
const ctx = raw; // @cast-boundary engine-payload
|
|
275
|
-
if (!ctx.traceId || !ctx.spanId)
|
|
276
|
-
return undefined;
|
|
277
|
-
return { traceId: ctx.traceId, spanId: ctx.spanId };
|
|
278
|
-
}
|
|
279
|
-
function captureTraceContext(tracer) {
|
|
280
|
-
const span = tracer.getActiveSpan();
|
|
281
|
-
if (!span?.traceId)
|
|
282
|
-
return undefined;
|
|
283
|
-
return { traceId: span.traceId, spanId: span.spanId };
|
|
284
|
-
}
|
|
285
|
-
const QUEUE_DEPTH_POLL_INTERVAL_MS = 15_000;
|
|
286
|
-
const QUEUE_DEPTH_WARN_INTERVAL_MS = 5 * 60_000;
|
|
287
|
-
// kumiko_job_queue_depth: only the lane's own consumer polls — the count is
|
|
288
|
-
// a global Redis-backed value (BullMQ, not per-process), so one reporter per
|
|
289
|
-
// lane is enough. Fires once immediately (metrics exist before the first
|
|
290
|
-
// poll tick) then on an interval; the caller stores + clears the handle.
|
|
291
|
-
async function startQueueDepthPolling(queue, lane, meter) {
|
|
292
|
-
let lastWarnAt;
|
|
293
|
-
const pollQueueDepth = async () => {
|
|
294
|
-
try {
|
|
295
|
-
const counts = await queue.getJobCounts("waiting", "active", "delayed", "failed", "paused");
|
|
296
|
-
emitJobQueueDepth(meter, lane, counts);
|
|
297
|
-
}
|
|
298
|
-
catch (err) {
|
|
299
|
-
// Next poll retries; the warn is throttled so a Redis outage logs once per interval, not once per poll.
|
|
300
|
-
const nowMs = Date.now();
|
|
301
|
-
if (lastWarnAt === undefined || nowMs - lastWarnAt >= QUEUE_DEPTH_WARN_INTERVAL_MS) {
|
|
302
|
-
lastWarnAt = nowMs;
|
|
303
|
-
createFallbackLogger("job-runner").warn("queue depth poll failed", {
|
|
304
|
-
lane,
|
|
305
|
-
error: scrubLogText(err instanceof Error ? err.message : String(err)),
|
|
306
|
-
});
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
};
|
|
310
|
-
await pollQueueDepth();
|
|
311
|
-
return setInterval(() => void pollQueueDepth(), QUEUE_DEPTH_POLL_INTERVAL_MS);
|
|
312
|
-
}
|
|
313
|
-
function parseRedisOpts(url) {
|
|
314
|
-
const parsed = new URL(url);
|
|
315
|
-
const result = {
|
|
316
|
-
host: parsed.hostname,
|
|
317
|
-
port: Number(parsed.port) || 6379,
|
|
318
|
-
};
|
|
319
|
-
if (parsed.pathname.length > 1) {
|
|
320
|
-
result.db = Number(parsed.pathname.slice(1));
|
|
321
|
-
}
|
|
322
|
-
return result;
|
|
323
|
-
}
|
|
324
|
-
// redisOpts carries no connectTimeout/retry cap, so an unreachable Redis
|
|
325
|
-
// would otherwise hang start() forever with no health endpoint to notice.
|
|
326
|
-
const BOOT_REDIS_TIMEOUT_MS = 10_000;
|
|
327
|
-
// Shutdown must fit a k8s grace period even when Redis is down, so the
|
|
328
|
-
// readiness wait in stop() is capped well below the boot timeout.
|
|
329
|
-
const STOP_QUEUE_READY_TIMEOUT_MS = 1_000;
|
|
330
|
-
// A gate without an explicit `timeout` may legitimately run long (migration-style
|
|
331
|
-
// checks), so it is never rejected; a hang just must not stay silent.
|
|
332
|
-
const BOOT_GATE_SLOW_WARN_MS = 60_000;
|
|
333
|
-
function timeoutReject(ms, message) {
|
|
334
|
-
let timer;
|
|
335
|
-
const promise = new Promise((_, reject) => {
|
|
336
|
-
timer = setTimeout(() => reject(new Error(message)), ms);
|
|
337
|
-
});
|
|
338
|
-
return {
|
|
339
|
-
promise,
|
|
340
|
-
cancel: () => {
|
|
341
|
-
if (timer !== undefined)
|
|
342
|
-
clearTimeout(timer);
|
|
343
|
-
},
|
|
344
|
-
};
|
|
345
|
-
}
|
|
346
|
-
// Default base delay when a job opts into backoff without an explicit
|
|
347
|
-
// delayMs. Without a `delay`, BullMQ's fixed/exponential strategies compute
|
|
348
|
-
// NaN/undefined, which is falsy — the job retries immediately instead of
|
|
349
|
-
// waiting.
|
|
350
|
-
const DEFAULT_JOB_BACKOFF_DELAY_MS = 1_000;
|
|
351
|
-
// Shared by every enqueue path (dispatch(), handleEvent(), cron, runOnBoot,
|
|
352
|
-
// perTenant wrapper/children, sequential re-enqueue) — a job with `retries`
|
|
353
|
-
// set must retry the same way regardless of how it got enqueued, or it
|
|
354
|
-
// fails for good on the very first error on whichever path skips this.
|
|
355
|
-
function buildRetryBullOpts(jobDef, priorAttempts = 0) {
|
|
356
|
-
const opts = {};
|
|
357
|
-
if (jobDef.retries !== undefined)
|
|
358
|
-
opts.attempts = Math.max(1, jobDef.retries + 1 - priorAttempts);
|
|
359
|
-
if (jobDef.backoff) {
|
|
360
|
-
opts.backoff =
|
|
361
|
-
typeof jobDef.backoff === "string"
|
|
362
|
-
? { type: jobDef.backoff, delay: DEFAULT_JOB_BACKOFF_DELAY_MS }
|
|
363
|
-
: {
|
|
364
|
-
type: jobDef.backoff.type,
|
|
365
|
-
delay: jobDef.backoff.delayMs ?? DEFAULT_JOB_BACKOFF_DELAY_MS,
|
|
366
|
-
};
|
|
367
|
-
}
|
|
368
|
-
return opts;
|
|
369
|
-
}
|
|
370
|
-
// Equality filter on top-level payload fields for a job's trigger.where —
|
|
371
|
-
// lets N jobs share one broad event QN, partitioned by a payload
|
|
372
|
-
// discriminant instead of needing N distinct event types.
|
|
373
|
-
function payloadMatchesTriggerFilter(where, payload) {
|
|
374
|
-
if (!where)
|
|
375
|
-
return true;
|
|
376
|
-
return Object.entries(where).every(([key, value]) => payload[key] === value);
|
|
377
|
-
}
|
|
378
|
-
// BullMQ sweeps queue-wide: any job finishing with keepJobs set can evict
|
|
379
|
-
// OTHER jobs in the same completed/failed zset (moveToFinished lua,
|
|
380
|
-
// removeJobsByMaxAge/removeJobsByMaxCount), not just the finishing job, and
|
|
381
|
-
// the sweep only runs lazily on a later finish into that set. Per-job
|
|
382
|
-
// retention is therefore meaningless on a shared queue — only queue-wide,
|
|
383
|
-
// age-only retention (no count) is safe: a count would evict perTenant
|
|
384
|
-
// children still inside their wrapper's retry window, and boot jobs. The
|
|
385
|
-
// completed age must stay above every perTenant wrapper's own worst-case
|
|
386
|
-
// retry window (see maxRetryWindowMs and the invariant check in
|
|
387
|
-
// createJobRunner below), because child dedup (perTenantChildJobId) relies
|
|
388
|
-
// on the children still existing in Redis when a wrapper retry re-derives
|
|
389
|
-
// their ids. The margin here is generous — queue wait time isn't bounded by
|
|
390
|
-
// backoff — to cover realistic windows. Audit trail lives in read_job_runs,
|
|
391
|
-
// not BullMQ (fw#3199).
|
|
392
|
-
const COMPLETED_JOB_RETENTION_AGE_SEC = 86_400; // 24h
|
|
393
|
-
const FAILED_JOB_RETENTION_AGE_SEC = 604_800; // 7d
|
|
394
|
-
// Total wall-clock time BullMQ can hold a perTenant wrapper across all its
|
|
395
|
-
// retries, consistent with buildRetryBullOpts's own backoff computation.
|
|
396
|
-
function maxRetryWindowMs(jobDef) {
|
|
397
|
-
if (!jobDef.backoff || jobDef.retries === undefined)
|
|
398
|
-
return 0;
|
|
399
|
-
const delayMs = (typeof jobDef.backoff === "string" ? undefined : jobDef.backoff.delayMs) ??
|
|
400
|
-
DEFAULT_JOB_BACKOFF_DELAY_MS;
|
|
401
|
-
const type = typeof jobDef.backoff === "string" ? jobDef.backoff : jobDef.backoff.type;
|
|
402
|
-
return type === "exponential" ? delayMs * (2 ** jobDef.retries - 1) : jobDef.retries * delayMs;
|
|
403
|
-
}
|
|
25
|
+
export { serializeJobSubject } from "./job-runner-helpers.js";
|
|
26
|
+
export { bootJobIdForJobName, ORPHAN_SCHEDULER_GRACE_MS, schedulerIdForJobName, } from "./job-runner-schedulers.js";
|
|
404
27
|
export function createJobRunner(options) {
|
|
405
28
|
const { registry, context, redisUrl, consumerLane } = options;
|
|
406
29
|
const queueNamePrefix = options.queueNamePrefix ?? DEFAULT_QUEUE_NAME_PREFIX;
|
|
@@ -576,8 +576,8 @@ readOnlyForTenant = false) {
|
|
|
576
576
|
await appendDomainEvent(ctx, args, user, tx, registry.getHandlerFeature(type));
|
|
577
577
|
},
|
|
578
578
|
// Savepoint-scoped append: catches a losing writer's VersionConflictError
|
|
579
|
-
// without poisoning the rest of the handler's transaction.
|
|
580
|
-
//
|
|
579
|
+
// without poisoning the rest of the handler's transaction. postgres.js
|
|
580
|
+
// aborts the WHOLE begin() on an uncaught statement error
|
|
581
581
|
// (SQLSTATE 25P02) even if the JS error is caught — runInSavepoint
|
|
582
582
|
// wraps the append in a real SAVEPOINT so only that nested scope rolls
|
|
583
583
|
// back on conflict, and subsequent statements in the outer tx (e.g. a
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
/// <reference types="temporal-polyfill/global" preserve="true" />
|
|
2
|
-
import type {
|
|
2
|
+
import type { DbRunner } from "../db/connection.ts";
|
|
3
|
+
export type ConsumerTransitionRejection = "unknown-consumer" | "wrong-status";
|
|
4
|
+
export declare class ConsumerTransitionError extends Error {
|
|
5
|
+
readonly rejection: ConsumerTransitionRejection;
|
|
6
|
+
constructor(rejection: ConsumerTransitionRejection, message: string);
|
|
7
|
+
}
|
|
3
8
|
export type ConsumerRecoveryState = {
|
|
4
9
|
readonly name: string;
|
|
5
10
|
readonly instanceId: string;
|
|
@@ -10,13 +15,15 @@ export type ConsumerRecoveryState = {
|
|
|
10
15
|
readonly lastError: string | null;
|
|
11
16
|
readonly updatedAt: Temporal.Instant;
|
|
12
17
|
};
|
|
13
|
-
export declare function restartConsumer(db:
|
|
14
|
-
export declare function disableConsumer(db:
|
|
15
|
-
export declare function enableConsumer(db:
|
|
16
|
-
export declare function skipPoisonEvent(db:
|
|
18
|
+
export declare function restartConsumer(db: DbRunner, name: string, instanceId?: string): Promise<ConsumerRecoveryState>;
|
|
19
|
+
export declare function disableConsumer(db: DbRunner, name: string, instanceId?: string): Promise<ConsumerRecoveryState>;
|
|
20
|
+
export declare function enableConsumer(db: DbRunner, name: string, instanceId?: string): Promise<ConsumerRecoveryState>;
|
|
21
|
+
export declare function skipPoisonEvent(db: DbRunner, name: string, instanceId?: string, options?: {
|
|
22
|
+
readonly requireStatus?: string;
|
|
23
|
+
}): Promise<ConsumerRecoveryState & {
|
|
17
24
|
readonly skippedEventId: bigint | null;
|
|
18
25
|
}>;
|
|
19
|
-
export declare function getConsumerState(db:
|
|
26
|
+
export declare function getConsumerState(db: DbRunner, name: string, instanceId?: string): Promise<{
|
|
20
27
|
readonly name: string;
|
|
21
28
|
readonly instanceId: string;
|
|
22
29
|
readonly status: string;
|
|
@@ -26,7 +33,7 @@ export declare function getConsumerState(db: DbConnection, name: string, instanc
|
|
|
26
33
|
readonly lastError: string | null;
|
|
27
34
|
readonly updatedAt: Temporal.Instant;
|
|
28
35
|
} | null>;
|
|
29
|
-
export declare function listConsumersWithState(db:
|
|
36
|
+
export declare function listConsumersWithState(db: DbRunner, registeredNames: readonly string[]): Promise<ReadonlyArray<{
|
|
30
37
|
readonly name: string;
|
|
31
38
|
readonly instanceId: string;
|
|
32
39
|
readonly status: string;
|
|
@@ -44,4 +51,4 @@ export type ConsumerProgress = {
|
|
|
44
51
|
readonly highWaterMark: bigint;
|
|
45
52
|
readonly lag: bigint;
|
|
46
53
|
};
|
|
47
|
-
export declare function getAllConsumerProgress(db:
|
|
54
|
+
export declare function getAllConsumerProgress(db: DbRunner, registeredNames: readonly string[]): Promise<readonly ConsumerProgress[]>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { advanceConsumerPastEventReturning, removePendingGapReturning, selectConsumerForUpdate, selectSnapshotXmax, updateConsumerStatusReturning, } from "../db/queries/event-consumer.js";
|
|
2
2
|
import { selectNextEventIdAfter, selectSmallestVisibleIdInRanges, } from "../db/queries/event-store.js";
|
|
3
|
-
import { coerceRow, extractTableInfo, selectMany } from "../db/query.js";
|
|
3
|
+
import { coerceRow, extractTableInfo, runInNewTransaction, runInSavepointIfSupported, runnerCanBeginTransaction, selectMany, } from "../db/query.js";
|
|
4
4
|
import { getEventsHighWaterMark } from "../event-store/index.js";
|
|
5
5
|
import { eventConsumerStateTable, SHARED_INSTANCE_SENTINEL, } from "./event-consumer-state.js";
|
|
6
6
|
import { mergeContiguousGapRanges, rangeContainsId, splitRangeExcludingIds, toIdRanges, } from "./pending-gap-ranges.js";
|
|
@@ -33,6 +33,14 @@ function normalizeConsumerState(row) {
|
|
|
33
33
|
updatedAt: row.updatedAt,
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
|
+
export class ConsumerTransitionError extends Error {
|
|
37
|
+
rejection;
|
|
38
|
+
constructor(rejection, message) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.rejection = rejection;
|
|
41
|
+
this.name = "ConsumerTransitionError";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
36
44
|
// Ops calls default to the SHARED_INSTANCE_SENTINEL row — that's the only
|
|
37
45
|
// row shared-delivery consumers have, so legacy CLI invocations without
|
|
38
46
|
// --instance-id keep working. Per-instance consumers require an explicit
|
|
@@ -45,7 +53,7 @@ async function requireConsumerRow(db, name, instanceId) {
|
|
|
45
53
|
instanceId,
|
|
46
54
|
});
|
|
47
55
|
if (!row) {
|
|
48
|
-
throw new
|
|
56
|
+
throw new ConsumerTransitionError("unknown-consumer", `Consumer "${name}" (instance_id="${instanceId}") has no state row — it hasn't run yet, the name is misspelled, or the instance is misspelled. ` +
|
|
49
57
|
`For per-instance consumers pass the instance_id explicitly; shared consumers use the default.`);
|
|
50
58
|
}
|
|
51
59
|
return row;
|
|
@@ -53,7 +61,7 @@ async function requireConsumerRow(db, name, instanceId) {
|
|
|
53
61
|
async function requireConsumerRowForUpdate(tx, name, instanceId) {
|
|
54
62
|
const raw = await selectConsumerForUpdate(tx, name, instanceId);
|
|
55
63
|
if (!raw) {
|
|
56
|
-
throw new
|
|
64
|
+
throw new ConsumerTransitionError("unknown-consumer", `Consumer "${name}" (instance_id="${instanceId}") has no state row — it hasn't run yet, the name is misspelled, or the instance is misspelled. ` +
|
|
57
65
|
`For per-instance consumers pass the instance_id explicitly; shared consumers use the default.`);
|
|
58
66
|
}
|
|
59
67
|
return coerceRow(raw, extractTableInfo(eventConsumerStateTable));
|
|
@@ -69,7 +77,7 @@ async function applyConsumerStatusTransition(db, name, instanceId, targetStatus)
|
|
|
69
77
|
export async function restartConsumer(db, name, instanceId = SHARED_INSTANCE_SENTINEL) {
|
|
70
78
|
const before = await requireConsumerRow(db, name, instanceId);
|
|
71
79
|
if (before.status !== "dead") {
|
|
72
|
-
throw new
|
|
80
|
+
throw new ConsumerTransitionError("wrong-status", `Consumer "${name}" (instance_id="${instanceId}") is not dead (status="${before.status}"). Restart only applies to dead consumers; use "enable" for a disabled one.`);
|
|
73
81
|
}
|
|
74
82
|
return applyConsumerStatusTransition(db, name, instanceId, "idle");
|
|
75
83
|
}
|
|
@@ -80,10 +88,17 @@ export async function disableConsumer(db, name, instanceId = SHARED_INSTANCE_SEN
|
|
|
80
88
|
export async function enableConsumer(db, name, instanceId = SHARED_INSTANCE_SENTINEL) {
|
|
81
89
|
const before = await requireConsumerRow(db, name, instanceId);
|
|
82
90
|
if (before.status !== "disabled") {
|
|
83
|
-
throw new
|
|
91
|
+
throw new ConsumerTransitionError("wrong-status", `Consumer "${name}" (instance_id="${instanceId}") is not disabled (status="${before.status}"). Enable only flips disabled → idle; use "restart" for a dead consumer.`);
|
|
84
92
|
}
|
|
85
93
|
return applyConsumerStatusTransition(db, name, instanceId, "idle");
|
|
86
94
|
}
|
|
95
|
+
// A pool connection opens its own transaction; inside a handler transaction the
|
|
96
|
+
// locked read and writes join it, confined to a savepoint.
|
|
97
|
+
function runInOwnTransactionOrSavepoint(db, fn) {
|
|
98
|
+
return runnerCanBeginTransaction(db)
|
|
99
|
+
? runInNewTransaction(db, fn)
|
|
100
|
+
: runInSavepointIfSupported(db, fn);
|
|
101
|
+
}
|
|
87
102
|
// Delivery stops at the poison event, so ids between the cursor and it were
|
|
88
103
|
// invisible during that pass (in-flight commits); keeping them as a pending gap
|
|
89
104
|
// avoids losing them when the cursor jumps over.
|
|
@@ -97,6 +112,11 @@ async function invisibleWindowGap(tx, cursor, poisonId) {
|
|
|
97
112
|
xmax: await selectSnapshotXmax(tx),
|
|
98
113
|
};
|
|
99
114
|
}
|
|
115
|
+
function assertRequiredStatus(row, name, instanceId, requiredStatus) {
|
|
116
|
+
if (requiredStatus !== undefined && row.status !== requiredStatus) {
|
|
117
|
+
throw new ConsumerTransitionError("wrong-status", `Consumer "${name}" (instance_id="${instanceId}") is "${row.status}", not "${requiredStatus}"; nothing skipped.`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
100
120
|
// skipPoisonEvent skips the event whose handler failed last
|
|
101
121
|
// (last_failed_event_id, set by the delivery's halt-on-poison branch): inside
|
|
102
122
|
// pending_gaps it is split out of its range, otherwise the cursor moves onto
|
|
@@ -104,11 +124,12 @@ async function invisibleWindowGap(tx, cursor, poisonId) {
|
|
|
104
124
|
// falls back to a guess: the smallest visible pending-gap id, else the next
|
|
105
125
|
// event after the cursor. Single TX so concurrent dispatcher passes can't
|
|
106
126
|
// double-advance. Nothing to skip → idempotent no-op.
|
|
107
|
-
export async function skipPoisonEvent(db, name, instanceId = SHARED_INSTANCE_SENTINEL) {
|
|
108
|
-
return db
|
|
127
|
+
export async function skipPoisonEvent(db, name, instanceId = SHARED_INSTANCE_SENTINEL, options = {}) {
|
|
128
|
+
return runInOwnTransactionOrSavepoint(db, async (tx) => {
|
|
109
129
|
// Locked read: a dispatcher turn committing new gaps between an unlocked
|
|
110
130
|
// read and the pending_gaps overwrite below would have them dropped.
|
|
111
131
|
const before = await requireConsumerRowForUpdate(tx, name, instanceId);
|
|
132
|
+
assertRequiredStatus(before, name, instanceId, options.requireStatus);
|
|
112
133
|
const pendingGaps = before.pendingGaps;
|
|
113
134
|
const failedEventId = before.lastFailedEventId;
|
|
114
135
|
const failedIsPending = failedEventId !== null && pendingGaps.some((gap) => rangeContainsId(gap, failedEventId));
|
|
@@ -49,5 +49,5 @@ export type EventDispatcherOptions = {
|
|
|
49
49
|
readonly pgClient?: PgListenClient;
|
|
50
50
|
};
|
|
51
51
|
export declare function createEventDispatcher(options: EventDispatcherOptions): EventDispatcher;
|
|
52
|
-
export type { ConsumerProgress, ConsumerRecoveryState } from "./event-dispatcher-admin.ts";
|
|
53
|
-
export { disableConsumer, enableConsumer, getAllConsumerProgress, getConsumerState, listConsumersWithState, restartConsumer, skipPoisonEvent, } from "./event-dispatcher-admin.ts";
|
|
52
|
+
export type { ConsumerProgress, ConsumerRecoveryState, ConsumerTransitionRejection, } from "./event-dispatcher-admin.ts";
|
|
53
|
+
export { ConsumerTransitionError, disableConsumer, enableConsumer, getAllConsumerProgress, getConsumerState, listConsumersWithState, restartConsumer, skipPoisonEvent, } from "./event-dispatcher-admin.ts";
|
|
@@ -21,7 +21,6 @@ const CONNECTION_LOSS_CODES = new Set([
|
|
|
21
21
|
"CONNECT_TIMEOUT",
|
|
22
22
|
"ECONNREFUSED",
|
|
23
23
|
"ECONNRESET",
|
|
24
|
-
"ERR_POSTGRES_CONNECTION_CLOSED",
|
|
25
24
|
"57P01",
|
|
26
25
|
]);
|
|
27
26
|
function isConnectionLossError(e) {
|
|
@@ -506,7 +505,7 @@ export function createEventDispatcher(options) {
|
|
|
506
505
|
// Fires on initial connect and again when the driver's own
|
|
507
506
|
// re-LISTEN lands after a drop; during an outage that re-LISTEN
|
|
508
507
|
// waits in its connection backoff (postgres-js: measured 10-30s
|
|
509
|
-
// after the DB returns
|
|
508
|
+
// after the DB returns), polling covers
|
|
510
509
|
// delivery meanwhile. Never unlisten()
|
|
511
510
|
// + listen() by hand: that leaves duplicate listeners.
|
|
512
511
|
// ponytail: no own LISTEN reconnect, the backoff gap is polled;
|
|
@@ -534,7 +533,7 @@ export function createEventDispatcher(options) {
|
|
|
534
533
|
timer = null;
|
|
535
534
|
}
|
|
536
535
|
if (pgSubscription) {
|
|
537
|
-
// Called on the subscription:
|
|
536
|
+
// Called on the subscription, not detached: unlisten may rely on `this`.
|
|
538
537
|
await pgSubscription.unlisten().catch(() => {
|
|
539
538
|
// skip: unlisten failure only matters during shutdown — the
|
|
540
539
|
// subscription is being torn down anyway.
|
|
@@ -573,4 +572,4 @@ export function createEventDispatcher(options) {
|
|
|
573
572
|
runOnce,
|
|
574
573
|
};
|
|
575
574
|
}
|
|
576
|
-
export { disableConsumer, enableConsumer, getAllConsumerProgress, getConsumerState, listConsumersWithState, restartConsumer, skipPoisonEvent, } from "./event-dispatcher-admin.js";
|
|
575
|
+
export { ConsumerTransitionError, disableConsumer, enableConsumer, getAllConsumerProgress, getConsumerState, listConsumersWithState, restartConsumer, skipPoisonEvent, } from "./event-dispatcher-admin.js";
|
package/dist/pipeline/index.d.ts
CHANGED
|
@@ -19,8 +19,8 @@ export type { ConsumerStatus, PendingGapEntry } from "./event-consumer-state.ts"
|
|
|
19
19
|
export { ConsumerStatuses, createEventConsumerStateTable, eventConsumerStateTable, SHARED_INSTANCE_SENTINEL, } from "./event-consumer-state.ts";
|
|
20
20
|
export type { EventDedup } from "./event-dedup.ts";
|
|
21
21
|
export { createEventDedup } from "./event-dedup.ts";
|
|
22
|
-
export type { ConsumerProgress, ConsumerRecoveryState, DispatcherPassResult, EventConsumer, EventConsumerBatchHandler, EventConsumerHandler, EventDispatcher, EventDispatcherOptions, } from "./event-dispatcher.ts";
|
|
23
|
-
export { createEventDispatcher, disableConsumer, enableConsumer, getAllConsumerProgress, getConsumerState, listConsumersWithState, restartConsumer, skipPoisonEvent, } from "./event-dispatcher.ts";
|
|
22
|
+
export type { ConsumerProgress, ConsumerRecoveryState, ConsumerTransitionRejection, DispatcherPassResult, EventConsumer, EventConsumerBatchHandler, EventConsumerHandler, EventDispatcher, EventDispatcherOptions, } from "./event-dispatcher.ts";
|
|
23
|
+
export { ConsumerTransitionError, createEventDispatcher, disableConsumer, enableConsumer, getAllConsumerProgress, getConsumerState, listConsumersWithState, restartConsumer, skipPoisonEvent, } from "./event-dispatcher.ts";
|
|
24
24
|
export type { ConsumerCursor } from "./event-dispatcher-delivery.ts";
|
|
25
25
|
export { selectConsumerCursorForUpdate } from "./event-dispatcher-delivery.ts";
|
|
26
26
|
export type { PruneEventsOptions, PruneEventsResult } from "./event-retention.ts";
|
package/dist/pipeline/index.js
CHANGED
|
@@ -10,7 +10,7 @@ export { createDistributedLock } from "./distributed-lock.js";
|
|
|
10
10
|
export { createEntityCache } from "./entity-cache.js";
|
|
11
11
|
export { ConsumerStatuses, createEventConsumerStateTable, eventConsumerStateTable, SHARED_INSTANCE_SENTINEL, } from "./event-consumer-state.js";
|
|
12
12
|
export { createEventDedup } from "./event-dedup.js";
|
|
13
|
-
export { createEventDispatcher, disableConsumer, enableConsumer, getAllConsumerProgress, getConsumerState, listConsumersWithState, restartConsumer, skipPoisonEvent, } from "./event-dispatcher.js";
|
|
13
|
+
export { ConsumerTransitionError, createEventDispatcher, disableConsumer, enableConsumer, getAllConsumerProgress, getConsumerState, listConsumersWithState, restartConsumer, skipPoisonEvent, } from "./event-dispatcher.js";
|
|
14
14
|
export { selectConsumerCursorForUpdate } from "./event-dispatcher-delivery.js";
|
|
15
15
|
export { ConsumerLagError, pruneEvents } from "./event-retention.js";
|
|
16
16
|
export { createIdempotencyGuard } from "./idempotency.js";
|