payment-kit 1.29.1 → 1.29.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/dev.ts +41 -2
- package/api/hono.d.ts +42 -0
- package/api/node-sqlite.d.ts +12 -0
- package/api/src/bootstrap.ts +47 -0
- package/api/src/crons/base.ts +3 -3
- package/api/src/crons/currency.ts +1 -1
- package/api/src/crons/index.ts +41 -37
- package/api/src/crons/metering-subscription-detection.ts +1 -1
- package/api/src/crons/overdue-detection.ts +2 -2
- package/api/src/crons/retry-pending-events.ts +6 -0
- package/api/src/crons/tenant-fanout.ts +82 -0
- package/api/src/host-node/did-connect-runtime-node.ts +33 -0
- package/api/src/host-node/serve-static-arc.ts +68 -0
- package/api/src/host-node/serve-static.ts +41 -0
- package/api/src/index.ts +22 -161
- package/api/src/integrations/app-store/client.ts +3 -4
- package/api/src/integrations/app-store/handlers/subscription.ts +7 -7
- package/api/src/integrations/app-store/signed-data-verifier.ts +3 -2
- package/api/src/integrations/arcblock/token.ts +21 -7
- package/api/src/integrations/google-play/handlers/subscription.ts +6 -6
- package/api/src/integrations/google-play/handlers/voided.ts +2 -2
- package/api/src/integrations/google-play/verify.ts +3 -2
- package/api/src/integrations/iap-reconcile.ts +3 -5
- package/api/src/integrations/stripe/handlers/invoice.ts +2 -2
- package/api/src/integrations/stripe/handlers/subscription.ts +3 -3
- package/api/src/libs/archive/query.ts +19 -0
- package/api/src/libs/audit.ts +61 -4
- package/api/src/libs/auth.ts +247 -47
- package/api/src/libs/context.ts +89 -1
- package/api/src/libs/currency.ts +2 -2
- package/api/src/libs/dayjs.ts +8 -2
- package/api/src/libs/did-connect/runtime-did-connect-js.ts +88 -0
- package/api/src/libs/did-connect/tenant-identity.ts +221 -0
- package/api/src/libs/drivers/auth-storage.ts +118 -0
- package/api/src/libs/drivers/cron.ts +264 -0
- package/api/src/libs/drivers/db.ts +170 -0
- package/api/src/libs/drivers/identity.ts +142 -0
- package/api/src/libs/drivers/index.ts +40 -0
- package/api/src/libs/drivers/locks.ts +226 -0
- package/api/src/libs/drivers/migrate-runner.ts +70 -0
- package/api/src/libs/drivers/queue.ts +104 -0
- package/api/src/libs/drivers/secrets.ts +194 -0
- package/api/src/libs/env.ts +170 -54
- package/api/src/libs/exchange-rate/service.ts +7 -6
- package/api/src/libs/http-fetch-adapter.ts +60 -0
- package/api/src/libs/invoice.ts +1 -1
- package/api/src/libs/lock.ts +51 -47
- package/api/src/libs/logger.ts +48 -8
- package/api/src/libs/notification/index.ts +1 -1
- package/api/src/libs/notification/template/customer-credit-low-balance.ts +2 -1
- package/api/src/libs/notification/template/customer-revenue-succeeded.ts +1 -1
- package/api/src/libs/notification/template/customer-reward-succeeded.ts +1 -1
- package/api/src/libs/overdraft-protection.ts +1 -1
- package/api/src/libs/payout.ts +1 -1
- package/api/src/libs/queue/index.ts +271 -52
- package/api/src/libs/queue/runtime.ts +175 -0
- package/api/src/libs/resource.ts +3 -3
- package/api/src/libs/secrets.ts +38 -0
- package/api/src/libs/session.ts +3 -2
- package/api/src/libs/subscription.ts +5 -5
- package/api/src/libs/tenant.ts +92 -0
- package/api/src/libs/url.ts +3 -3
- package/api/src/libs/util.ts +21 -13
- package/api/src/middlewares/hono/cdn.ts +63 -0
- package/api/src/middlewares/hono/context.ts +80 -0
- package/api/src/middlewares/hono/csrf.ts +83 -0
- package/api/src/middlewares/hono/fallback.ts +194 -0
- package/api/src/middlewares/hono/pipeline.ts +73 -0
- package/api/src/middlewares/hono/resource-mount.ts +42 -0
- package/api/src/middlewares/hono/resource.ts +63 -0
- package/api/src/middlewares/hono/security.ts +209 -0
- package/api/src/middlewares/hono/session.ts +114 -0
- package/api/src/middlewares/hono/xss.ts +61 -0
- package/api/src/queues/auto-recharge.ts +12 -10
- package/api/src/queues/checkout-session.ts +38 -21
- package/api/src/queues/credit-consume.ts +40 -36
- package/api/src/queues/credit-grant.ts +25 -18
- package/api/src/queues/credit-reconciliation.ts +7 -5
- package/api/src/queues/discount-status.ts +9 -6
- package/api/src/queues/event.ts +41 -11
- package/api/src/queues/exchange-rate-health.ts +49 -30
- package/api/src/queues/invoice.ts +18 -15
- package/api/src/queues/notification.ts +14 -7
- package/api/src/queues/payment.ts +64 -37
- package/api/src/queues/payout.ts +37 -21
- package/api/src/queues/refund.ts +36 -18
- package/api/src/queues/subscription.ts +83 -53
- package/api/src/queues/token-transfer.ts +15 -10
- package/api/src/queues/usage-record.ts +8 -5
- package/api/src/queues/vendors/commission.ts +7 -5
- package/api/src/queues/vendors/fulfillment-coordinator.ts +17 -13
- package/api/src/queues/vendors/fulfillment.ts +4 -2
- package/api/src/queues/vendors/return-processor.ts +5 -3
- package/api/src/queues/vendors/return-scanner.ts +5 -4
- package/api/src/queues/vendors/status-check.ts +10 -7
- package/api/src/queues/webhook.ts +60 -32
- package/api/src/routes/connect/shared.ts +1 -2
- package/api/src/routes/connect/subscribe.ts +3 -3
- package/api/src/routes/{archive.ts → hono/archive.ts} +69 -64
- package/api/src/routes/{auto-recharge-configs.ts → hono/auto-recharge-configs.ts} +39 -28
- package/api/src/routes/{checkout-sessions.ts → hono/checkout-sessions.ts} +790 -923
- package/api/src/routes/{coupons.ts → hono/coupons.ts} +93 -76
- package/api/src/routes/{credit-grants.ts → hono/credit-grants.ts} +140 -126
- package/api/src/routes/hono/credit-tokens.ts +43 -0
- package/api/src/routes/{credit-transactions.ts → hono/credit-transactions.ts} +37 -29
- package/api/src/routes/{customers.ts → hono/customers.ts} +199 -224
- package/api/src/routes/{donations.ts → hono/donations.ts} +41 -32
- package/api/src/routes/{entitlements.ts → hono/entitlements.ts} +28 -25
- package/api/src/routes/{events.ts → hono/events.ts} +107 -71
- package/api/src/routes/{exchange-rate-providers.ts → hono/exchange-rate-providers.ts} +138 -126
- package/api/src/routes/hono/exchange-rates.ts +77 -0
- package/api/src/routes/hono/index.ts +115 -0
- package/api/src/routes/{integrations → hono/integrations}/app-store.ts +68 -48
- package/api/src/routes/{integrations → hono/integrations}/google-play.ts +78 -58
- package/api/src/routes/hono/integrations/stripe.ts +74 -0
- package/api/src/routes/{invoices.ts → hono/invoices.ts} +253 -244
- package/api/src/routes/{meter-events.ts → hono/meter-events.ts} +120 -110
- package/api/src/routes/hono/meters.ts +288 -0
- package/api/src/routes/hono/passports.ts +73 -0
- package/api/src/routes/{payment-currencies.ts → hono/payment-currencies.ts} +219 -197
- package/api/src/routes/{payment-intents.ts → hono/payment-intents.ts} +136 -132
- package/api/src/routes/{payment-links.ts → hono/payment-links.ts} +145 -128
- package/api/src/routes/{payment-methods.ts → hono/payment-methods.ts} +125 -93
- package/api/src/routes/{payment-stats.ts → hono/payment-stats.ts} +30 -25
- package/api/src/routes/{payouts.ts → hono/payouts.ts} +55 -47
- package/api/src/routes/{prices.ts → hono/prices.ts} +265 -242
- package/api/src/routes/{pricing-table.ts → hono/pricing-table.ts} +94 -87
- package/api/src/routes/{products.ts → hono/products.ts} +172 -159
- package/api/src/routes/{promotion-codes.ts → hono/promotion-codes.ts} +207 -185
- package/api/src/routes/hono/redirect.ts +24 -0
- package/api/src/routes/{refunds.ts → hono/refunds.ts} +98 -83
- package/api/src/routes/{settings.ts → hono/settings.ts} +64 -55
- package/api/src/routes/{subscription-items.ts → hono/subscription-items.ts} +64 -57
- package/api/src/routes/{subscriptions.ts → hono/subscriptions.ts} +475 -528
- package/api/src/routes/{tax-rates.ts → hono/tax-rates.ts} +71 -70
- package/api/src/routes/hono/tool.ts +69 -0
- package/api/src/routes/{usage-records.ts → hono/usage-records.ts} +47 -42
- package/api/src/routes/{vendor.ts → hono/vendor.ts} +315 -167
- package/api/src/routes/{webhook-attempts.ts → hono/webhook-attempts.ts} +17 -13
- package/api/src/routes/hono/webhook-endpoints.ts +126 -0
- package/api/src/service.ts +814 -0
- package/api/src/store/migrations/20230911-seeding.ts +2 -1
- package/api/src/store/migrations/20260609-remove-did-space-jobs.ts +23 -0
- package/api/src/store/migrations/20260610-tenant-columns.ts +40 -0
- package/api/src/store/migrations/20260611-tenant-backfill.ts +33 -0
- package/api/src/store/models/auto-recharge-config.ts +22 -10
- package/api/src/store/models/checkout-session.ts +15 -14
- package/api/src/store/models/coupon.ts +29 -20
- package/api/src/store/models/credit-grant.ts +38 -29
- package/api/src/store/models/credit-transaction.ts +32 -21
- package/api/src/store/models/customer.ts +19 -17
- package/api/src/store/models/discount.ts +11 -2
- package/api/src/store/models/entitlement-grant.ts +21 -9
- package/api/src/store/models/entitlement-product.ts +21 -9
- package/api/src/store/models/entitlement.ts +19 -10
- package/api/src/store/models/event.ts +18 -9
- package/api/src/store/models/exchange-rate-provider.ts +17 -4
- package/api/src/store/models/invoice-item.ts +18 -9
- package/api/src/store/models/invoice.ts +16 -8
- package/api/src/store/models/meter-event.ts +27 -9
- package/api/src/store/models/meter.ts +31 -22
- package/api/src/store/models/payment-currency.ts +25 -8
- package/api/src/store/models/payment-intent.ts +15 -6
- package/api/src/store/models/payment-link.ts +15 -6
- package/api/src/store/models/payment-method.ts +38 -22
- package/api/src/store/models/payment-stat.ts +18 -9
- package/api/src/store/models/payout.ts +15 -6
- package/api/src/store/models/price-quote.ts +17 -8
- package/api/src/store/models/price.ts +24 -12
- package/api/src/store/models/pricing-table.ts +29 -20
- package/api/src/store/models/product-vendor.ts +20 -10
- package/api/src/store/models/product.ts +15 -6
- package/api/src/store/models/promotion-code.ts +14 -6
- package/api/src/store/models/refund.ts +15 -6
- package/api/src/store/models/revenue-snapshot.ts +21 -9
- package/api/src/store/models/setting.ts +18 -9
- package/api/src/store/models/setup-intent.ts +36 -27
- package/api/src/store/models/subscription-item.ts +21 -9
- package/api/src/store/models/subscription-schedule.ts +21 -9
- package/api/src/store/models/subscription.ts +21 -10
- package/api/src/store/models/tax-rate.ts +29 -21
- package/api/src/store/models/usage-record.ts +11 -2
- package/api/src/store/models/webhook-attempt.ts +18 -9
- package/api/src/store/models/webhook-endpoint.ts +18 -9
- package/api/src/store/scoped-core.ts +55 -0
- package/api/src/store/scoped.ts +247 -0
- package/api/src/store/sequelize.ts +82 -23
- package/api/src/store/sql-migrations.ts +20 -0
- package/api/src/store/tenant-backfill.ts +260 -0
- package/api/src/store/tenant-model.ts +124 -0
- package/api/src/store/tenant-tables.ts +50 -0
- package/api/tests/bootstrap/bootstrap.spec.ts +162 -0
- package/api/tests/crons/tenant-fanout.spec.ts +158 -0
- package/api/tests/embedded/embedded-multi-mode-d3.spec.ts +257 -0
- package/api/tests/fixtures/bare-query-violation.ts +13 -0
- package/api/tests/fixtures/core-env-violation.ts +10 -0
- package/api/tests/fixtures/host-read-violation.ts +19 -0
- package/api/tests/fixtures/tenants.ts +4 -0
- package/api/tests/integrations/iap-tenant.spec.ts +284 -0
- package/api/tests/libs/archive-query.spec.ts +26 -0
- package/api/tests/libs/audit-tenant.spec.ts +153 -0
- package/api/tests/libs/context.spec.ts +204 -0
- package/api/tests/libs/core-config.spec.ts +115 -0
- package/api/tests/libs/cron-driver-d2.spec.ts +237 -0
- package/api/tests/libs/crons-conservation-d2.spec.ts +52 -0
- package/api/tests/libs/did-connect-runtime-js.spec.ts +98 -0
- package/api/tests/libs/did-connect-tenant-identity.spec.ts +159 -0
- package/api/tests/libs/lock-tenant.spec.ts +66 -0
- package/api/tests/libs/scoped.spec.ts +222 -0
- package/api/tests/libs/secrets-facade.spec.ts +52 -0
- package/api/tests/libs/service-host.spec.ts +37 -0
- package/api/tests/libs/tenancy-slot-authority.spec.ts +209 -0
- package/api/tests/libs/tenant-middleware.spec.ts +42 -0
- package/api/tests/libs/tenant-scanner.spec.ts +120 -0
- package/api/tests/middlewares/hono/cdn.spec.ts +70 -0
- package/api/tests/middlewares/hono/context.spec.ts +113 -0
- package/api/tests/middlewares/hono/csrf.spec.ts +136 -0
- package/api/tests/middlewares/hono/fallback.spec.ts +67 -0
- package/api/tests/middlewares/hono/pipeline.spec.ts +47 -0
- package/api/tests/middlewares/hono/security.spec.ts +181 -0
- package/api/tests/middlewares/hono/session.spec.ts +42 -0
- package/api/tests/middlewares/hono/xss.spec.ts +81 -0
- package/api/tests/models/tenant-backfill.spec.ts +287 -0
- package/api/tests/models/tenant-columns-model.spec.ts +46 -0
- package/api/tests/models/tenant-columns.spec.ts +161 -0
- package/api/tests/queues/credit-consume-batch.spec.ts +8 -1
- package/api/tests/queues/credit-consume.spec.ts +8 -1
- package/api/tests/queues/event-tenant.spec.ts +292 -0
- package/api/tests/queues/exchange-rate-health-tenant-d6.spec.ts +62 -0
- package/api/tests/queues/queue-parity.spec.ts +249 -0
- package/api/tests/queues/queue-runtime-surface.spec.ts +277 -0
- package/api/tests/queues/queue-teardown-d2.spec.ts +127 -0
- package/api/tests/queues/tenant-matrix-a.spec.ts +245 -0
- package/api/tests/queues/tenant-matrix-b.spec.ts +168 -0
- package/api/tests/routes/connect/hono-attach.spec.ts +107 -0
- package/api/tests/service/collapse.spec.ts +96 -0
- package/api/tests/service/didconnect-storage-slot.spec.ts +60 -0
- package/api/tests/service/fail-closed-http.spec.ts +79 -0
- package/api/tests/service/static-arc-handler.spec.ts +101 -0
- package/api/tests/service/static-externalized.spec.ts +48 -0
- package/api/tests/store/tenant-crosscut.spec.ts +202 -0
- package/api/tests/store/tenant-model-spike.spec.ts +177 -0
- package/api/tests/store/tenant-model.spec.ts +162 -0
- package/api/tests/store/tenant-residual.spec.ts +196 -0
- package/api/third.d.ts +4 -0
- package/blocklet.yml +1 -1
- package/cloudflare/MIGRATION-RUNBOOK.md +3 -8
- package/cloudflare/README.md +34 -27
- package/cloudflare/STAGING-MIGRATION-GUIDE.md +3 -15
- package/cloudflare/build.ts +33 -13
- package/cloudflare/cf-adapter.ts +419 -0
- package/cloudflare/did-connect-runtime.ts +96 -0
- package/cloudflare/did-connect-token-storage.ts +151 -0
- package/cloudflare/esbuild-cf-config.cjs +407 -0
- package/cloudflare/migrations/0006_tenant_columns.sql +46 -0
- package/cloudflare/migrations/0007_tenant_backfill_indexes.sql +65 -0
- package/cloudflare/migrations/0008_schema_parity.sql +16 -0
- package/cloudflare/migrations/0009_remove_did_space_jobs.sql +5 -0
- package/cloudflare/queue-runtime-mode.ts +13 -0
- package/cloudflare/run-build.js +33 -403
- package/cloudflare/scripts/cf-package-import-probe.mjs +90 -0
- package/cloudflare/scripts/didconnect-mock-smoke.mjs +140 -0
- package/cloudflare/shims/blocklet-sdk/asset-host-transformer.ts +20 -0
- package/cloudflare/shims/blocklet-sdk/config.ts +8 -1
- package/cloudflare/shims/blocklet-sdk/login.ts +12 -0
- package/cloudflare/shims/blocklet-sdk/service-api.ts +14 -0
- package/cloudflare/shims/blocklet-sdk/session.ts +4 -2
- package/cloudflare/shims/blocklet-sdk/util-constants.ts +8 -0
- package/cloudflare/shims/blocklet-sdk/wallet-authenticator.ts +16 -1
- package/cloudflare/shims/blocklet-sdk/wallet-handler.ts +18 -3
- package/cloudflare/shims/cron.ts +38 -158
- package/cloudflare/shims/events.ts +124 -0
- package/cloudflare/shims/fastq.ts +15 -1
- package/cloudflare/shims/nedb-storage.ts +16 -8
- package/cloudflare/shims/xss.ts +8 -0
- package/cloudflare/tenant-middleware.ts +36 -0
- package/cloudflare/tests/cf-adapter.spec.ts +244 -0
- package/cloudflare/tests/did-connect-token-storage.spec.ts +105 -0
- package/cloudflare/tests/tenant-middleware.spec.ts +160 -0
- package/cloudflare/tests/worker-handler-gate.spec.ts +69 -0
- package/cloudflare/vite.config.ts +53 -45
- package/cloudflare/worker.ts +261 -448
- package/cloudflare/wrangler.json +0 -6
- package/cloudflare/wrangler.jsonc +0 -6
- package/cloudflare/wrangler.local-e2e.jsonc +25 -0
- package/cloudflare/wrangler.staging.json +0 -6
- package/jest.config.js +3 -1
- package/package.json +33 -38
- package/scripts/bootstrap-inject.ts +166 -0
- package/scripts/core-env-whitelist.json +1 -0
- package/scripts/e2e-12b-runtime.ts +149 -0
- package/scripts/e2e-core-config.ts +125 -0
- package/scripts/e2e-d1-tenancy.ts +116 -0
- package/scripts/e2e-d2-cron-queue.ts +139 -0
- package/scripts/e2e-d3-embedded-multi.ts +171 -0
- package/scripts/e2e-hono-s2.ts +125 -0
- package/scripts/e2e-hono-s3e.ts +135 -0
- package/scripts/e2e-hono-s4.ts +114 -0
- package/scripts/e2e-migration-contract.ts +100 -0
- package/scripts/e2e-s0.ts +61 -0
- package/scripts/e2e-s1.ts +107 -0
- package/scripts/e2e-s2.ts +178 -0
- package/scripts/e2e-s3.ts +110 -0
- package/scripts/e2e-s4.ts +191 -0
- package/scripts/e2e-s5.ts +139 -0
- package/scripts/e2e-s6.ts +127 -0
- package/scripts/e2e-tenant-model.ts +119 -0
- package/scripts/e2e-tenant-worker.ts +199 -0
- package/scripts/gen-sql-migrations.js +46 -0
- package/scripts/phase8-codemod.js +219 -0
- package/scripts/phase9a-env-getters-codemod.js +82 -0
- package/scripts/scan-core-env.js +109 -0
- package/scripts/scan-tenant-queries.js +235 -0
- package/scripts/schema-drift-guard.ts +210 -0
- package/scripts/tenant-scan-whitelist.json +1 -0
- package/src/app.tsx +2 -1
- package/src/env.d.ts +13 -1
- package/src/libs/service-host.ts +13 -0
- package/tsconfig.json +1 -1
- package/vite.arc.config.ts +159 -0
- package/api/src/libs/did-space.ts +0 -235
- package/api/src/libs/middleware.ts +0 -50
- package/api/src/libs/security.ts +0 -192
- package/api/src/queues/space.ts +0 -662
- package/api/src/routes/credit-tokens.ts +0 -38
- package/api/src/routes/exchange-rates.ts +0 -87
- package/api/src/routes/index.ts +0 -142
- package/api/src/routes/integrations/stripe.ts +0 -61
- package/api/src/routes/meters.ts +0 -274
- package/api/src/routes/passports.ts +0 -68
- package/api/src/routes/redirect.ts +0 -20
- package/api/src/routes/tool.ts +0 -65
- package/api/src/routes/webhook-endpoints.ts +0 -126
- package/api/tests/routes/credit-grants.spec.ts +0 -1261
- package/cloudflare/did-connect-auth.ts +0 -527
- package/cloudflare/shims/did-space-js.ts +0 -17
- package/cloudflare/shims/did-space.ts +0 -11
- package/cloudflare/shims/express-compat/index.ts +0 -80
- package/cloudflare/shims/express-compat/types.ts +0 -41
- package/cloudflare/shims/lock.ts +0 -115
- package/cloudflare/shims/queue.ts +0 -611
- package/cloudflare/tests/shims/queue-delayed-persist.spec.ts +0 -87
- package/cloudflare/tests/shims/queue-scheduled.spec.ts +0 -186
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// Phase 9 (W2-1b): queue engine + executor parity (W2 判据 4).
|
|
2
|
+
//
|
|
3
|
+
// The CF worker runs the SAME queue engine as Blocklet Server
|
|
4
|
+
// (api/src/libs/queue) — only the fastq EXECUTOR primitive is swapped (real
|
|
5
|
+
// fastq on Node, cloudflare/shims/fastq in the worker). This spec proves:
|
|
6
|
+
// Part A — the engine's delay/retry/cancel/pushAndWait semantics (embedded).
|
|
7
|
+
// Part B — the fastq shim is a faithful drop-in for real fastq on the exact
|
|
8
|
+
// operations the engine uses, so the worker behaves identically.
|
|
9
|
+
// Together that is the embedded↔worker queue parity the acceptance gate wants.
|
|
10
|
+
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import os from 'os';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import { Sequelize } from 'sequelize';
|
|
15
|
+
import { SequelizeStorage, Umzug } from 'umzug';
|
|
16
|
+
|
|
17
|
+
import { withTenant, getInstanceDid } from '../../src/libs/context';
|
|
18
|
+
|
|
19
|
+
/* eslint-disable global-require, import/no-dynamic-require, require-await, no-promise-executor-return */
|
|
20
|
+
|
|
21
|
+
// the retry/settle cases do real per-attempt DB ops on a shared sqlite file;
|
|
22
|
+
// under full-suite parallel load they can exceed jest's 5s default, so give the
|
|
23
|
+
// suite a generous timeout (these are inherently I/O-bound, not hung). Phase 12b
|
|
24
|
+
// added queue-runtime-surface.spec.ts which contends for the same jest workers,
|
|
25
|
+
// so the margin is bumped to keep the settle cases from a transient timeout.
|
|
26
|
+
jest.setTimeout(60000);
|
|
27
|
+
|
|
28
|
+
jest.mock('../../src/libs/logger', () => ({
|
|
29
|
+
__esModule: true,
|
|
30
|
+
default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
const STORE_DIR = path.join(__dirname, '../../src/store');
|
|
34
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'queue-parity-'));
|
|
35
|
+
const sequelize = new Sequelize({ dialect: 'sqlite', storage: path.join(dir, 'test.db'), logging: false });
|
|
36
|
+
const umzug = new Umzug({
|
|
37
|
+
migrations: {
|
|
38
|
+
glob: ['migrations/*.ts', { cwd: STORE_DIR }],
|
|
39
|
+
resolve: ({ name, path: p, context }) => {
|
|
40
|
+
const migration = require(p!);
|
|
41
|
+
return {
|
|
42
|
+
name: name.replace(/\.ts$/, '.js'),
|
|
43
|
+
up: () => migration.up({ context }),
|
|
44
|
+
down: () => migration.down({ context }),
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
context: sequelize.getQueryInterface(),
|
|
49
|
+
storage: new SequelizeStorage({ sequelize }),
|
|
50
|
+
logger: undefined,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
let createQueue: any;
|
|
54
|
+
|
|
55
|
+
beforeAll(async () => {
|
|
56
|
+
await umzug.up();
|
|
57
|
+
const models = require('../../src/store/models');
|
|
58
|
+
models.initialize(sequelize);
|
|
59
|
+
createQueue = require('../../src/libs/queue').default;
|
|
60
|
+
}, 120000);
|
|
61
|
+
|
|
62
|
+
afterAll(async () => {
|
|
63
|
+
await sequelize.close();
|
|
64
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const settle = (emitter: any): Promise<{ event: string; data: any }> =>
|
|
68
|
+
new Promise((resolve) => {
|
|
69
|
+
['finished', 'failed', 'cancelled'].forEach((e) => emitter.on(e, (data: any) => resolve({ event: e, data })));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('Part A — Node queue engine contract semantics', () => {
|
|
73
|
+
beforeEach(async () => {
|
|
74
|
+
await sequelize.query('DELETE FROM jobs');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('immediate job runs and the jobs row is cleared on finish', async () => {
|
|
78
|
+
const seen: any[] = [];
|
|
79
|
+
const q = createQueue({
|
|
80
|
+
name: `pa-imm-${Date.now()}`,
|
|
81
|
+
onJob: async (job: any) => {
|
|
82
|
+
seen.push(job.v);
|
|
83
|
+
return job.v * 2;
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
const ev = q.push({ job: { v: 21 } });
|
|
87
|
+
const { event, data } = await settle(ev);
|
|
88
|
+
expect(event).toBe('finished');
|
|
89
|
+
expect(data.result).toBe(42);
|
|
90
|
+
expect(seen).toEqual([21]);
|
|
91
|
+
const row = await q.get(ev.id);
|
|
92
|
+
expect(row).toBeNull(); // cleared
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('pushAndWait resolves with the handler result', async () => {
|
|
96
|
+
const q = createQueue({ name: `pa-paw-${Date.now()}`, onJob: async (job: any) => `ok:${job.v}` });
|
|
97
|
+
const res: any = await q.pushAndWait({ job: { v: 7 } });
|
|
98
|
+
expect(res.result).toBe('ok:7');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('retries up to maxRetries then fails (no infinite retry)', async () => {
|
|
102
|
+
let attempts = 0;
|
|
103
|
+
const retries: number[] = [];
|
|
104
|
+
const q = createQueue({
|
|
105
|
+
name: `pa-retry-${Date.now()}`,
|
|
106
|
+
onJob: async () => {
|
|
107
|
+
attempts += 1;
|
|
108
|
+
throw new Error('always');
|
|
109
|
+
},
|
|
110
|
+
options: { maxRetries: 3, retryDelay: 1 },
|
|
111
|
+
});
|
|
112
|
+
q.on('retry', () => retries.push(1));
|
|
113
|
+
const ev = q.push({ job: { v: 1 } });
|
|
114
|
+
const { event } = await settle(ev);
|
|
115
|
+
expect(event).toBe('failed');
|
|
116
|
+
expect(attempts).toBe(3); // initial + 2 retries (retry_count starts at 1, fails at >= maxRetries)
|
|
117
|
+
expect(retries.length).toBeGreaterThanOrEqual(1);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('nonRetryable error fails immediately (single attempt)', async () => {
|
|
121
|
+
let attempts = 0;
|
|
122
|
+
const q = createQueue({
|
|
123
|
+
name: `pa-nonretry-${Date.now()}`,
|
|
124
|
+
onJob: async () => {
|
|
125
|
+
attempts += 1;
|
|
126
|
+
const err: any = new Error('forged');
|
|
127
|
+
err.nonRetryable = true;
|
|
128
|
+
throw err;
|
|
129
|
+
},
|
|
130
|
+
options: { maxRetries: 5, retryDelay: 1 },
|
|
131
|
+
});
|
|
132
|
+
const ev = q.push({ job: { v: 1 } });
|
|
133
|
+
const { event } = await settle(ev);
|
|
134
|
+
expect(event).toBe('failed');
|
|
135
|
+
expect(attempts).toBe(1);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('cancel marks the row and excludes it from recovery (the skip mechanism)', async () => {
|
|
139
|
+
const q = createQueue({ name: `pa-cancel-${Date.now()}`, onJob: async () => undefined });
|
|
140
|
+
await q.store.addJob('cj-1', { v: 1, instance_did: 'did:abt:zCANCEL' }, {});
|
|
141
|
+
// before cancel: recovery would pick the row up
|
|
142
|
+
expect((await q.store.getJobs()).some((j: any) => j.id === 'cj-1')).toBe(true);
|
|
143
|
+
await q.cancel('cj-1');
|
|
144
|
+
// after cancel: execution-time guard sees it, recovery query excludes it
|
|
145
|
+
expect(await q.store.isCancelled('cj-1')).toBe(true);
|
|
146
|
+
expect((await q.store.getJobs()).some((j: any) => j.id === 'cj-1')).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Bad input
|
|
150
|
+
it('rejects an empty job', () => {
|
|
151
|
+
const q = createQueue({ name: `pa-empty-${Date.now()}`, onJob: async () => undefined });
|
|
152
|
+
expect(() => q.push({ job: undefined as any })).toThrow(/Can not queue empty job/);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Data damage — same job id does not execute twice (duplicate guard)
|
|
156
|
+
it('a duplicate job id executes only once', async () => {
|
|
157
|
+
let runs = 0;
|
|
158
|
+
const q = createQueue({
|
|
159
|
+
name: `pa-dup-${Date.now()}`,
|
|
160
|
+
onJob: async () => {
|
|
161
|
+
runs += 1;
|
|
162
|
+
return runs;
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
q.push({ job: { v: 1 }, id: 'dup-1' });
|
|
166
|
+
q.push({ job: { v: 1 }, id: 'dup-1' }); // duplicate id — store rejects, no second execution
|
|
167
|
+
// Either push can win the concurrent addJob UNIQUE race; the LOSER's
|
|
168
|
+
// jobEvents never settles (its addJob rejects before queueJob), so awaiting
|
|
169
|
+
// one specific push is racy and starves under parallel load. Wait for the
|
|
170
|
+
// single execution to actually land instead — that is what we assert.
|
|
171
|
+
const deadline = Date.now() + 5000;
|
|
172
|
+
while (runs < 1 && Date.now() < deadline) {
|
|
173
|
+
// eslint-disable-next-line no-await-in-loop, no-promise-executor-return
|
|
174
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
175
|
+
}
|
|
176
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
177
|
+
expect(runs).toBe(1);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Data leak — the handler runs under the PAYLOAD tenant, never another's
|
|
181
|
+
it('handler executes under the payload tenant (no cross-tenant leak)', async () => {
|
|
182
|
+
const seen: Record<string, string> = {};
|
|
183
|
+
const q = createQueue({
|
|
184
|
+
name: `pa-tenant-${Date.now()}`,
|
|
185
|
+
onJob: async (job: any) => {
|
|
186
|
+
seen[job.tag] = getInstanceDid();
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
// push under tenant A's context — payload is stamped with A
|
|
190
|
+
const evA = await withTenant('did:abt:zTENANTA', async () => q.push({ job: { tag: 'A' } }));
|
|
191
|
+
const evB = await withTenant('did:abt:zTENANTB', async () => q.push({ job: { tag: 'B' } }));
|
|
192
|
+
await Promise.all([settle(evA), settle(evB)]);
|
|
193
|
+
expect(seen.A).toBe('did:abt:zTENANTA');
|
|
194
|
+
expect(seen.B).toBe('did:abt:zTENANTB');
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('Part B — fastq shim is a faithful drop-in for real fastq', () => {
|
|
199
|
+
// run the exact operations the engine uses against both executors
|
|
200
|
+
const realFastq = require('fastq');
|
|
201
|
+
const shimFastq = require('../../../cloudflare/shims/fastq').default;
|
|
202
|
+
|
|
203
|
+
const scenario = (fastqImpl: any) =>
|
|
204
|
+
new Promise<any>((resolve) => {
|
|
205
|
+
const order: string[] = [];
|
|
206
|
+
const results: any[] = [];
|
|
207
|
+
const q = fastqImpl(async (data: any, cb: Function) => {
|
|
208
|
+
order.push(data.id);
|
|
209
|
+
if (data.fail) {
|
|
210
|
+
cb(new Error(`fail:${data.id}`));
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
cb(null, `done:${data.id}`);
|
|
214
|
+
}, 1);
|
|
215
|
+
let pending = 3;
|
|
216
|
+
const done = (tag: string) => (err: any, res: any) => {
|
|
217
|
+
results.push({ tag, err: err?.message ?? null, res: res ?? null });
|
|
218
|
+
pending -= 1;
|
|
219
|
+
if (pending === 0) resolve({ order, results });
|
|
220
|
+
};
|
|
221
|
+
q.push({ id: 'a' }, done('a'));
|
|
222
|
+
q.push({ id: 'b', fail: true }, done('b'));
|
|
223
|
+
q.unshift({ id: 'c' }, done('c')); // unshift jumps the queue (retry path)
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('produces identical execution order and results on both executors', async () => {
|
|
227
|
+
const real = await scenario(realFastq);
|
|
228
|
+
const shim = await scenario(shimFastq);
|
|
229
|
+
// same success/error callback shape per job
|
|
230
|
+
const norm = (r: any) => r.results.sort((x: any, y: any) => x.tag.localeCompare(y.tag));
|
|
231
|
+
expect(norm(shim)).toEqual(norm(real));
|
|
232
|
+
// job 'b' errored on both, 'a'/'c' succeeded on both
|
|
233
|
+
const byTag = (r: any) => Object.fromEntries(r.results.map((x: any) => [x.tag, x.err ? 'err' : 'ok']));
|
|
234
|
+
expect(byTag(shim)).toEqual(byTag(real));
|
|
235
|
+
expect(byTag(real)).toEqual({ a: 'ok', b: 'err', c: 'ok' });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('the shim accepts the 2-arg fastq(worker, concurrency) form the engine uses', async () => {
|
|
239
|
+
// regression for the pre-existing "worker is not a function" bug
|
|
240
|
+
const ran: any[] = [];
|
|
241
|
+
const q = shimFastq(async (data: any, cb: Function) => {
|
|
242
|
+
ran.push(data.id);
|
|
243
|
+
cb(null, 'ok');
|
|
244
|
+
}, 1);
|
|
245
|
+
const res = await new Promise((r) => q.push({ id: 'x' }, (_e: any, v: any) => r(v)));
|
|
246
|
+
expect(ran).toEqual(['x']);
|
|
247
|
+
expect(res).toBe('ok');
|
|
248
|
+
});
|
|
249
|
+
});
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// Phase 12b (W2′): core queue RUNTIME surface parity probes.
|
|
2
|
+
//
|
|
3
|
+
// These lock the contract the CF worker now drives through the service/slot
|
|
4
|
+
// boundary instead of cloudflare/shims/queue.ts:
|
|
5
|
+
// - the queue REGISTRY is non-empty under the canonical (node-engine) build
|
|
6
|
+
// - a due delayed job dispatches + executes EXACTLY once via dispatchDueJobs()
|
|
7
|
+
// - a cancelled delayed job is NOT executed by dispatchDueJobs() (no
|
|
8
|
+
// half-execution residue — the spec's mandatory negative case)
|
|
9
|
+
// - retry / nonRetryable / maxRetries match the node engine
|
|
10
|
+
// - the handler runs under the PAYLOAD tenant; a cross-tenant object is
|
|
11
|
+
// fail-closed
|
|
12
|
+
// - in 'workerd' mode the background loop() is disabled (a frozen isolate
|
|
13
|
+
// must not run a timer) and an immediate push is not lost: flushQueueWork()
|
|
14
|
+
// drains it before the (simulated) response
|
|
15
|
+
// - the queue() consumer can resolve a core handler by name (no undefined ack)
|
|
16
|
+
//
|
|
17
|
+
// These supersede the deleted cloudflare/tests/shims/queue-{scheduled,delayed-
|
|
18
|
+
// persist}.spec.ts: those probed the removed cloudflare/shims/queue.ts duplicate
|
|
19
|
+
// engine (D1 + CF-Queue-send + inline-fallback). Under the canonical node engine
|
|
20
|
+
// the worker never sends to CF Queue (immediate jobs run in-isolate via fastq),
|
|
21
|
+
// so those CF-Queue-fallback scenarios no longer exist; due-delayed dispatch is
|
|
22
|
+
// covered here by dispatchDueJobs().
|
|
23
|
+
|
|
24
|
+
import fs from 'fs';
|
|
25
|
+
import os from 'os';
|
|
26
|
+
import path from 'path';
|
|
27
|
+
import { Sequelize } from 'sequelize';
|
|
28
|
+
import { SequelizeStorage, Umzug } from 'umzug';
|
|
29
|
+
|
|
30
|
+
import { withTenant, getInstanceDid } from '../../src/libs/context';
|
|
31
|
+
import {
|
|
32
|
+
setQueueRuntimeMode,
|
|
33
|
+
getQueueHandler,
|
|
34
|
+
getAllQueueNames,
|
|
35
|
+
dispatchDueJobs,
|
|
36
|
+
flushQueueWork,
|
|
37
|
+
__test__ as runtimeTest,
|
|
38
|
+
} from '../../src/libs/queue/runtime';
|
|
39
|
+
|
|
40
|
+
/* eslint-disable global-require, require-await, no-promise-executor-return */
|
|
41
|
+
|
|
42
|
+
// delayed/retry cases do real per-attempt DB ops on a shared sqlite file; give
|
|
43
|
+
// the suite a generous timeout (I/O-bound, not hung).
|
|
44
|
+
jest.setTimeout(30000);
|
|
45
|
+
|
|
46
|
+
jest.mock('../../src/libs/logger', () => ({
|
|
47
|
+
__esModule: true,
|
|
48
|
+
default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
const STORE_DIR = path.join(__dirname, '../../src/store');
|
|
52
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'queue-runtime-'));
|
|
53
|
+
const sequelize = new Sequelize({ dialect: 'sqlite', storage: path.join(dir, 'test.db'), logging: false });
|
|
54
|
+
const umzug = new Umzug({
|
|
55
|
+
migrations: {
|
|
56
|
+
glob: ['migrations/*.ts', { cwd: STORE_DIR }],
|
|
57
|
+
resolve: ({ name, path: p, context }) => {
|
|
58
|
+
const migration = require(p!);
|
|
59
|
+
return {
|
|
60
|
+
name: name.replace(/\.ts$/, '.js'),
|
|
61
|
+
up: () => migration.up({ context }),
|
|
62
|
+
down: () => migration.down({ context }),
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
context: sequelize.getQueryInterface(),
|
|
67
|
+
storage: new SequelizeStorage({ sequelize }),
|
|
68
|
+
logger: undefined,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
let createQueue: any;
|
|
72
|
+
const TENANT_A = 'did:abt:zRUNTIMEA';
|
|
73
|
+
const TENANT_B = 'did:abt:zRUNTIMEB';
|
|
74
|
+
|
|
75
|
+
const settle = (emitter: any): Promise<{ event: string; data: any }> =>
|
|
76
|
+
new Promise((resolve) => {
|
|
77
|
+
['finished', 'failed', 'cancelled'].forEach((e) => emitter.on(e, (data: any) => resolve({ event: e, data })));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
beforeAll(async () => {
|
|
81
|
+
await umzug.up();
|
|
82
|
+
const models = require('../../src/store/models');
|
|
83
|
+
models.initialize(sequelize);
|
|
84
|
+
createQueue = require('../../src/libs/queue').default;
|
|
85
|
+
}, 120000);
|
|
86
|
+
|
|
87
|
+
afterAll(async () => {
|
|
88
|
+
await sequelize.close();
|
|
89
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
beforeEach(async () => {
|
|
93
|
+
await sequelize.query('DELETE FROM jobs');
|
|
94
|
+
setQueueRuntimeMode('node');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
afterEach(() => {
|
|
98
|
+
// clears the registry + restores 'node' so each test is self-contained
|
|
99
|
+
runtimeTest.reset();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('registry + consumer lookup', () => {
|
|
103
|
+
it('createQueue registers the handle into the runtime registry (non-empty)', () => {
|
|
104
|
+
const name = `reg-${Date.now()}`;
|
|
105
|
+
const q = createQueue({ name, onJob: async () => undefined });
|
|
106
|
+
expect(getAllQueueNames()).toContain(name);
|
|
107
|
+
expect(getQueueHandler(name)).toBe(q);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('the queue() consumer resolves a core handler by name and runs onJob once (no undefined ack)', async () => {
|
|
111
|
+
let runs = 0;
|
|
112
|
+
const name = `consume-${Date.now()}`;
|
|
113
|
+
createQueue({ name, onJob: async () => { runs += 1; return 'ok'; } });
|
|
114
|
+
|
|
115
|
+
const handle = getQueueHandler(name);
|
|
116
|
+
expect(handle).toBeTruthy();
|
|
117
|
+
const res: any = await withTenant(TENANT_A, async () =>
|
|
118
|
+
handle.pushAndWait({ job: { v: 1, instance_did: TENANT_A } })
|
|
119
|
+
);
|
|
120
|
+
expect(res.result).toBe('ok');
|
|
121
|
+
expect(runs).toBe(1);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('scheduled dispatch (delay) parity — host-driven dispatchDueJobs()', () => {
|
|
126
|
+
it("'workerd' mode disables the background loop (no auto-dispatch)", async () => {
|
|
127
|
+
setQueueRuntimeMode('workerd');
|
|
128
|
+
let ran = 0;
|
|
129
|
+
const name = `wd-loop-${Date.now()}`;
|
|
130
|
+
const q = createQueue({ name, onJob: async () => { ran += 1; }, options: { enableScheduledJob: true } });
|
|
131
|
+
// a due delayed row sitting in D1
|
|
132
|
+
await q.store.addJob('wd-1', { v: 1, instance_did: TENANT_A }, { delay: 5, will_run_at: Date.now() - 1000 });
|
|
133
|
+
// a node loop would have polled + run it by now; workerd must not
|
|
134
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
135
|
+
expect(ran).toBe(0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('a due delayed job dispatches and executes exactly once via dispatchDueJobs()', async () => {
|
|
139
|
+
setQueueRuntimeMode('workerd');
|
|
140
|
+
let ran = 0;
|
|
141
|
+
const seenTenant: string[] = [];
|
|
142
|
+
const name = `wd-due-${Date.now()}`;
|
|
143
|
+
const q = createQueue({
|
|
144
|
+
name,
|
|
145
|
+
onJob: async () => { ran += 1; seenTenant.push(getInstanceDid()); },
|
|
146
|
+
options: { enableScheduledJob: true },
|
|
147
|
+
});
|
|
148
|
+
await q.store.addJob('due-1', { v: 1, instance_did: TENANT_A }, { delay: 5, will_run_at: Date.now() - 1000 });
|
|
149
|
+
|
|
150
|
+
const r = await dispatchDueJobs();
|
|
151
|
+
await flushQueueWork(); // drain the re-dispatched immediate execution
|
|
152
|
+
|
|
153
|
+
expect(r.dispatched).toBe(1);
|
|
154
|
+
expect(ran).toBe(1);
|
|
155
|
+
expect(seenTenant).toEqual([TENANT_A]); // ran under the payload tenant
|
|
156
|
+
// row cleared after success
|
|
157
|
+
expect(await q.store.getJob('due-1')).toBeNull();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('a cancelled delayed job is NOT executed by dispatchDueJobs() (no half-execution residue)', async () => {
|
|
161
|
+
setQueueRuntimeMode('workerd');
|
|
162
|
+
let ran = 0;
|
|
163
|
+
const name = `wd-cancel-${Date.now()}`;
|
|
164
|
+
const q = createQueue({ name, onJob: async () => { ran += 1; }, options: { enableScheduledJob: true } });
|
|
165
|
+
await q.store.addJob('c-1', { v: 1, instance_did: TENANT_A }, { delay: 5, will_run_at: Date.now() - 1000 });
|
|
166
|
+
await q.cancel('c-1'); // marks cancelled=true BEFORE the due-dispatch tick
|
|
167
|
+
|
|
168
|
+
const r = await dispatchDueJobs();
|
|
169
|
+
await flushQueueWork();
|
|
170
|
+
|
|
171
|
+
expect(r.dispatched).toBe(0);
|
|
172
|
+
expect(ran).toBe(0);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('node entry: a cancelled delayed row is excluded from due dispatch (same filter both entries use)', async () => {
|
|
176
|
+
setQueueRuntimeMode('node');
|
|
177
|
+
// no enableScheduledJob → no background loop leaks; we assert the exact
|
|
178
|
+
// store filter (cancelled:false) that BOTH the node loop() and the workerd
|
|
179
|
+
// dispatchDueJobs() select due rows with, so cancel parity is structural.
|
|
180
|
+
const q = createQueue({ name: `node-cancel-${Date.now()}`, onJob: async () => undefined });
|
|
181
|
+
await q.store.addJob('nc-1', { v: 1, instance_did: TENANT_A }, { delay: 5, will_run_at: Date.now() - 1000 });
|
|
182
|
+
expect((await q.store.getScheduledJobs()).some((j: any) => j.id === 'nc-1')).toBe(true);
|
|
183
|
+
await q.cancel('nc-1');
|
|
184
|
+
expect((await q.store.getScheduledJobs()).some((j: any) => j.id === 'nc-1')).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('retry / nonRetryable parity (node engine semantics)', () => {
|
|
189
|
+
it('retries up to maxRetries then fails (initial + retries, no infinite loop)', async () => {
|
|
190
|
+
let attempts = 0;
|
|
191
|
+
const q = createQueue({
|
|
192
|
+
name: `rt-retry-${Date.now()}`,
|
|
193
|
+
onJob: async () => { attempts += 1; throw new Error('always'); },
|
|
194
|
+
options: { maxRetries: 3, retryDelay: 1 },
|
|
195
|
+
});
|
|
196
|
+
const ev = q.push({ job: { v: 1, instance_did: TENANT_A } });
|
|
197
|
+
const { event } = await settle(ev);
|
|
198
|
+
expect(event).toBe('failed');
|
|
199
|
+
expect(attempts).toBe(3);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('a nonRetryable error fails immediately (single attempt)', async () => {
|
|
203
|
+
let attempts = 0;
|
|
204
|
+
const q = createQueue({
|
|
205
|
+
name: `rt-nonretry-${Date.now()}`,
|
|
206
|
+
onJob: async () => {
|
|
207
|
+
attempts += 1;
|
|
208
|
+
const err: any = new Error('forged');
|
|
209
|
+
err.nonRetryable = true;
|
|
210
|
+
throw err;
|
|
211
|
+
},
|
|
212
|
+
options: { maxRetries: 5, retryDelay: 1 },
|
|
213
|
+
});
|
|
214
|
+
const ev = q.push({ job: { v: 1, instance_did: TENANT_A } });
|
|
215
|
+
const { event } = await settle(ev);
|
|
216
|
+
expect(event).toBe('failed');
|
|
217
|
+
expect(attempts).toBe(1);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('tenant safety', () => {
|
|
222
|
+
it('handler runs under the payload tenant; a cross-tenant object is fail-closed', async () => {
|
|
223
|
+
const { assertJobObjectTenant } = require('../../src/libs/queue');
|
|
224
|
+
let leaked = false;
|
|
225
|
+
let observedTenant = '';
|
|
226
|
+
const q = createQueue({
|
|
227
|
+
name: `tn-${Date.now()}`,
|
|
228
|
+
onJob: async (_job: any) => {
|
|
229
|
+
observedTenant = getInstanceDid();
|
|
230
|
+
// simulate an object loaded cross-tenant (payload says A, row says B)
|
|
231
|
+
assertJobObjectTenant({ instance_did: TENANT_B });
|
|
232
|
+
leaked = true; // must be unreachable — assert throws first
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
const ev = await withTenant(TENANT_A, async () => q.push({ job: { tag: 'x' } }));
|
|
236
|
+
const { event } = await settle(ev);
|
|
237
|
+
expect(observedTenant).toBe(TENANT_A);
|
|
238
|
+
expect(leaked).toBe(false);
|
|
239
|
+
expect(event).toBe('failed');
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe('workerd flush — immediate push not lost', () => {
|
|
244
|
+
it('flushQueueWork() drains an in-flight immediate push before the response', async () => {
|
|
245
|
+
setQueueRuntimeMode('workerd');
|
|
246
|
+
let ran = 0;
|
|
247
|
+
const q = createQueue({ name: `flush-${Date.now()}`, onJob: async () => { ran += 1; } });
|
|
248
|
+
// fire-and-forget immediate push (the worker does this inside a request)
|
|
249
|
+
q.push({ job: { v: 1, instance_did: TENANT_A } });
|
|
250
|
+
// simulate the host draining before the isolate freezes
|
|
251
|
+
await flushQueueWork();
|
|
252
|
+
expect(ran).toBe(1);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('flushQueueWork() is a no-op on node (nothing tracked, returns immediately)', async () => {
|
|
256
|
+
setQueueRuntimeMode('node');
|
|
257
|
+
await expect(flushQueueWork()).resolves.toBeUndefined();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('a duplicate immediate push does not hang flushQueueWork() (tracker released on addJob failure)', async () => {
|
|
261
|
+
setQueueRuntimeMode('workerd');
|
|
262
|
+
let runs = 0;
|
|
263
|
+
const q = createQueue({ name: `flush-dup-${Date.now()}`, onJob: async () => { runs += 1; } });
|
|
264
|
+
q.push({ job: { v: 1, instance_did: TENANT_A }, id: 'dup-flush' });
|
|
265
|
+
q.push({ job: { v: 1, instance_did: TENANT_A }, id: 'dup-flush' }); // duplicate → never enqueues
|
|
266
|
+
// must resolve, not hang on the never-settling duplicate
|
|
267
|
+
await flushQueueWork();
|
|
268
|
+
expect(runs).toBe(1);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe('test harness sanity', () => {
|
|
273
|
+
it('runtime __test__.reset clears the registry', () => {
|
|
274
|
+
createQueue({ name: `sanity-${Date.now()}`, onJob: async () => undefined });
|
|
275
|
+
expect(runtimeTest.registrySize()).toBeGreaterThan(0);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// D2 (S3.0) — the node queue poll loop is cancelable. On a Node host the
|
|
2
|
+
// per-scheduled-queue loop() polls due delayed rows on a timer; lifecycle.stop()
|
|
3
|
+
// calls stopAllQueues() so no poll timer survives a stop / ARC_PAYMENT toggle
|
|
4
|
+
// (the spec's "active handles 归零"). This proves: (1) the loop self-dispatches a
|
|
5
|
+
// due job on node, (2) after stopAllQueues() it no longer dispatches AND its
|
|
6
|
+
// sleep timer is gone.
|
|
7
|
+
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import os from 'os';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import { Sequelize } from 'sequelize';
|
|
12
|
+
import { SequelizeStorage, Umzug } from 'umzug';
|
|
13
|
+
|
|
14
|
+
import { withTenant } from '../../src/libs/context';
|
|
15
|
+
import { setQueueRuntimeMode, stopAllQueues, __test__ as runtimeTest } from '../../src/libs/queue/runtime';
|
|
16
|
+
|
|
17
|
+
jest.setTimeout(30000);
|
|
18
|
+
|
|
19
|
+
jest.mock('../../src/libs/logger', () => ({
|
|
20
|
+
__esModule: true,
|
|
21
|
+
default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
const STORE_DIR = path.join(__dirname, '../../src/store');
|
|
25
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'queue-teardown-'));
|
|
26
|
+
const sequelize = new Sequelize({ dialect: 'sqlite', storage: path.join(dir, 'test.db'), logging: false });
|
|
27
|
+
const umzug = new Umzug({
|
|
28
|
+
migrations: {
|
|
29
|
+
glob: ['migrations/*.ts', { cwd: STORE_DIR }],
|
|
30
|
+
resolve: ({ name, path: p, context }) => {
|
|
31
|
+
const migration = require(p!);
|
|
32
|
+
return {
|
|
33
|
+
name: name.replace(/\.ts$/, '.js'),
|
|
34
|
+
up: () => migration.up({ context }),
|
|
35
|
+
down: () => migration.down({ context }),
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
context: sequelize.getQueryInterface(),
|
|
40
|
+
storage: new SequelizeStorage({ sequelize }),
|
|
41
|
+
logger: undefined,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
let createQueue: any;
|
|
45
|
+
const TENANT = 'did:abt:zTEARDOWNA';
|
|
46
|
+
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
47
|
+
const countTimers = () =>
|
|
48
|
+
(process as any).getActiveResourcesInfo().filter((r: string) => r === 'Timeout').length;
|
|
49
|
+
|
|
50
|
+
beforeAll(async () => {
|
|
51
|
+
await umzug.up();
|
|
52
|
+
const models = require('../../src/store/models');
|
|
53
|
+
models.initialize(sequelize);
|
|
54
|
+
createQueue = require('../../src/libs/queue').default;
|
|
55
|
+
}, 120000);
|
|
56
|
+
|
|
57
|
+
afterAll(async () => {
|
|
58
|
+
await sequelize.close();
|
|
59
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
beforeEach(async () => {
|
|
63
|
+
await sequelize.query('DELETE FROM jobs');
|
|
64
|
+
setQueueRuntimeMode('node');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
stopAllQueues();
|
|
69
|
+
runtimeTest.reset();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('D2 — node queue loop self-dispatches a due delayed job (positive)', () => {
|
|
73
|
+
it('a due delayed row is picked up by the node loop with no host tick', async () => {
|
|
74
|
+
let ran = 0;
|
|
75
|
+
const name = `loop-pos-${Date.now()}`;
|
|
76
|
+
const q = createQueue({
|
|
77
|
+
name,
|
|
78
|
+
onJob: async () => {
|
|
79
|
+
ran += 1;
|
|
80
|
+
},
|
|
81
|
+
options: { enableScheduledJob: true },
|
|
82
|
+
});
|
|
83
|
+
await q.store.addJob('due-1', { v: 1, instance_did: TENANT }, { delay: 5, will_run_at: Date.now() - 1000 });
|
|
84
|
+
await wait(1600); // loop polls every minDelay/2 = 1s in test env
|
|
85
|
+
expect(ran).toBeGreaterThanOrEqual(1);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('D2 — stopAllQueues() stops the loop and clears its timer (teardown)', () => {
|
|
90
|
+
it('after stopAllQueues() a due delayed row is NOT auto-dispatched', async () => {
|
|
91
|
+
let ran = 0;
|
|
92
|
+
const name = `loop-stop-${Date.now()}`;
|
|
93
|
+
const q = createQueue({
|
|
94
|
+
name,
|
|
95
|
+
onJob: async () => {
|
|
96
|
+
ran += 1;
|
|
97
|
+
},
|
|
98
|
+
options: { enableScheduledJob: true },
|
|
99
|
+
});
|
|
100
|
+
const withLoop = countTimers();
|
|
101
|
+
stopAllQueues(); // teardown before the first tick fires
|
|
102
|
+
const afterStop = countTimers();
|
|
103
|
+
// the loop's pending sleep timer is gone
|
|
104
|
+
expect(afterStop).toBeLessThan(withLoop);
|
|
105
|
+
|
|
106
|
+
await q.store.addJob('due-2', { v: 1, instance_did: TENANT }, { delay: 5, will_run_at: Date.now() - 1000 });
|
|
107
|
+
await wait(1600);
|
|
108
|
+
expect(ran).toBe(0); // loop is dead — nothing dispatched it
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('the queue handle exposes a stop() function', () => {
|
|
112
|
+
const q = createQueue({
|
|
113
|
+
name: `has-stop-${Date.now()}`,
|
|
114
|
+
onJob: async () => {},
|
|
115
|
+
options: { enableScheduledJob: true },
|
|
116
|
+
});
|
|
117
|
+
expect(typeof q.stop).toBe('function');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('pushAndWait still works after a stop (immediate path unaffected by loop teardown)', async () => {
|
|
121
|
+
const name = `imm-${Date.now()}`;
|
|
122
|
+
const q = createQueue({ name, onJob: async () => 'ok', options: { enableScheduledJob: true } });
|
|
123
|
+
stopAllQueues();
|
|
124
|
+
const res: any = await withTenant(TENANT, async () => q.pushAndWait({ job: { v: 1, instance_did: TENANT } }));
|
|
125
|
+
expect(res.result).toBe('ok');
|
|
126
|
+
});
|
|
127
|
+
});
|