@fullstackhouse/open-mercato-durable-work 0.1.4 → 0.2.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/dist/index.js CHANGED
@@ -36,6 +36,7 @@ import { reconcileOnce } from "./core/reconciler.js";
36
36
  import { RECONCILE_QUEUE, RECONCILE_TICK_ID, enqueueJob, startWorker } from "./core/worker.js";
37
37
  import { PORTABLE_QUEUE_NAME, deliveryId, makeOwnerId, parseDeliveryId, queueNameFor, sliceIdempotencyKey } from "./core/ids.js";
38
38
  import { createTransport, readConfig } from "./om/config.js";
39
+ import { resetInProcessWorker, startInProcessWorker } from "./om/in-process-worker.js";
39
40
  import { mikroExecutor, mikroTx } from "./om/sql-executor-mikro.js";
40
41
  import { createProgressMirror } from "./om/progress-mirror.js";
41
42
  import { DurableWorkJob } from "./modules/durable_work/data/entities.js";
@@ -87,11 +88,13 @@ export {
87
88
  readConfig,
88
89
  reconcileOnce,
89
90
  registry,
91
+ resetInProcessWorker,
90
92
  resolveKind,
91
93
  runAfterTransition,
92
94
  runSlice,
93
95
  runTerminalTransition,
94
96
  sliceIdempotencyKey,
97
+ startInProcessWorker,
95
98
  startWorker,
96
99
  store,
97
100
  transportConformance
package/dist/index.js.map CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/index.ts"],
4
- "sourcesContent": ["// Public API of @fullstackhouse/open-mercato-durable-work.\n//\n// The OM module itself lives at ./modules/durable_work and is loaded by the host through\n// `{ id: 'durable_work', from: '@fullstackhouse/open-mercato-durable-work' }`. Everything\n// exported here is usable without Open Mercato: `core/` speaks to Postgres through\n// `SqlExecutor` and to a broker through `TransportAdapter`, which is what lets the failure\n// harness run the real mechanism with no app around it.\n\nexport { metadata } from './modules/durable_work/index'\nexport { features } from './modules/durable_work/acl'\n\nexport type {\n Delivery,\n DurableJob,\n DurableJobStatus,\n ErrorClass,\n Lease,\n LeaseSettings,\n BudgetSettings,\n RetrySettings,\n OrphanPolicy,\n ParkReason,\n Scope,\n SliceOutcome,\n SliceVerdict,\n SqlExecutor,\n SqlTransactor,\n StartJobInput,\n} from './core/types'\n\nexport {\n TransientError,\n TerminalError,\n UnrecoverableError,\n LeaseLostError,\n LockKeyHeldError,\n NoFurtherAttempts,\n UnknownKindError,\n classifyError,\n} from './core/errors'\n\nexport {\n DEFAULT_BUDGET,\n DEFAULT_LEASE,\n DEFAULT_RETRY,\n KindRegistry,\n nextAttemptDelayMs,\n registry,\n resolveKind,\n} from './core/registry'\nexport type { KindDefinition, ResolvedKind, SliceContext } from './core/registry'\n\nexport {\n CREATE_INDEXES,\n CREATE_TABLE,\n DROP_INDEXES,\n DROP_TABLE,\n NO_ORG,\n SCHEMA_STATEMENTS,\n TABLE,\n} from './core/schema'\n\nexport * as store from './core/store'\nexport { runSlice } from './core/run-slice'\nexport type { RunSliceDeps, RunSliceResult } from './core/run-slice'\nexport { DomainMirrorMismatchError, runAfterTransition, runTerminalTransition } from './core/terminal'\nexport type { TerminalResult, Transition } from './core/terminal'\nexport { DurableWorkService } from './core/service'\nexport type { DurableWorkServiceDeps, RedriveRefusal, StartResult } from './core/service'\nexport { reconcileOnce } from './core/reconciler'\nexport type { ReconcileReport, ReconcilerDeps } from './core/reconciler'\nexport { RECONCILE_QUEUE, RECONCILE_TICK_ID, enqueueJob, startWorker } from './core/worker'\nexport type { DurableWorker, WorkerOptions } from './core/worker'\nexport { PORTABLE_QUEUE_NAME, deliveryId, makeOwnerId, parseDeliveryId, queueNameFor, sliceIdempotencyKey } from './core/ids'\n\nexport type {\n BindOptions,\n BoundWorker,\n DeliveryHandler,\n DeliveryState,\n EnqueueOptions,\n HandlerContext,\n TransportAdapter,\n TransportName,\n} from './transport/types'\nexport { createTransport, readConfig } from './om/config'\nexport type { DurableWorkConfig } from './om/config'\nexport { mikroExecutor, mikroTx } from './om/sql-executor-mikro'\nexport { createProgressMirror } from './om/progress-mirror'\nexport type { ProgressMirror, ProgressServiceLike } from './om/progress-mirror'\nexport { DurableWorkJob } from './modules/durable_work/data/entities'\n\nexport { MemoryTransport } from './transport/memory'\nexport type { MemoryFaults } from './transport/memory'\nexport { BullMQTransport } from './transport/bullmq'\nexport type { BullMQTransportOptions } from './transport/bullmq'\nexport { PgBossTransport } from './transport/pgboss'\nexport type { PgBossTransportOptions } from './transport/pgboss'\nexport { transportConformance } from './transport/conformance'\nexport type { ConformanceHooks } from './transport/conformance'\n"],
5
- "mappings": "AAQA,SAAS,gBAAgB;AACzB,SAAS,gBAAgB;AAqBzB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAGP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEP,YAAY,WAAW;AACvB,SAAS,gBAAgB;AAEzB,SAAS,2BAA2B,oBAAoB,6BAA6B;AAErF,SAAS,0BAA0B;AAEnC,SAAS,qBAAqB;AAE9B,SAAS,iBAAiB,mBAAmB,YAAY,mBAAmB;AAE5E,SAAS,qBAAqB,YAAY,aAAa,iBAAiB,cAAc,2BAA2B;AAYjH,SAAS,iBAAiB,kBAAkB;AAE5C,SAAS,eAAe,eAAe;AACvC,SAAS,4BAA4B;AAErC,SAAS,sBAAsB;AAE/B,SAAS,uBAAuB;AAEhC,SAAS,uBAAuB;AAEhC,SAAS,uBAAuB;AAEhC,SAAS,4BAA4B;",
4
+ "sourcesContent": ["// Public API of @fullstackhouse/open-mercato-durable-work.\n//\n// The OM module itself lives at ./modules/durable_work and is loaded by the host through\n// `{ id: 'durable_work', from: '@fullstackhouse/open-mercato-durable-work' }`. Everything\n// exported here is usable without Open Mercato: `core/` speaks to Postgres through\n// `SqlExecutor` and to a broker through `TransportAdapter`, which is what lets the failure\n// harness run the real mechanism with no app around it.\n\nexport { metadata } from './modules/durable_work/index'\nexport { features } from './modules/durable_work/acl'\n\nexport type {\n Delivery,\n DurableJob,\n DurableJobStatus,\n ErrorClass,\n Lease,\n LeaseSettings,\n BudgetSettings,\n RetrySettings,\n OrphanPolicy,\n ParkReason,\n Scope,\n SliceOutcome,\n SliceVerdict,\n SqlExecutor,\n SqlTransactor,\n StartJobInput,\n} from './core/types'\n\nexport {\n TransientError,\n TerminalError,\n UnrecoverableError,\n LeaseLostError,\n LockKeyHeldError,\n NoFurtherAttempts,\n UnknownKindError,\n classifyError,\n} from './core/errors'\n\nexport {\n DEFAULT_BUDGET,\n DEFAULT_LEASE,\n DEFAULT_RETRY,\n KindRegistry,\n nextAttemptDelayMs,\n registry,\n resolveKind,\n} from './core/registry'\nexport type { KindDefinition, ResolvedKind, SliceContext } from './core/registry'\n\nexport {\n CREATE_INDEXES,\n CREATE_TABLE,\n DROP_INDEXES,\n DROP_TABLE,\n NO_ORG,\n SCHEMA_STATEMENTS,\n TABLE,\n} from './core/schema'\n\nexport * as store from './core/store'\nexport { runSlice } from './core/run-slice'\nexport type { RunSliceDeps, RunSliceResult } from './core/run-slice'\nexport { DomainMirrorMismatchError, runAfterTransition, runTerminalTransition } from './core/terminal'\nexport type { TerminalResult, Transition } from './core/terminal'\nexport { DurableWorkService } from './core/service'\nexport type { DurableWorkServiceDeps, RedriveRefusal, StartResult } from './core/service'\nexport { reconcileOnce } from './core/reconciler'\nexport type { ReconcileReport, ReconcilerDeps } from './core/reconciler'\nexport { RECONCILE_QUEUE, RECONCILE_TICK_ID, enqueueJob, startWorker } from './core/worker'\nexport type { DurableWorker, WorkerOptions } from './core/worker'\nexport { PORTABLE_QUEUE_NAME, deliveryId, makeOwnerId, parseDeliveryId, queueNameFor, sliceIdempotencyKey } from './core/ids'\n\nexport type {\n BindOptions,\n BoundWorker,\n DeliveryHandler,\n DeliveryState,\n EnqueueOptions,\n HandlerContext,\n TransportAdapter,\n TransportName,\n} from './transport/types'\nexport { createTransport, readConfig } from './om/config'\nexport type { DurableWorkConfig } from './om/config'\nexport { resetInProcessWorker, startInProcessWorker } from './om/in-process-worker'\nexport type { InProcessWorkerOptions } from './om/in-process-worker'\nexport { mikroExecutor, mikroTx } from './om/sql-executor-mikro'\nexport { createProgressMirror } from './om/progress-mirror'\nexport type { ProgressMirror, ProgressServiceLike } from './om/progress-mirror'\nexport { DurableWorkJob } from './modules/durable_work/data/entities'\n\nexport { MemoryTransport } from './transport/memory'\nexport type { MemoryFaults } from './transport/memory'\nexport { BullMQTransport } from './transport/bullmq'\nexport type { BullMQTransportOptions } from './transport/bullmq'\nexport { PgBossTransport } from './transport/pgboss'\nexport type { PgBossTransportOptions } from './transport/pgboss'\nexport { transportConformance } from './transport/conformance'\nexport type { ConformanceHooks } from './transport/conformance'\n"],
5
+ "mappings": "AAQA,SAAS,gBAAgB;AACzB,SAAS,gBAAgB;AAqBzB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAGP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEP,YAAY,WAAW;AACvB,SAAS,gBAAgB;AAEzB,SAAS,2BAA2B,oBAAoB,6BAA6B;AAErF,SAAS,0BAA0B;AAEnC,SAAS,qBAAqB;AAE9B,SAAS,iBAAiB,mBAAmB,YAAY,mBAAmB;AAE5E,SAAS,qBAAqB,YAAY,aAAa,iBAAiB,cAAc,2BAA2B;AAYjH,SAAS,iBAAiB,kBAAkB;AAE5C,SAAS,sBAAsB,4BAA4B;AAE3D,SAAS,eAAe,eAAe;AACvC,SAAS,4BAA4B;AAErC,SAAS,sBAAsB;AAE/B,SAAS,uBAAuB;AAEhC,SAAS,uBAAuB;AAEhC,SAAS,uBAAuB;AAEhC,SAAS,4BAA4B;",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,56 @@
1
+ import { registry } from "../core/registry.js";
2
+ import { startWorker } from "../core/worker.js";
3
+ import { readConfig } from "./config.js";
4
+ let started = null;
5
+ async function startInProcessWorker(options = {}) {
6
+ const config = readConfig(options.env ?? process.env);
7
+ if (!config.inProcessWorker) return null;
8
+ if (started) return started;
9
+ started = (async () => {
10
+ const log = options.log ?? (() => void 0);
11
+ const resolveContainer = options.resolveContainer ?? (async () => {
12
+ const { createRequestContainer } = await import("@open-mercato/shared/lib/di/container");
13
+ return createRequestContainer();
14
+ });
15
+ const container = await resolveContainer();
16
+ const sql = container.resolve("durableWorkSql");
17
+ const transport = container.resolve("durableWorkTransport");
18
+ const worker = await startWorker({
19
+ sql,
20
+ transport,
21
+ registry,
22
+ kinds: options.kinds,
23
+ concurrency: options.concurrency,
24
+ tickMs: config.tickMs,
25
+ reconcilerGraceMs: config.reconcilerGraceMs,
26
+ drainTimeoutMs: config.drainTimeoutMs,
27
+ log
28
+ });
29
+ log("durable_work.worker_started", {
30
+ owner: worker.owner,
31
+ transport: transport.name,
32
+ inProcess: true,
33
+ kinds: registry.list().map((kind) => kind.kind)
34
+ });
35
+ let stopping = false;
36
+ const stop = async (signal) => {
37
+ if (stopping) return;
38
+ stopping = true;
39
+ log("durable_work.worker_draining", { signal, timeoutMs: config.drainTimeoutMs });
40
+ await worker.stop().catch(() => void 0);
41
+ log("durable_work.worker_stopped", {});
42
+ };
43
+ process.once("SIGTERM", () => void stop("SIGTERM"));
44
+ process.once("SIGINT", () => void stop("SIGINT"));
45
+ return { owner: worker.owner };
46
+ })();
47
+ return started;
48
+ }
49
+ function resetInProcessWorker() {
50
+ started = null;
51
+ }
52
+ export {
53
+ resetInProcessWorker,
54
+ startInProcessWorker
55
+ };
56
+ //# sourceMappingURL=in-process-worker.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/om/in-process-worker.ts"],
4
+ "sourcesContent": ["// Running the worker inside the app's server process, rather than beside it.\n//\n// The worker has to be *a* process; it does not have to be its own. `mercato server start`\n// already brings up the queue workers an app declares, and an operator reasonably expects\n// durable work to arrive the same way \u2014 install the package, register the module, done. A\n// deployment that also requires a second container or Deployment is a step every host must get\n// right, and one that is silently fatal when missed: runs are created, adopted, leased by\n// nobody, and parked by the reconciler much later.\n//\n// Coupling the worker to the web process costs less here than it would elsewhere, because the\n// mechanism is built for exactly the failure that coupling introduces. A deploy stops the web\n// process mid-slice; the lease expires, the reconciler takes the job, and another replica\n// resumes from the committed cursor. That is the same path a killed worker takes, and it is\n// tested. Scaling is a benefit rather than a hazard: N web replicas mean N workers, and the\n// lock key still allows only one live run per subject.\n//\n// What it does cost, stated plainly so a host can weigh it:\n//\n// - the worker shares the pod's memory and database pool with request handling, so a host\n// that sizes pods tightly must account for a third consumer\n// - slice work is I/O-bound (SQL, HTTP, a source database), so it interleaves with requests\n// rather than blocking them \u2014 but a CPU-heavy kind would not, and belongs in its own process\n// - autoscaling on CPU sees worker load as web load\n//\n// A host that would rather keep them apart sets nothing and runs `mercato durable_work worker`\n// as its own process; that path is unchanged and remains the right one for heavy kinds.\n\nimport { registry } from '../core/registry'\nimport { startWorker } from '../core/worker'\nimport type { SqlTransactor } from '../core/types'\nimport type { TransportAdapter } from '../transport/types'\nimport { readConfig } from './config'\n\nexport type InProcessWorkerOptions = {\n /** Resolves the app's container. Defaults to Open Mercato's request container. */\n resolveContainer?: () => Promise<{ resolve(name: string): unknown }>\n /** Restrict to a subset of registered kinds. */\n kinds?: string[]\n concurrency?: number\n log?: (event: string, fields: Record<string, unknown>) => void\n env?: NodeJS.ProcessEnv\n}\n\n/** Started once per process, however many times a host's bootstrap runs. Next calls\n * `register()` per runtime, and a container may be built per request. */\nlet started: Promise<{ owner: string } | null> | null = null\n\n/**\n * Starts the durable worker in this process, if the host asked for one.\n *\n * A no-op unless `DURABLE_WORK_INPROCESS_WORKER` is set, so importing this is safe from a\n * bootstrap that also runs in a CLI, a migration, or a build.\n *\n * Returns the worker's owner id, or null when it did not start.\n */\nexport async function startInProcessWorker(options: InProcessWorkerOptions = {}): Promise<{ owner: string } | null> {\n const config = readConfig(options.env ?? process.env)\n if (!config.inProcessWorker) return null\n if (started) return started\n\n started = (async () => {\n const log = options.log ?? (() => undefined)\n const resolveContainer =\n options.resolveContainer ??\n (async () => {\n const { createRequestContainer } = await import('@open-mercato/shared/lib/di/container')\n return createRequestContainer()\n })\n\n const container = await resolveContainer()\n const sql = container.resolve('durableWorkSql') as SqlTransactor\n const transport = container.resolve('durableWorkTransport') as TransportAdapter\n\n const worker = await startWorker({\n sql,\n transport,\n registry,\n kinds: options.kinds,\n concurrency: options.concurrency,\n tickMs: config.tickMs,\n reconcilerGraceMs: config.reconcilerGraceMs,\n drainTimeoutMs: config.drainTimeoutMs,\n log,\n })\n\n log('durable_work.worker_started', {\n owner: worker.owner,\n transport: transport.name,\n inProcess: true,\n kinds: registry.list().map((kind) => kind.kind),\n })\n\n // SIGTERM is what a deploy sends. Draining rather than exiting is the difference between a\n // slice handing its remaining work back and a slice being cut off between two writes.\n //\n // The listeners do not call `process.exit`: this process is the web server, and it owns\n // when to leave. Draining the worker first is all that is wanted here.\n let stopping = false\n const stop = async (signal: string) => {\n if (stopping) return\n stopping = true\n log('durable_work.worker_draining', { signal, timeoutMs: config.drainTimeoutMs })\n await worker.stop().catch(() => undefined)\n log('durable_work.worker_stopped', {})\n }\n process.once('SIGTERM', () => void stop('SIGTERM'))\n process.once('SIGINT', () => void stop('SIGINT'))\n\n return { owner: worker.owner }\n })()\n\n return started\n}\n\n/** Test seam: forget that a worker was started in this process. */\nexport function resetInProcessWorker(): void {\n started = null\n}\n"],
5
+ "mappings": "AA2BA,SAAS,gBAAgB;AACzB,SAAS,mBAAmB;AAG5B,SAAS,kBAAkB;AAc3B,IAAI,UAAoD;AAUxD,eAAsB,qBAAqB,UAAkC,CAAC,GAAsC;AAClH,QAAM,SAAS,WAAW,QAAQ,OAAO,QAAQ,GAAG;AACpD,MAAI,CAAC,OAAO,gBAAiB,QAAO;AACpC,MAAI,QAAS,QAAO;AAEpB,aAAW,YAAY;AACrB,UAAM,MAAM,QAAQ,QAAQ,MAAM;AAClC,UAAM,mBACJ,QAAQ,qBACP,YAAY;AACX,YAAM,EAAE,uBAAuB,IAAI,MAAM,OAAO,uCAAuC;AACvF,aAAO,uBAAuB;AAAA,IAChC;AAEF,UAAM,YAAY,MAAM,iBAAiB;AACzC,UAAM,MAAM,UAAU,QAAQ,gBAAgB;AAC9C,UAAM,YAAY,UAAU,QAAQ,sBAAsB;AAE1D,UAAM,SAAS,MAAM,YAAY;AAAA,MAC/B;AAAA,MACA;AAAA,MACA;AAAA,MACA,OAAO,QAAQ;AAAA,MACf,aAAa,QAAQ;AAAA,MACrB,QAAQ,OAAO;AAAA,MACf,mBAAmB,OAAO;AAAA,MAC1B,gBAAgB,OAAO;AAAA,MACvB;AAAA,IACF,CAAC;AAED,QAAI,+BAA+B;AAAA,MACjC,OAAO,OAAO;AAAA,MACd,WAAW,UAAU;AAAA,MACrB,WAAW;AAAA,MACX,OAAO,SAAS,KAAK,EAAE,IAAI,CAAC,SAAS,KAAK,IAAI;AAAA,IAChD,CAAC;AAOD,QAAI,WAAW;AACf,UAAM,OAAO,OAAO,WAAmB;AACrC,UAAI,SAAU;AACd,iBAAW;AACX,UAAI,gCAAgC,EAAE,QAAQ,WAAW,OAAO,eAAe,CAAC;AAChF,YAAM,OAAO,KAAK,EAAE,MAAM,MAAM,MAAS;AACzC,UAAI,+BAA+B,CAAC,CAAC;AAAA,IACvC;AACA,YAAQ,KAAK,WAAW,MAAM,KAAK,KAAK,SAAS,CAAC;AAClD,YAAQ,KAAK,UAAU,MAAM,KAAK,KAAK,QAAQ,CAAC;AAEhD,WAAO,EAAE,OAAO,OAAO,MAAM;AAAA,EAC/B,GAAG;AAEH,SAAO;AACT;AAGO,SAAS,uBAA6B;AAC3C,YAAU;AACZ;",
6
+ "names": []
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstackhouse/open-mercato-durable-work",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "Durable at-least-once background work for Open Mercato apps: a leased job record in Postgres with epoch fencing, bounded resumable slices, a server-side reconciler, fenced cancel and an operator API. Pluggable transport (BullMQ or pg-boss).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/index.ts CHANGED
@@ -85,6 +85,8 @@ export type {
85
85
  } from './transport/types'
86
86
  export { createTransport, readConfig } from './om/config'
87
87
  export type { DurableWorkConfig } from './om/config'
88
+ export { resetInProcessWorker, startInProcessWorker } from './om/in-process-worker'
89
+ export type { InProcessWorkerOptions } from './om/in-process-worker'
88
90
  export { mikroExecutor, mikroTx } from './om/sql-executor-mikro'
89
91
  export { createProgressMirror } from './om/progress-mirror'
90
92
  export type { ProgressMirror, ProgressServiceLike } from './om/progress-mirror'
@@ -0,0 +1,76 @@
1
+ // The worker running inside the host's server process.
2
+ //
3
+ // The behaviour that matters here is not "does it start" but "does it start exactly once, and
4
+ // stay out of the way when it was not asked for". A host's bootstrap runs in places that are
5
+ // not a server — a migration, a CLI command, a build — and Next calls `register()` once per
6
+ // runtime, so a helper that started a worker per call would put several on one process.
7
+
8
+ import { afterEach, describe, expect, it, vi } from 'vitest'
9
+
10
+ const stop = vi.fn().mockResolvedValue(undefined)
11
+ const startWorker = vi.fn().mockResolvedValue({ owner: 'dw-test-owner', stop })
12
+
13
+ vi.mock('../core/worker', async (importOriginal) => ({
14
+ ...(await importOriginal<typeof import('../core/worker')>()),
15
+ startWorker: (...args: unknown[]) => startWorker(...args),
16
+ }))
17
+
18
+ const { resetInProcessWorker, startInProcessWorker } = await import('./in-process-worker')
19
+
20
+ const container = { resolve: (name: string) => ({ name }) }
21
+ const run = (env: Record<string, string | undefined>) =>
22
+ startInProcessWorker({ resolveContainer: async () => container, env: env as NodeJS.ProcessEnv })
23
+
24
+ afterEach(() => {
25
+ resetInProcessWorker()
26
+ startWorker.mockClear()
27
+ })
28
+
29
+ describe('startInProcessWorker', () => {
30
+ it('does nothing unless the host asked for it', async () => {
31
+ // Safe to call from a bootstrap shared with migrations and CLI commands.
32
+ await expect(run({ DURABLE_WORK_TRANSPORT: 'pgboss' })).resolves.toBeNull()
33
+ expect(startWorker).not.toHaveBeenCalled()
34
+ })
35
+
36
+ it('starts a worker when the flag is set, and reports its owner', async () => {
37
+ await expect(run({ DURABLE_WORK_TRANSPORT: 'pgboss', DURABLE_WORK_INPROCESS_WORKER: 'true' })).resolves.toEqual({
38
+ owner: 'dw-test-owner',
39
+ })
40
+ expect(startWorker).toHaveBeenCalledTimes(1)
41
+ })
42
+
43
+ it('starts once per process however many times a bootstrap runs', async () => {
44
+ const env = { DURABLE_WORK_TRANSPORT: 'pgboss', DURABLE_WORK_INPROCESS_WORKER: '1' }
45
+ const [first, second, third] = await Promise.all([run(env), run(env), run(env)])
46
+
47
+ expect(startWorker).toHaveBeenCalledTimes(1)
48
+ expect(first).toEqual(second)
49
+ expect(second).toEqual(third)
50
+ })
51
+
52
+ it("passes the host's timing configuration through rather than re-deriving it", async () => {
53
+ await run({
54
+ DURABLE_WORK_TRANSPORT: 'pgboss',
55
+ DURABLE_WORK_INPROCESS_WORKER: 'true',
56
+ DURABLE_WORK_TICK_MS: '5000',
57
+ DURABLE_WORK_GRACE_MS: '7000',
58
+ DURABLE_WORK_DRAIN_TIMEOUT_MS: '9000',
59
+ })
60
+
61
+ expect(startWorker).toHaveBeenCalledWith(
62
+ expect.objectContaining({ tickMs: 5000, reconcilerGraceMs: 7000, drainTimeoutMs: 9000 }),
63
+ )
64
+ })
65
+
66
+ it('drains on SIGTERM instead of leaving the process, which the server owns', async () => {
67
+ await run({ DURABLE_WORK_TRANSPORT: 'pgboss', DURABLE_WORK_INPROCESS_WORKER: 'true' })
68
+ const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never)
69
+
70
+ process.emit('SIGTERM')
71
+ await vi.waitFor(() => expect(stop).toHaveBeenCalled())
72
+
73
+ expect(exit).not.toHaveBeenCalled()
74
+ exit.mockRestore()
75
+ })
76
+ })
@@ -0,0 +1,118 @@
1
+ // Running the worker inside the app's server process, rather than beside it.
2
+ //
3
+ // The worker has to be *a* process; it does not have to be its own. `mercato server start`
4
+ // already brings up the queue workers an app declares, and an operator reasonably expects
5
+ // durable work to arrive the same way — install the package, register the module, done. A
6
+ // deployment that also requires a second container or Deployment is a step every host must get
7
+ // right, and one that is silently fatal when missed: runs are created, adopted, leased by
8
+ // nobody, and parked by the reconciler much later.
9
+ //
10
+ // Coupling the worker to the web process costs less here than it would elsewhere, because the
11
+ // mechanism is built for exactly the failure that coupling introduces. A deploy stops the web
12
+ // process mid-slice; the lease expires, the reconciler takes the job, and another replica
13
+ // resumes from the committed cursor. That is the same path a killed worker takes, and it is
14
+ // tested. Scaling is a benefit rather than a hazard: N web replicas mean N workers, and the
15
+ // lock key still allows only one live run per subject.
16
+ //
17
+ // What it does cost, stated plainly so a host can weigh it:
18
+ //
19
+ // - the worker shares the pod's memory and database pool with request handling, so a host
20
+ // that sizes pods tightly must account for a third consumer
21
+ // - slice work is I/O-bound (SQL, HTTP, a source database), so it interleaves with requests
22
+ // rather than blocking them — but a CPU-heavy kind would not, and belongs in its own process
23
+ // - autoscaling on CPU sees worker load as web load
24
+ //
25
+ // A host that would rather keep them apart sets nothing and runs `mercato durable_work worker`
26
+ // as its own process; that path is unchanged and remains the right one for heavy kinds.
27
+
28
+ import { registry } from '../core/registry'
29
+ import { startWorker } from '../core/worker'
30
+ import type { SqlTransactor } from '../core/types'
31
+ import type { TransportAdapter } from '../transport/types'
32
+ import { readConfig } from './config'
33
+
34
+ export type InProcessWorkerOptions = {
35
+ /** Resolves the app's container. Defaults to Open Mercato's request container. */
36
+ resolveContainer?: () => Promise<{ resolve(name: string): unknown }>
37
+ /** Restrict to a subset of registered kinds. */
38
+ kinds?: string[]
39
+ concurrency?: number
40
+ log?: (event: string, fields: Record<string, unknown>) => void
41
+ env?: NodeJS.ProcessEnv
42
+ }
43
+
44
+ /** Started once per process, however many times a host's bootstrap runs. Next calls
45
+ * `register()` per runtime, and a container may be built per request. */
46
+ let started: Promise<{ owner: string } | null> | null = null
47
+
48
+ /**
49
+ * Starts the durable worker in this process, if the host asked for one.
50
+ *
51
+ * A no-op unless `DURABLE_WORK_INPROCESS_WORKER` is set, so importing this is safe from a
52
+ * bootstrap that also runs in a CLI, a migration, or a build.
53
+ *
54
+ * Returns the worker's owner id, or null when it did not start.
55
+ */
56
+ export async function startInProcessWorker(options: InProcessWorkerOptions = {}): Promise<{ owner: string } | null> {
57
+ const config = readConfig(options.env ?? process.env)
58
+ if (!config.inProcessWorker) return null
59
+ if (started) return started
60
+
61
+ started = (async () => {
62
+ const log = options.log ?? (() => undefined)
63
+ const resolveContainer =
64
+ options.resolveContainer ??
65
+ (async () => {
66
+ const { createRequestContainer } = await import('@open-mercato/shared/lib/di/container')
67
+ return createRequestContainer()
68
+ })
69
+
70
+ const container = await resolveContainer()
71
+ const sql = container.resolve('durableWorkSql') as SqlTransactor
72
+ const transport = container.resolve('durableWorkTransport') as TransportAdapter
73
+
74
+ const worker = await startWorker({
75
+ sql,
76
+ transport,
77
+ registry,
78
+ kinds: options.kinds,
79
+ concurrency: options.concurrency,
80
+ tickMs: config.tickMs,
81
+ reconcilerGraceMs: config.reconcilerGraceMs,
82
+ drainTimeoutMs: config.drainTimeoutMs,
83
+ log,
84
+ })
85
+
86
+ log('durable_work.worker_started', {
87
+ owner: worker.owner,
88
+ transport: transport.name,
89
+ inProcess: true,
90
+ kinds: registry.list().map((kind) => kind.kind),
91
+ })
92
+
93
+ // SIGTERM is what a deploy sends. Draining rather than exiting is the difference between a
94
+ // slice handing its remaining work back and a slice being cut off between two writes.
95
+ //
96
+ // The listeners do not call `process.exit`: this process is the web server, and it owns
97
+ // when to leave. Draining the worker first is all that is wanted here.
98
+ let stopping = false
99
+ const stop = async (signal: string) => {
100
+ if (stopping) return
101
+ stopping = true
102
+ log('durable_work.worker_draining', { signal, timeoutMs: config.drainTimeoutMs })
103
+ await worker.stop().catch(() => undefined)
104
+ log('durable_work.worker_stopped', {})
105
+ }
106
+ process.once('SIGTERM', () => void stop('SIGTERM'))
107
+ process.once('SIGINT', () => void stop('SIGINT'))
108
+
109
+ return { owner: worker.owner }
110
+ })()
111
+
112
+ return started
113
+ }
114
+
115
+ /** Test seam: forget that a worker was started in this process. */
116
+ export function resetInProcessWorker(): void {
117
+ started = null
118
+ }