@cosmicdrift/kumiko-framework 0.363.0 → 0.365.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.md +338 -0
- package/agents/examples/access-control.md +355 -0
- package/agents/examples/anonymous-access-multitenant.md +446 -0
- package/agents/examples/anonymous-access.md +288 -0
- package/agents/examples/apex-landing.md +528 -0
- package/agents/examples/apex-surface-auth.md +355 -0
- package/agents/examples/api-worker-split.md +434 -0
- package/agents/examples/auth-claims.md +351 -0
- package/agents/examples/auth-foundation-providers.md +155 -0
- package/agents/examples/auth-mfa.md +319 -0
- package/agents/examples/basic-entity.md +420 -0
- package/agents/examples/boot-check.md +122 -0
- package/agents/examples/chat-channels.md +254 -0
- package/agents/examples/compliance-profiles-demo.md +307 -0
- package/agents/examples/cross-feature-events.md +317 -0
- package/agents/examples/crypto-shredding-hr.md +314 -0
- package/agents/examples/currencies-global.md +609 -0
- package/agents/examples/currencies-per-tenant.md +453 -0
- package/agents/examples/custom-fields-basic.md +315 -0
- package/agents/examples/custom-handlers.md +444 -0
- package/agents/examples/dashboard-stats.md +229 -0
- package/agents/examples/delivery-notifications.md +432 -0
- package/agents/examples/designer-demo.md +86 -0
- package/agents/examples/e2e-generator.md +211 -0
- package/agents/examples/embedded-entity-form.md +598 -0
- package/agents/examples/embedded.md +649 -0
- package/agents/examples/encrypted-tenant-config.md +414 -0
- package/agents/examples/error-contract.md +535 -0
- package/agents/examples/event-sourcing.md +1287 -0
- package/agents/examples/feature-toggles.md +274 -0
- package/agents/examples/field-access.md +271 -0
- package/agents/examples/files-post-processing.md +433 -0
- package/agents/examples/i18n.md +166 -0
- package/agents/examples/idempotency.md +179 -0
- package/agents/examples/inbound-mail-triage.md +315 -0
- package/agents/examples/lane-routing.md +246 -0
- package/agents/examples/ledger-banking.md +215 -0
- package/agents/examples/ledger-invoicing.md +236 -0
- package/agents/examples/legal-pages.md +393 -0
- package/agents/examples/lifecycle-hooks.md +368 -0
- package/agents/examples/managed-config.md +428 -0
- package/agents/examples/managed-pages.md +353 -0
- package/agents/examples/notes-history-basic.md +346 -0
- package/agents/examples/ownership.md +404 -0
- package/agents/examples/pdf-audit-export.md +306 -0
- package/agents/examples/pipeline-basics.md +792 -0
- package/agents/examples/public-share-token.md +351 -0
- package/agents/examples/rate-limiting.md +211 -0
- package/agents/examples/realtime-sse.md +187 -0
- package/agents/examples/record-detail-layout.md +297 -0
- package/agents/examples/reference-data.md +151 -0
- package/agents/examples/relations.md +280 -0
- package/agents/examples/screens-nav.md +353 -0
- package/agents/examples/search.md +233 -0
- package/agents/examples/secrets-demo.md +417 -0
- package/agents/examples/security-baseline.md +187 -0
- package/agents/examples/seed-migration.md +99 -0
- package/agents/examples/session-revocation.md +250 -0
- package/agents/examples/state-machine.md +920 -0
- package/agents/examples/tags-basic.md +401 -0
- package/agents/examples/tenant-isolation.md +173 -0
- package/agents/examples/tenant-settings.md +275 -0
- package/agents/examples/tier-admin.md +347 -0
- package/agents/examples/timezones.md +366 -0
- package/agents/examples/user-data-rights.md +418 -0
- package/agents/examples/user-profile.md +226 -0
- package/agents/examples/webhook-step.md +597 -0
- package/agents/examples/wizard-form.md +379 -0
- package/agents/examples/workflow-engine.md +1731 -0
- package/agents/examples/writeform-section.md +300 -0
- package/agents/examples.md +75 -0
- package/agents/features.md +768 -0
- package/agents/widgets.md +181 -0
- package/dist/api/auth-middleware.d.ts +2 -0
- package/dist/api/auth-middleware.js +7 -0
- package/dist/api/auth-routes.d.ts +0 -1
- package/dist/api/auth-routes.js +49 -46
- package/dist/api/extra-route.d.ts +3 -0
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js +1 -1
- package/dist/api/routes.js +46 -17
- package/dist/api/server.d.ts +4 -3
- package/dist/api/server.js +39 -10
- package/dist/api/session-cookies.d.ts +1 -0
- package/dist/api/session-cookies.js +20 -5
- package/dist/api/sse-route.js +16 -1
- package/dist/bun-db/connection.js +3 -16
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +15 -3
- package/dist/bun-db/query.js +76 -24
- package/dist/crypto/owner-transition-rekey.d.ts +1 -0
- package/dist/crypto/owner-transition-rekey.js +7 -3
- package/dist/crypto/pii-field-encryption.d.ts +1 -0
- package/dist/crypto/pii-field-encryption.js +9 -2
- package/dist/crypto/subject-resolver.d.ts +1 -0
- package/dist/crypto/subject-resolver.js +7 -0
- package/dist/db/api.d.ts +4 -3
- package/dist/db/apply-entity-event.js +26 -2
- package/dist/db/blind-index-cleanup.js +2 -1
- package/dist/db/bun-provider.js +9 -18
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/dialect.d.ts +1 -1
- package/dist/db/dialect.js +2 -0
- package/dist/db/eagerload.js +4 -3
- package/dist/db/event-store-executor-context.d.ts +6 -6
- package/dist/db/event-store-executor-context.js +25 -22
- package/dist/db/event-store-executor-read.js +31 -16
- package/dist/db/event-store-executor-write.d.ts +1 -1
- package/dist/db/event-store-executor-write.js +84 -63
- package/dist/db/index.d.ts +2 -1
- package/dist/db/index.js +2 -1
- package/dist/db/lock-guard.d.ts +4 -0
- package/dist/db/lock-guard.js +14 -0
- package/dist/db/migrate-generator.js +1 -1
- package/dist/db/migrate-runner.d.ts +1 -0
- package/dist/db/migrate-runner.js +64 -38
- package/dist/db/queries/backfill-pii.js +63 -25
- package/dist/db/queries/event-consumer.d.ts +6 -6
- package/dist/db/queries/event-consumer.js +2 -1
- package/dist/db/queries/event-store-transfer.js +15 -4
- package/dist/db/queries/event-store.d.ts +1 -1
- package/dist/db/queries/event-store.js +11 -10
- package/dist/db/queries/pii-backfill-state.js +3 -2
- package/dist/db/queries/postgres-array-literal.d.ts +2 -0
- package/dist/db/queries/postgres-array-literal.js +6 -0
- package/dist/db/queries/projection-rebuild.d.ts +1 -1
- package/dist/db/queries/projection-rebuild.js +3 -2
- package/dist/db/queries/shadow-swap.d.ts +1 -1
- package/dist/db/queries/shadow-swap.js +12 -8
- package/dist/db/query.d.ts +1 -1
- package/dist/db/query.js +1 -1
- package/dist/db/retired-framework-tables.js +22 -4
- package/dist/db/table-builder.js +6 -2
- package/dist/db/tenant-db-runner.d.ts +4 -0
- package/dist/db/tenant-db-runner.js +17 -0
- package/dist/db/tenant-db.d.ts +4 -0
- package/dist/db/tenant-db.js +50 -5
- package/dist/derivatives/delete-stored-file.d.ts +4 -0
- package/dist/derivatives/delete-stored-file.js +9 -0
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/derivatives/index.d.ts +1 -1
- package/dist/derivatives/index.js +1 -1
- package/dist/engine/boot-validator/access-declarations.js +2 -2
- package/dist/engine/boot-validator/api-ext.js +3 -3
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/config-deps.js +13 -13
- package/dist/engine/boot-validator/detail-screens.js +1 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +87 -35
- package/dist/engine/boot-validator/entity-list-screens.js +2 -2
- package/dist/engine/boot-validator/handler-field-references.js +19 -9
- package/dist/engine/boot-validator/i18n-keys.js +2 -2
- package/dist/engine/boot-validator/nav.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +8 -8
- package/dist/engine/boot-validator/projection-list-screens.js +5 -5
- package/dist/engine/boot-validator/query-output-columns.js +7 -7
- package/dist/engine/boot-validator/query-refs.js +14 -2
- package/dist/engine/boot-validator/screens.d.ts +2 -1
- package/dist/engine/boot-validator/screens.js +153 -95
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/dedupe-features.js +31 -4
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +8 -1
- package/dist/engine/entity-handlers.js +14 -7
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +2 -0
- package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
- package/dist/engine/feature-ast/render.js +10 -2
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -3
- package/dist/engine/handler-field-references.d.ts +21 -2
- package/dist/engine/handler-field-references.js +55 -17
- package/dist/engine/index.d.ts +3 -3
- package/dist/engine/index.js +1 -1
- package/dist/engine/schema-builder.js +2 -1
- package/dist/engine/screen-helpers.d.ts +1 -0
- package/dist/engine/screen-helpers.js +2 -2
- package/dist/engine/soft-delete-cleanup.js +10 -3
- package/dist/engine/steps/_read-source.js +5 -2
- package/dist/engine/steps/retry.js +2 -1
- package/dist/engine/steps/unsafe-projection-delete.js +2 -2
- package/dist/engine/steps/unsafe-projection-upsert.js +2 -2
- package/dist/engine/template-variables.d.ts +3 -0
- package/dist/engine/template-variables.js +3 -0
- package/dist/engine/tier-resolver-extension.d.ts +1 -0
- package/dist/engine/tier-resolver-extension.js +6 -0
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/entrypoint/index.d.ts +2 -2
- package/dist/env/_zod-introspect.d.ts +1 -0
- package/dist/errors/classes.js +1 -0
- package/dist/errors/docs-url.js +6 -6
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/errors/kumiko-error.d.ts +2 -0
- package/dist/errors/kumiko-error.js +2 -0
- package/dist/errors/subject-erased.d.ts +1 -0
- package/dist/errors/subject-erased.js +6 -0
- package/dist/errors/write-error-info.js +2 -0
- package/dist/event-store/admin-api.js +3 -2
- package/dist/event-store/errors.d.ts +9 -0
- package/dist/event-store/errors.js +20 -0
- package/dist/event-store/event-store.d.ts +6 -0
- package/dist/event-store/event-store.js +28 -6
- package/dist/event-store/index.d.ts +1 -1
- package/dist/event-store/index.js +1 -1
- package/dist/event-store/tenant-db-append.js +2 -2
- package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
- package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
- package/dist/files/bind-pending-file-refs.d.ts +2 -0
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/bind-referenced-files.write.d.ts +1 -1
- package/dist/files/bind-referenced-files.write.js +22 -13
- package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
- package/dist/files/create-file-ref-from-bytes.js +67 -4
- package/dist/files/create-file-ref.d.ts +24 -1
- package/dist/files/create-file-ref.js +67 -25
- package/dist/files/dispose-removed-file-refs.js +21 -6
- package/dist/files/feature.d.ts +1 -0
- package/dist/files/feature.js +2 -1
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +12 -0
- package/dist/files/file-routes.js +10 -6
- package/dist/files/index.d.ts +3 -3
- package/dist/files/index.js +2 -2
- package/dist/files/load-save-file-refs.d.ts +10 -0
- package/dist/files/load-save-file-refs.js +47 -0
- package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
- package/dist/files/purge-expired-file-ref-bytes.js +46 -21
- package/dist/files/resolve-content-type.js +40 -4
- package/dist/files/storage-tracking.js +5 -4
- package/dist/files/types.js +5 -8
- package/dist/files/zip-entry-names.d.ts +1 -0
- package/dist/files/zip-entry-names.js +46 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +5 -1
- package/dist/jobs/job-runner.js +124 -37
- package/dist/observability/escape-hatch-report.d.ts +2 -1
- package/dist/observability/escape-hatch-report.js +48 -7
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.js +1 -1
- package/dist/observability/metrics-handle.d.ts +1 -0
- package/dist/observability/metrics-handle.js +7 -0
- package/dist/observability/standard-metrics.js +4 -4
- package/dist/pipeline/cascade-handler.js +5 -2
- package/dist/pipeline/dispatch-batch.js +79 -31
- package/dist/pipeline/dispatch-query.d.ts +2 -1
- package/dist/pipeline/dispatch-query.js +6 -6
- package/dist/pipeline/dispatch-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +136 -48
- package/dist/pipeline/dispatch-stream.js +10 -4
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/dispatcher.js +3 -1
- package/dist/pipeline/event-dispatcher.d.ts +2 -2
- package/dist/pipeline/event-dispatcher.js +11 -9
- package/dist/pipeline/event-retention.d.ts +1 -0
- package/dist/pipeline/event-retention.js +7 -1
- package/dist/pipeline/index.d.ts +1 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/notify-scope.js +3 -1
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +29 -9
- package/dist/rate-limit/bucket.d.ts +3 -3
- package/dist/rate-limit/bucket.js +14 -4
- package/dist/redis/cache-sync-bus.js +9 -3
- package/dist/schema-cli.js +12 -1
- package/dist/search/meilisearch-adapter.js +36 -9
- package/dist/stack/db.d.ts +2 -2
- package/dist/stack/test-stack.js +2 -1
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/multipart-helper.d.ts +6 -0
- package/dist/testing/multipart-helper.js +1 -1
- package/dist/testing/zip-fixture.d.ts +1 -0
- package/dist/testing/zip-fixture.js +33 -0
- package/dist/ui-types/card-column-roles.js +4 -2
- package/dist/ui-types/index.d.ts +1 -1
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-cli.js +46 -13
- package/package.json +7 -5
- package/src/changes.json +512 -8
|
@@ -1,34 +1,59 @@
|
|
|
1
1
|
import { deleteMany, selectMany } from "../db/query.js";
|
|
2
|
-
import {
|
|
2
|
+
import { deleteFileRefBytes } from "../derivatives/delete-stored-file.js";
|
|
3
3
|
export const EXPIRED_FILE_REF_PURGE_BATCH = 500;
|
|
4
|
+
const PAGE_ORDER = [
|
|
5
|
+
{ col: "deletedAt", direction: "asc" },
|
|
6
|
+
{ col: "id", direction: "asc" },
|
|
7
|
+
];
|
|
8
|
+
// Keyset page over (deletedAt, id): rows at the cursor's instant with a larger id first, then
|
|
9
|
+
// strictly later instants. `where` already bounds deletedAt by the cutoff; the later-instants
|
|
10
|
+
// query repeats that bound next to the cursor.
|
|
11
|
+
async function selectNextPage(args) {
|
|
12
|
+
const { db, table, where, expiredBefore, cursor, pageSize } = args;
|
|
13
|
+
const options = { orderBy: PAGE_ORDER, limit: pageSize };
|
|
14
|
+
if (cursor === undefined)
|
|
15
|
+
return selectMany(db, table, where, options);
|
|
16
|
+
const sameInstant = await selectMany(db, table, { ...where, deletedAt: cursor.deletedAt, id: { gt: cursor.id } }, options);
|
|
17
|
+
if (sameInstant.length >= pageSize)
|
|
18
|
+
return sameInstant;
|
|
19
|
+
const later = await selectMany(db, table, { ...where, deletedAt: { gt: cursor.deletedAt, lt: expiredBefore } }, { ...options, limit: pageSize - sameInstant.length });
|
|
20
|
+
return [...sameInstant, ...later];
|
|
21
|
+
}
|
|
4
22
|
// Deletes the bytes (original + derivatives) of expired soft-deleted fileRef rows, then the
|
|
5
23
|
// rows. Bytes first and fail-closed: once the row is gone nobody knows the storageKey, so a
|
|
6
|
-
// row whose bytes could not be removed stays and the next run retries it.
|
|
24
|
+
// row whose bytes could not be removed stays and the next run retries it. A run walks the
|
|
25
|
+
// expired rows page by page along (deletedAt, id), so rows that keep failing cannot hide the
|
|
26
|
+
// rows behind them; the run ends on the first empty page.
|
|
7
27
|
export async function purgeExpiredFileRefsWithBytes(args) {
|
|
8
|
-
const { db, table, where, files, log } = args;
|
|
28
|
+
const { db, table, where, expiredBefore, files, log } = args;
|
|
29
|
+
const pageSize = args.pageSize ?? EXPIRED_FILE_REF_PURGE_BATCH;
|
|
9
30
|
if (!files) {
|
|
10
31
|
log?.warn("[soft-delete-cleanup] no file storage available, expired fileRef rows kept");
|
|
11
32
|
return;
|
|
12
33
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
34
|
+
let cursor;
|
|
35
|
+
for (;;) {
|
|
36
|
+
const rows = await selectNextPage({ db, table, where, expiredBefore, cursor, pageSize });
|
|
37
|
+
const lastRow = rows[rows.length - 1];
|
|
38
|
+
// skip: an empty page means every expired row has been visited
|
|
39
|
+
if (lastRow === undefined)
|
|
40
|
+
return;
|
|
41
|
+
cursor = lastRow;
|
|
42
|
+
const purgedIds = [];
|
|
43
|
+
for (const row of rows) {
|
|
44
|
+
try {
|
|
45
|
+
const failedKeys = await deleteFileRefBytes(files, row, "soft-delete-cleanup:fileRef");
|
|
46
|
+
if (failedKeys.length === 0) {
|
|
47
|
+
purgedIds.push(row.id);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
log?.warn(`[soft-delete-cleanup] bytes of fileRef ${row.id} not fully deleted, row kept`);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
log?.warn(`[soft-delete-cleanup] bytes of fileRef ${row.id} not deleted, row kept: ${err instanceof Error ? err.message : String(err)}`);
|
|
25
54
|
}
|
|
26
|
-
log?.warn(`[soft-delete-cleanup] bytes of fileRef ${row.id} not fully deleted, row kept`);
|
|
27
|
-
}
|
|
28
|
-
catch (err) {
|
|
29
|
-
log?.warn(`[soft-delete-cleanup] bytes of fileRef ${row.id} not deleted, row kept: ${err instanceof Error ? err.message : String(err)}`);
|
|
30
55
|
}
|
|
56
|
+
if (purgedIds.length > 0)
|
|
57
|
+
await deleteMany(db, table, { ...where, id: { in: purgedIds } });
|
|
31
58
|
}
|
|
32
|
-
if (purgedIds.length > 0)
|
|
33
|
-
await deleteMany(db, table, { ...where, id: { in: purgedIds } });
|
|
34
59
|
}
|
|
@@ -13,6 +13,41 @@ const UNAMBIGUOUS_SNIFFED_TYPES = new Set([
|
|
|
13
13
|
]);
|
|
14
14
|
const ZIP_SIGNATURE = [0x50, 0x4b, 0x03, 0x04];
|
|
15
15
|
const XML_PROBE_BYTES = 512;
|
|
16
|
+
// Root elements of markup that starts with `<` but is not a document the XML
|
|
17
|
+
// ingest providers should receive: HTML (documents and fragments), SVG.
|
|
18
|
+
const NON_XML_ROOT_ELEMENTS = new Set([
|
|
19
|
+
"html",
|
|
20
|
+
"head",
|
|
21
|
+
"body",
|
|
22
|
+
"script",
|
|
23
|
+
"style",
|
|
24
|
+
"meta",
|
|
25
|
+
"link",
|
|
26
|
+
"title",
|
|
27
|
+
"svg",
|
|
28
|
+
"div",
|
|
29
|
+
"span",
|
|
30
|
+
"p",
|
|
31
|
+
"a",
|
|
32
|
+
"img",
|
|
33
|
+
"br",
|
|
34
|
+
"hr",
|
|
35
|
+
"table",
|
|
36
|
+
"ul",
|
|
37
|
+
"ol",
|
|
38
|
+
"li",
|
|
39
|
+
"h1",
|
|
40
|
+
"h2",
|
|
41
|
+
"h3",
|
|
42
|
+
"h4",
|
|
43
|
+
"h5",
|
|
44
|
+
"h6",
|
|
45
|
+
"section",
|
|
46
|
+
"article",
|
|
47
|
+
"font",
|
|
48
|
+
"center",
|
|
49
|
+
]);
|
|
50
|
+
const ROOT_ELEMENT_PATTERN = /^<(?:[A-Za-z_][\w.-]*:)?([A-Za-z_][\w.-]*)/;
|
|
16
51
|
const EXTENSION_FALLBACK = {
|
|
17
52
|
xml: "application/xml",
|
|
18
53
|
pdf: "application/pdf",
|
|
@@ -38,8 +73,8 @@ function hasPrefix(bytes, prefix) {
|
|
|
38
73
|
return prefix.every((byte, i) => bytes[i] === byte);
|
|
39
74
|
}
|
|
40
75
|
// Textual start only: a NUL in the probe means binary (or UTF-16, which is
|
|
41
|
-
// deliberately not detected).
|
|
42
|
-
// relabelled as XML.
|
|
76
|
+
// deliberately not detected). Without an XML declaration the root element
|
|
77
|
+
// decides: HTML/SVG roots (also namespace-prefixed) are not relabelled as XML.
|
|
43
78
|
function looksLikeXml(bytes) {
|
|
44
79
|
const probe = bytes.subarray(0, XML_PROBE_BYTES);
|
|
45
80
|
if (probe.includes(0))
|
|
@@ -47,9 +82,10 @@ function looksLikeXml(bytes) {
|
|
|
47
82
|
const text = new TextDecoder().decode(probe).replace(/^/, "").trimStart();
|
|
48
83
|
if (text.startsWith("<?xml"))
|
|
49
84
|
return true;
|
|
50
|
-
|
|
85
|
+
const root = ROOT_ELEMENT_PATTERN.exec(text)?.[1];
|
|
86
|
+
if (root === undefined)
|
|
51
87
|
return false;
|
|
52
|
-
return
|
|
88
|
+
return !NON_XML_ROOT_ELEMENTS.has(root.toLowerCase());
|
|
53
89
|
}
|
|
54
90
|
function extensionOf(filename) {
|
|
55
91
|
const dot = filename.lastIndexOf(".");
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// into createApp / setupTestStack alongside their domain features.
|
|
13
13
|
import { bigint, instant, integer, table as pgTable, sql, uuid } from "../db/dialect.js";
|
|
14
14
|
import { entityEventName, executeRawQueryRead } from "../db/index.js";
|
|
15
|
+
import { toPostgresArrayLiteral } from "../db/queries/postgres-array-literal.js";
|
|
15
16
|
import { incrementCounter } from "../db/query.js";
|
|
16
17
|
import { defineFeature, qn, toKebab } from "../engine/index.js";
|
|
17
18
|
import { SHARED_INSTANCE_SENTINEL, selectConsumerCursorForUpdate, } from "../pipeline/index.js";
|
|
@@ -141,12 +142,12 @@ export async function transferTenantStorageUsage(args) {
|
|
|
141
142
|
if (!cursor)
|
|
142
143
|
return;
|
|
143
144
|
const rows = await executeRawQueryRead(db, `SELECT "id"::text AS id, "type", "payload"::text AS payload FROM kumiko_events
|
|
144
|
-
WHERE "tenant_id" = $1 AND "aggregate_type" = $2 AND "aggregate_id" = ANY($3)
|
|
145
|
-
AND "type" = ANY($4) AND "id" <= $5`, [
|
|
145
|
+
WHERE "tenant_id" = $1 AND "aggregate_type" = $2 AND "aggregate_id" = ANY($3::uuid[])
|
|
146
|
+
AND "type" = ANY($4::text[]) AND "id" <= $5`, [
|
|
146
147
|
sourceTenantId,
|
|
147
148
|
FILE_REF_AGGREGATE_TYPE,
|
|
148
|
-
fileRefIds,
|
|
149
|
-
TRACKED_FILE_REF_EVENT_TYPES,
|
|
149
|
+
toPostgresArrayLiteral(fileRefIds),
|
|
150
|
+
toPostgresArrayLiteral(TRACKED_FILE_REF_EVENT_TYPES),
|
|
150
151
|
cursor.lastProcessedEventId,
|
|
151
152
|
]);
|
|
152
153
|
let bytes = 0;
|
package/dist/files/types.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readZipEntryNames } from "./zip-entry-names.js";
|
|
1
2
|
// Lives here (not in file-ref-entity.ts) so engine code can name the entity without importing
|
|
2
3
|
// a module that calls createEntity at load time.
|
|
3
4
|
export const FILE_REF_ENTITY_NAME = "fileRef";
|
|
@@ -111,13 +112,9 @@ const MAGIC_BYTE_SIGNATURES = [
|
|
|
111
112
|
// so a byte search finds the docx part directory without unzipping. The main
|
|
112
113
|
// part is resolved via _rels/.rels and may be named e.g. word/document2.xml.
|
|
113
114
|
const DOCX_PART_PREFIX = "word/";
|
|
114
|
-
function
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
if (startsWithAscii(bytes, ascii, i))
|
|
118
|
-
return true;
|
|
119
|
-
}
|
|
120
|
-
return false;
|
|
115
|
+
function hasDocxPart(zipBytes) {
|
|
116
|
+
const names = readZipEntryNames(zipBytes);
|
|
117
|
+
return names?.some((name) => name.startsWith(DOCX_PART_PREFIX)) ?? false;
|
|
121
118
|
}
|
|
122
119
|
function startsWithBytes(bytes, signature) {
|
|
123
120
|
if (bytes.length < signature.length)
|
|
@@ -237,7 +234,7 @@ export function validateFileContent(fileName, content, accept) {
|
|
|
237
234
|
return { kind: "ok" };
|
|
238
235
|
const sniffed = sniffMimeType(content);
|
|
239
236
|
// sniffMimeType only sees the ZIP container; any .xlsx/.pptx/.zip shares it.
|
|
240
|
-
if (sniffed === DOCX_MIME_TYPE && !
|
|
237
|
+
if (sniffed === DOCX_MIME_TYPE && !hasDocxPart(content)) {
|
|
241
238
|
return {
|
|
242
239
|
kind: "rejected",
|
|
243
240
|
error: `content_mismatch: ".${ext}" upload bytes are a ZIP archive without ${DOCX_PART_PREFIX} entries, not a docx`,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function readZipEntryNames(bytes: Uint8Array): readonly string[] | null;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const EOCD_SIGNATURE = 0x06054b50;
|
|
2
|
+
const CENTRAL_HEADER_SIGNATURE = 0x02014b50;
|
|
3
|
+
const EOCD_MIN_LENGTH = 22;
|
|
4
|
+
const EOCD_MAX_COMMENT_LENGTH = 0xffff;
|
|
5
|
+
const CENTRAL_HEADER_FIXED_LENGTH = 46;
|
|
6
|
+
// Entry names from the central directory, which (unlike local headers) always
|
|
7
|
+
// carries sizes and names even for streamed ZIPs written with data descriptors.
|
|
8
|
+
// Returns null when the container is not a readable (non-ZIP64) ZIP.
|
|
9
|
+
export function readZipEntryNames(bytes) {
|
|
10
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
11
|
+
const eocdOffset = findEndOfCentralDirectory(view);
|
|
12
|
+
if (eocdOffset === null)
|
|
13
|
+
return null;
|
|
14
|
+
const entryCount = view.getUint16(eocdOffset + 10, true);
|
|
15
|
+
let offset = view.getUint32(eocdOffset + 16, true);
|
|
16
|
+
const decoder = new TextDecoder();
|
|
17
|
+
const names = [];
|
|
18
|
+
for (let i = 0; i < entryCount; i++) {
|
|
19
|
+
if (offset + CENTRAL_HEADER_FIXED_LENGTH > view.byteLength)
|
|
20
|
+
return null;
|
|
21
|
+
if (view.getUint32(offset, true) !== CENTRAL_HEADER_SIGNATURE)
|
|
22
|
+
return null;
|
|
23
|
+
const nameLength = view.getUint16(offset + 28, true);
|
|
24
|
+
const extraLength = view.getUint16(offset + 30, true);
|
|
25
|
+
const commentLength = view.getUint16(offset + 32, true);
|
|
26
|
+
const nameStart = offset + CENTRAL_HEADER_FIXED_LENGTH;
|
|
27
|
+
if (nameStart + nameLength > view.byteLength)
|
|
28
|
+
return null;
|
|
29
|
+
names.push(decoder.decode(bytes.subarray(nameStart, nameStart + nameLength)));
|
|
30
|
+
offset = nameStart + nameLength + extraLength + commentLength;
|
|
31
|
+
}
|
|
32
|
+
return names;
|
|
33
|
+
}
|
|
34
|
+
function findEndOfCentralDirectory(view) {
|
|
35
|
+
const lowest = Math.max(0, view.byteLength - EOCD_MIN_LENGTH - EOCD_MAX_COMMENT_LENGTH);
|
|
36
|
+
for (let offset = view.byteLength - EOCD_MIN_LENGTH; offset >= lowest; offset--) {
|
|
37
|
+
if (view.getUint32(offset, true) !== EOCD_SIGNATURE)
|
|
38
|
+
continue;
|
|
39
|
+
// A signature inside the archive comment is skipped: the real record's
|
|
40
|
+
// comment length reaches exactly to the end of the buffer.
|
|
41
|
+
const commentLength = view.getUint16(offset + 20, true);
|
|
42
|
+
if (offset + EOCD_MIN_LENGTH + commentLength === view.byteLength)
|
|
43
|
+
return offset;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
package/dist/jobs/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export type { JobLogEntry, JobMeta, JobOutcomeMeta, JobQueuedMeta, JobRunner, JobRunnerOptions, JobSubjectValue, } from "./job-runner.js";
|
|
1
|
+
export type { JobDroppedMeta, JobLogEntry, JobMeta, JobOutcomeMeta, JobQueuedMeta, JobRunner, JobRunnerOptions, JobSubjectValue, } from "./job-runner.js";
|
|
2
2
|
export { createJobRunner, serializeJobSubject } from "./job-runner.js";
|
|
@@ -29,6 +29,9 @@ export type JobQueuedMeta = {
|
|
|
29
29
|
readonly tenantId: string;
|
|
30
30
|
readonly subject: string | null;
|
|
31
31
|
};
|
|
32
|
+
export type JobDroppedMeta = {
|
|
33
|
+
readonly tenantId: string;
|
|
34
|
+
};
|
|
32
35
|
export type JobOutcomeMeta = {
|
|
33
36
|
readonly tenantId: string;
|
|
34
37
|
readonly finalAttempt: boolean;
|
|
@@ -57,6 +60,7 @@ export type JobRunnerOptions = {
|
|
|
57
60
|
consumerLane?: JobRunIn | undefined;
|
|
58
61
|
queueNamePrefix?: string | undefined;
|
|
59
62
|
bootRedisTimeoutMs?: number | undefined;
|
|
63
|
+
schedulerSeenRefreshMs?: number | undefined;
|
|
60
64
|
jobRetention?: {
|
|
61
65
|
completedAgeSec?: number | undefined;
|
|
62
66
|
failedAgeSec?: number | undefined;
|
|
@@ -66,6 +70,6 @@ export type JobRunnerOptions = {
|
|
|
66
70
|
onJobComplete?: (jobName: string, jobId: string, duration: number, logs: JobLogEntry[], outcome?: JobOutcomeMeta) => void;
|
|
67
71
|
onJobFailed?: (jobName: string, jobId: string, error: string, logs: JobLogEntry[], outcome?: JobOutcomeMeta) => void;
|
|
68
72
|
onJobQueued?: (jobName: string, jobId: string, meta: JobQueuedMeta) => void;
|
|
69
|
-
onJobDropped?: (jobName: string, jobId: string) => void;
|
|
73
|
+
onJobDropped?: (jobName: string, jobId: string, meta: JobDroppedMeta) => void;
|
|
70
74
|
};
|
|
71
75
|
export declare function createJobRunner(options: JobRunnerOptions): JobRunner;
|
package/dist/jobs/job-runner.js
CHANGED
|
@@ -12,7 +12,7 @@ import { InternalError } from "../errors/index.js";
|
|
|
12
12
|
import { isKumikoError } from "../errors/kumiko-error.js";
|
|
13
13
|
import { createFileContext } from "../files/file-handle.js";
|
|
14
14
|
import { createFallbackLogger } from "../logging/index.js";
|
|
15
|
-
import { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, emitJobLastSuccess, emitJobQueueDepth, flushEscapeHatchAudits, getFallbackTracer, registerStandardMetrics, withEscapeHatchAuditScope, } from "../observability/index.js";
|
|
15
|
+
import { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, createMetricsForFactory, emitJobLastSuccess, emitJobQueueDepth, flushEscapeHatchAudits, getFallbackTracer, registerStandardMetrics, withEscapeHatchAuditScope, } from "../observability/index.js";
|
|
16
16
|
import { createDistributedLock } from "../pipeline/distributed-lock.js";
|
|
17
17
|
import { RedisKeys } from "../pipeline/redis-keys.js";
|
|
18
18
|
import { createGatedIdentitySwitch } from "../pipeline/system-identity-switch.js";
|
|
@@ -82,6 +82,18 @@ export const ORPHAN_SCHEDULER_GRACE_MS = 60 * 60 * 1000;
|
|
|
82
82
|
function schedulerSeenKey(queue) {
|
|
83
83
|
return queue.toKey("kumiko-scheduler-seen");
|
|
84
84
|
}
|
|
85
|
+
// "Seen" has to mean a live runner holds the scheduler, so runners re-stamp
|
|
86
|
+
// their own ids periodically; a single stamp at start() would let a runner
|
|
87
|
+
// restarting during a mixed-version window longer than the grace period delete
|
|
88
|
+
// a scheduler a healthy newer pod still owns.
|
|
89
|
+
async function markSchedulersSeen(queue, schedulerIds) {
|
|
90
|
+
// skip: no cron scheduler on this lane, nothing to stamp
|
|
91
|
+
if (schedulerIds.size === 0)
|
|
92
|
+
return;
|
|
93
|
+
const stamp = String(Date.now());
|
|
94
|
+
const entries = Object.fromEntries([...schedulerIds].map((id) => [id, stamp]));
|
|
95
|
+
await (await queue.client).hset(schedulerSeenKey(queue), entries);
|
|
96
|
+
}
|
|
85
97
|
// A cron job dropped from the app (or an older scheduler-id format) leaves its
|
|
86
98
|
// scheduler in Redis, where it keeps firing jobs the worker no longer knows
|
|
87
99
|
// ("Unknown job"). Best-effort: a Redis hiccup here must not stop the start.
|
|
@@ -364,6 +376,7 @@ export function createJobRunner(options) {
|
|
|
364
376
|
const { registry, context, redisUrl, consumerLane } = options;
|
|
365
377
|
const queueNamePrefix = options.queueNamePrefix ?? DEFAULT_QUEUE_NAME_PREFIX;
|
|
366
378
|
const bootRedisTimeoutMs = options.bootRedisTimeoutMs ?? BOOT_REDIS_TIMEOUT_MS;
|
|
379
|
+
const schedulerSeenRefreshMs = options.schedulerSeenRefreshMs ?? ORPHAN_SCHEDULER_GRACE_MS / 4;
|
|
367
380
|
const redisOpts = parseRedisOpts(redisUrl);
|
|
368
381
|
// Use the context's tracer when present (observability-provider injected at
|
|
369
382
|
// boot); otherwise noop so dispatch/handleJob stay zero-cost without config.
|
|
@@ -524,6 +537,11 @@ export function createJobRunner(options) {
|
|
|
524
537
|
}
|
|
525
538
|
let worker = null;
|
|
526
539
|
let queueDepthTimer = null;
|
|
540
|
+
let schedulerSeenTimer = null;
|
|
541
|
+
let schedulerStampPending = false;
|
|
542
|
+
// A timed-out gate is abandoned, not aborted (the job context carries no
|
|
543
|
+
// AbortSignal); stop() waits for it so it cannot write after the shutdown.
|
|
544
|
+
const abandonedBootGates = new Set();
|
|
527
545
|
// Forward reference to the runner's own API, exposed on the job-handler ctx
|
|
528
546
|
// so a handler can dispatch a follow-up job (job→job chaining, e.g.
|
|
529
547
|
// delivery.render → delivery.send). Assigned just before return; reads happen
|
|
@@ -611,12 +629,16 @@ export function createJobRunner(options) {
|
|
|
611
629
|
});
|
|
612
630
|
}
|
|
613
631
|
}
|
|
614
|
-
async function announceDropped(jobName, jobDef, bullJobId) {
|
|
632
|
+
async function announceDropped(jobName, jobDef, bullJobId, data) {
|
|
615
633
|
// skip: job did not opt in or no logger wired
|
|
616
634
|
if (!jobDef.tenantVisibleRun || !options.onJobDropped)
|
|
617
635
|
return;
|
|
636
|
+
const tenantId = tenantIdOfJobData(data);
|
|
637
|
+
// skip: tenant-less run — announceQueued never recorded a row for it
|
|
638
|
+
if (tenantId === SYSTEM_TENANT_ID)
|
|
639
|
+
return;
|
|
618
640
|
try {
|
|
619
|
-
await options.onJobDropped(jobName, bullJobId);
|
|
641
|
+
await options.onJobDropped(jobName, bullJobId, { tenantId });
|
|
620
642
|
}
|
|
621
643
|
catch (err) {
|
|
622
644
|
errorLogger.warn("tenant-visible queued state not cleared", {
|
|
@@ -732,7 +754,7 @@ export function createJobRunner(options) {
|
|
|
732
754
|
...buildRetryBullOpts(jobDef, priorAttempts),
|
|
733
755
|
});
|
|
734
756
|
if (bullJob.id !== undefined && bullJob.id !== requeued.id) {
|
|
735
|
-
await announceDropped(jobName, jobDef, bullJob.id);
|
|
757
|
+
await announceDropped(jobName, jobDef, bullJob.id, bullJob.data);
|
|
736
758
|
}
|
|
737
759
|
await announceQueued(jobName, jobDef, requeued.id, requeuedData);
|
|
738
760
|
// skip: lock taken, work re-enqueued with delay, current invocation done
|
|
@@ -939,6 +961,7 @@ export function createJobRunner(options) {
|
|
|
939
961
|
attempt,
|
|
940
962
|
finalAttempt,
|
|
941
963
|
log: createJobLogger(logs),
|
|
964
|
+
metricsFor: createMetricsForFactory(context.meter),
|
|
942
965
|
...(triggerName !== undefined && { triggerName }),
|
|
943
966
|
...(triggerEvent !== undefined && { triggerEvent }),
|
|
944
967
|
write: (qn, payload) => {
|
|
@@ -1074,17 +1097,18 @@ export function createJobRunner(options) {
|
|
|
1074
1097
|
});
|
|
1075
1098
|
}, BOOT_GATE_SLOW_WARN_MS)
|
|
1076
1099
|
: undefined;
|
|
1100
|
+
const gateRun = handleJob({
|
|
1101
|
+
id: bootJobIdForJobName(name),
|
|
1102
|
+
name,
|
|
1103
|
+
data: {},
|
|
1104
|
+
attemptsMade: 0,
|
|
1105
|
+
retryable: false,
|
|
1106
|
+
});
|
|
1107
|
+
const gateSettled = gateRun.then(() => undefined, () => undefined);
|
|
1108
|
+
abandonedBootGates.add(gateSettled);
|
|
1109
|
+
void gateSettled.then(() => abandonedBootGates.delete(gateSettled));
|
|
1077
1110
|
try {
|
|
1078
|
-
await Promise.race([
|
|
1079
|
-
handleJob({
|
|
1080
|
-
id: bootJobIdForJobName(name),
|
|
1081
|
-
name,
|
|
1082
|
-
data: {},
|
|
1083
|
-
attemptsMade: 0,
|
|
1084
|
-
retryable: false,
|
|
1085
|
-
}),
|
|
1086
|
-
...(gateTimeout ? [gateTimeout.promise] : []),
|
|
1087
|
-
]);
|
|
1111
|
+
await Promise.race([gateRun, ...(gateTimeout ? [gateTimeout.promise] : [])]);
|
|
1088
1112
|
}
|
|
1089
1113
|
catch (err) {
|
|
1090
1114
|
// Leaving the worker's Redis connections open would keep the event loop alive and turn the aborted boot into
|
|
@@ -1100,6 +1124,74 @@ export function createJobRunner(options) {
|
|
|
1100
1124
|
}
|
|
1101
1125
|
}
|
|
1102
1126
|
}
|
|
1127
|
+
function cronSchedulerIdsForLane(lane) {
|
|
1128
|
+
const ids = new Set();
|
|
1129
|
+
for (const [name, jobDef] of allJobs) {
|
|
1130
|
+
if (laneForJob(jobDef) === lane && "cron" in jobDef.trigger) {
|
|
1131
|
+
ids.add(schedulerIdForJobName(name));
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
return ids;
|
|
1135
|
+
}
|
|
1136
|
+
async function upsertCronSchedulers(queue, lane) {
|
|
1137
|
+
for (const [name, jobDef] of allJobs) {
|
|
1138
|
+
if (laneForJob(jobDef) !== lane)
|
|
1139
|
+
continue;
|
|
1140
|
+
if ("cron" in jobDef.trigger) {
|
|
1141
|
+
await queue.upsertJobScheduler(schedulerIdForJobName(name), cronRepeatOptions(jobDef.trigger), {
|
|
1142
|
+
name: jobDef.perTenant ? `_perTenant:${name}` : name,
|
|
1143
|
+
data: {},
|
|
1144
|
+
// Queue-level defaultJobOptions (jobRetentionOpts) covers
|
|
1145
|
+
// retention; a count here would sweep queue-wide again and
|
|
1146
|
+
// evict perTenant children and boot jobs.
|
|
1147
|
+
opts: {
|
|
1148
|
+
...buildRetryBullOpts(jobDef),
|
|
1149
|
+
},
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
// After a failed initial stamp an older runner may have removed our
|
|
1155
|
+
// schedulers as unseen, so the next tick stamps and re-upserts before the
|
|
1156
|
+
// flag clears.
|
|
1157
|
+
async function refreshSchedulerSeen(queue, lane, schedulerIds) {
|
|
1158
|
+
await markSchedulersSeen(queue, schedulerIds);
|
|
1159
|
+
if (schedulerStampPending) {
|
|
1160
|
+
await upsertCronSchedulers(queue, lane);
|
|
1161
|
+
schedulerStampPending = false;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
function startSchedulerSeenRefresh(queue, lane, schedulerIds) {
|
|
1165
|
+
if (schedulerIds.size === 0)
|
|
1166
|
+
return null;
|
|
1167
|
+
const timer = setInterval(() => {
|
|
1168
|
+
refreshSchedulerSeen(queue, lane, schedulerIds).catch((err) => {
|
|
1169
|
+
errorLogger.warn("scheduler seen refresh failed", {
|
|
1170
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1171
|
+
});
|
|
1172
|
+
});
|
|
1173
|
+
}, schedulerSeenRefreshMs);
|
|
1174
|
+
timer.unref();
|
|
1175
|
+
return timer;
|
|
1176
|
+
}
|
|
1177
|
+
// Bounded: a gate that never returns must not block shutdown as well.
|
|
1178
|
+
async function settleAbandonedBootGates(maxWaitMs) {
|
|
1179
|
+
// skip: no timed-out gate left behind
|
|
1180
|
+
if (abandonedBootGates.size === 0)
|
|
1181
|
+
return;
|
|
1182
|
+
const wait = timeoutReject(maxWaitMs, "job-runner: abandoned boot gate still running at stop()");
|
|
1183
|
+
try {
|
|
1184
|
+
await Promise.race([Promise.all(abandonedBootGates), wait.promise]);
|
|
1185
|
+
}
|
|
1186
|
+
catch (err) {
|
|
1187
|
+
errorLogger.warn("abandoned boot gate did not finish before stop()", {
|
|
1188
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
finally {
|
|
1192
|
+
wait.cancel();
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1103
1195
|
const runnerApi = {
|
|
1104
1196
|
async start() {
|
|
1105
1197
|
// skip: enqueuer-only runner — no BullMQ worker, no cron schedules,
|
|
@@ -1148,28 +1240,18 @@ export function createJobRunner(options) {
|
|
|
1148
1240
|
// Only schedule cron + boot for jobs that belong to this lane. Jobs
|
|
1149
1241
|
// assigned to the other lane get their cron/boot wiring from the
|
|
1150
1242
|
// runner running on that lane. Running both here would double-fire.
|
|
1151
|
-
const expectedSchedulerIds =
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
// evict perTenant children and boot jobs.
|
|
1164
|
-
opts: {
|
|
1165
|
-
...buildRetryBullOpts(jobDef),
|
|
1166
|
-
},
|
|
1167
|
-
});
|
|
1168
|
-
await (await consumerQueue.client).hset(schedulerSeenKey(consumerQueue), {
|
|
1169
|
-
[schedulerId]: String(Date.now()),
|
|
1170
|
-
});
|
|
1171
|
-
}
|
|
1172
|
-
}
|
|
1243
|
+
const expectedSchedulerIds = cronSchedulerIdsForLane(consumerLane);
|
|
1244
|
+
// Before the upserts: a seen entry without a scheduler is harmless, a
|
|
1245
|
+
// scheduler a concurrent older runner finds without one gets deleted.
|
|
1246
|
+
// Best-effort like the periodic refresh: a failed stamp must not stop the start.
|
|
1247
|
+
await markSchedulersSeen(consumerQueue, expectedSchedulerIds).catch((err) => {
|
|
1248
|
+
schedulerStampPending = true;
|
|
1249
|
+
errorLogger.warn("scheduler seen stamp failed", {
|
|
1250
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1251
|
+
});
|
|
1252
|
+
});
|
|
1253
|
+
await upsertCronSchedulers(consumerQueue, consumerLane);
|
|
1254
|
+
schedulerSeenTimer = startSchedulerSeenRefresh(consumerQueue, consumerLane, expectedSchedulerIds);
|
|
1173
1255
|
await removeOrphanJobSchedulers(consumerQueue, expectedSchedulerIds, consumerLane, {
|
|
1174
1256
|
info: (msg, data) => context.log ? context.log.info(msg, data) : errorLogger.debug(msg, data),
|
|
1175
1257
|
warn: errorLogger.warn,
|
|
@@ -1217,6 +1299,10 @@ export function createJobRunner(options) {
|
|
|
1217
1299
|
clearInterval(queueDepthTimer);
|
|
1218
1300
|
queueDepthTimer = null;
|
|
1219
1301
|
}
|
|
1302
|
+
if (schedulerSeenTimer) {
|
|
1303
|
+
clearInterval(schedulerSeenTimer);
|
|
1304
|
+
schedulerSeenTimer = null;
|
|
1305
|
+
}
|
|
1220
1306
|
if (worker) {
|
|
1221
1307
|
await worker.close();
|
|
1222
1308
|
worker = null;
|
|
@@ -1226,6 +1312,7 @@ export function createJobRunner(options) {
|
|
|
1226
1312
|
// without listeners -> unhandled rejection (fw#1805). Wait for readiness
|
|
1227
1313
|
// first; a failed/timed-out wait must not block the close itself.
|
|
1228
1314
|
const stopReadyTimeoutMs = Math.min(bootRedisTimeoutMs, STOP_QUEUE_READY_TIMEOUT_MS);
|
|
1315
|
+
await settleAbandonedBootGates(stopReadyTimeoutMs);
|
|
1229
1316
|
const readinessTimeout = timeoutReject(stopReadyTimeoutMs, `job-runner: Queue not ready within ${stopReadyTimeoutMs}ms during stop()`);
|
|
1230
1317
|
try {
|
|
1231
1318
|
await Promise.race([
|
|
@@ -1298,7 +1385,7 @@ export function createJobRunner(options) {
|
|
|
1298
1385
|
for (const j of waiting) {
|
|
1299
1386
|
if (j.name === jobName && j.id) {
|
|
1300
1387
|
await j.remove();
|
|
1301
|
-
await announceDropped(jobName, jobDef, j.id);
|
|
1388
|
+
await announceDropped(jobName, jobDef, j.id, j.data);
|
|
1302
1389
|
}
|
|
1303
1390
|
}
|
|
1304
1391
|
break;
|
|
@@ -11,10 +11,11 @@ export declare function createEscapeHatchReportWindow(opts?: {
|
|
|
11
11
|
}): EscapeHatchReportWindow;
|
|
12
12
|
export type EscapeHatchProcessDedup = {
|
|
13
13
|
readonly kinds: ReadonlySet<EscapeHatchKind>;
|
|
14
|
-
readonly
|
|
14
|
+
readonly lastAuditedAt: Map<string, number>;
|
|
15
15
|
};
|
|
16
16
|
export declare function createEscapeHatchProcessDedup(kinds: readonly EscapeHatchKind[]): EscapeHatchProcessDedup;
|
|
17
17
|
export declare function withEscapeHatchAuditScope<T>(run: () => Promise<T>): Promise<T>;
|
|
18
|
+
export declare function withEscapeHatchAuditScopeStream<T>(run: () => AsyncGenerator<T>): AsyncGenerator<T>;
|
|
18
19
|
export declare function flushEscapeHatchAudits(): Promise<void>;
|
|
19
20
|
export declare function reportEscapeHatchUse(event: EscapeHatchUseEvent, deps: {
|
|
20
21
|
readonly sink?: EscapeHatchAuditSink;
|
|
@@ -27,8 +27,11 @@ export function createEscapeHatchReportWindow(opts) {
|
|
|
27
27
|
},
|
|
28
28
|
};
|
|
29
29
|
}
|
|
30
|
+
// A long-lived worker must leave an audit event inside every retention window
|
|
31
|
+
// (90 days), or its crons vanish from the log once the first event is pruned.
|
|
32
|
+
const PROCESS_REAUDIT_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
30
33
|
export function createEscapeHatchProcessDedup(kinds) {
|
|
31
|
-
return { kinds: new Set(kinds),
|
|
34
|
+
return { kinds: new Set(kinds), lastAuditedAt: new Map() };
|
|
32
35
|
}
|
|
33
36
|
// Default window for createEscapeHatchReporter callers that don't pass their own.
|
|
34
37
|
const fallbackReportWindow = createEscapeHatchReportWindow();
|
|
@@ -37,9 +40,14 @@ const processPendingAudits = new Set();
|
|
|
37
40
|
// The sink writes of the dispatch or job run currently executing.
|
|
38
41
|
const scopePendingAudits = new AsyncLocalStorage();
|
|
39
42
|
function trackPendingAudit(write) {
|
|
43
|
+
const scope = scopePendingAudits.getStore();
|
|
40
44
|
processPendingAudits.add(write);
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
scope?.add(write);
|
|
46
|
+
// Settled writes leave the scope so a long-lived stream does not accumulate them.
|
|
47
|
+
void write.finally(() => {
|
|
48
|
+
processPendingAudits.delete(write);
|
|
49
|
+
scope?.delete(write);
|
|
50
|
+
});
|
|
43
51
|
}
|
|
44
52
|
// Awaits the audit writes started inside `run` once it has returned, so callers place it around
|
|
45
53
|
// the transaction and the write is durable when the dispatch or run ends. Nested scopes belong
|
|
@@ -56,6 +64,30 @@ export async function withEscapeHatchAuditScope(run) {
|
|
|
56
64
|
await Promise.allSettled([...pending]);
|
|
57
65
|
}
|
|
58
66
|
}
|
|
67
|
+
// Stream variant: the scope has to be entered on every pull because an async generator body
|
|
68
|
+
// runs in the caller's async context, and the wait happens once the stream ends or is closed.
|
|
69
|
+
export async function* withEscapeHatchAuditScopeStream(run) {
|
|
70
|
+
if (scopePendingAudits.getStore() !== undefined) {
|
|
71
|
+
yield* run();
|
|
72
|
+
// skip: an enclosing scope already waits for the pending audits
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const pending = new Set();
|
|
76
|
+
const source = run();
|
|
77
|
+
try {
|
|
78
|
+
while (true) {
|
|
79
|
+
const next = await scopePendingAudits.run(pending, () => source.next());
|
|
80
|
+
// skip: the source is exhausted
|
|
81
|
+
if (next.done)
|
|
82
|
+
return;
|
|
83
|
+
yield next.value;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
await scopePendingAudits.run(pending, () => source.return(undefined));
|
|
88
|
+
await Promise.allSettled([...pending]);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
59
91
|
export async function flushEscapeHatchAudits() {
|
|
60
92
|
while (processPendingAudits.size > 0) {
|
|
61
93
|
await Promise.allSettled([...processPendingAudits]);
|
|
@@ -105,11 +137,20 @@ export function createEscapeHatchReporter(opts) {
|
|
|
105
137
|
emitEscapeHatchUse(opts.meter, opts.handler, kind);
|
|
106
138
|
const { processDedup } = opts;
|
|
107
139
|
if (processDedup?.kinds.has(kind)) {
|
|
108
|
-
const processKey = JSON.stringify([
|
|
109
|
-
|
|
110
|
-
|
|
140
|
+
const processKey = JSON.stringify([
|
|
141
|
+
opts.handler,
|
|
142
|
+
kind,
|
|
143
|
+
opts.tenantId,
|
|
144
|
+
target?.id ?? null,
|
|
145
|
+
target?.tenantId ?? null,
|
|
146
|
+
]);
|
|
147
|
+
const nowMs = now();
|
|
148
|
+
const lastAuditedAt = processDedup.lastAuditedAt.get(processKey);
|
|
149
|
+
// skip: audited by this process within the re-audit interval
|
|
150
|
+
if (lastAuditedAt !== undefined && nowMs - lastAuditedAt < PROCESS_REAUDIT_INTERVAL_MS) {
|
|
111
151
|
return;
|
|
112
|
-
|
|
152
|
+
}
|
|
153
|
+
processDedup.lastAuditedAt.set(processKey, nowMs);
|
|
113
154
|
reportEscapeHatchUse(eventFor(kind, reason, target), deps);
|
|
114
155
|
// skip: audited above, the window dedup below only serves the other kinds
|
|
115
156
|
return;
|
|
@@ -4,7 +4,7 @@ export { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeH
|
|
|
4
4
|
export { getFallbackMeter, getFallbackProvider, getFallbackTracer } from "./fallback.js";
|
|
5
5
|
export { generateSpanId, generateTraceId } from "./ids.js";
|
|
6
6
|
export { buildMetricName, validateLabelKey, validateMetricName, } from "./metric-validator.js";
|
|
7
|
-
export { createMetricsHandle, createNoopMetricsHandle, createSafeMetricsHandle, createUnboundMetricsHandle, } from "./metrics-handle.js";
|
|
7
|
+
export { createMetricsForFactory, createMetricsHandle, createNoopMetricsHandle, createSafeMetricsHandle, createUnboundMetricsHandle, } from "./metrics-handle.js";
|
|
8
8
|
export { type ObservabilityWiring, prometheusMetricsEnvSchema, resolveObservabilityWiring, } from "./metrics-wiring.js";
|
|
9
9
|
export { createNoopProvider } from "./noop-provider.js";
|
|
10
10
|
export { createPrometheusMeter, type PrometheusMeter, type PrometheusMeterSnapshot, serializeOpenMetrics, } from "./prometheus-meter.js";
|