@open-mercato/cli 0.6.6-develop.5509.1.006f4d4f24 → 0.6.6-develop.5531.1.ab1959dfae
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/.turbo/turbo-build.log +1 -1
- package/dist/agentic/shared/ai/skills/om-troubleshooter/SKILL.md +40 -17
- package/dist/lib/single-instance-strategy-guard.js +89 -0
- package/dist/lib/single-instance-strategy-guard.js.map +7 -0
- package/dist/lib/worker-job-handler.js +20 -0
- package/dist/lib/worker-job-handler.js.map +7 -0
- package/dist/mercato.js +5 -12
- package/dist/mercato.js.map +2 -2
- package/package.json +5 -5
- package/src/lib/__tests__/single-instance-strategy-guard.test.ts +138 -0
- package/src/lib/__tests__/worker-job-handler.test.ts +135 -0
- package/src/lib/single-instance-strategy-guard.ts +172 -0
- package/src/lib/worker-job-handler.ts +45 -0
- package/src/mercato.ts +7 -12
package/.turbo/turbo-build.log
CHANGED
|
@@ -39,7 +39,7 @@ When the developer reports a problem, follow this order:
|
|
|
39
39
|
|
|
40
40
|
### Step 2: Check Generated Files
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
These commands fix 60%+ of issues, so they are usually the first fix to propose (they are mutating — propose, then run after confirmation per [Step 4](#step-4-propose-before-fixing)):
|
|
43
43
|
|
|
44
44
|
```bash
|
|
45
45
|
yarn generate # Regenerate module discovery files
|
|
@@ -61,6 +61,28 @@ ls .mercato/generated/
|
|
|
61
61
|
yarn typecheck
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
+
### Step 4: Propose Before Fixing
|
|
65
|
+
|
|
66
|
+
Once you have diagnosed the root cause, **do not apply the fix immediately**. First present:
|
|
67
|
+
|
|
68
|
+
1. The **root cause** — what is actually broken and why.
|
|
69
|
+
2. The **proposed fix** — the exact commands and/or code changes you intend to apply.
|
|
70
|
+
|
|
71
|
+
Then **wait for explicit user confirmation** before applying any **mutating** change. This keeps the developer in control and avoids surprise edits, migrations, or restarts.
|
|
72
|
+
|
|
73
|
+
**Read-only diagnostics may run without asking** — they only gather information and change nothing:
|
|
74
|
+
|
|
75
|
+
| Allowed without confirmation (read-only) | Requires confirmation (mutating) |
|
|
76
|
+
|------------------------------------------|----------------------------------|
|
|
77
|
+
| `yarn typecheck` | `yarn generate` |
|
|
78
|
+
| `grep` / file reads / `ls` | `yarn db:generate` |
|
|
79
|
+
| log / browser-console inspection | `yarn db:migrate` |
|
|
80
|
+
| `docker compose ps` | editing files |
|
|
81
|
+
| `curl` against a running endpoint (GET) | restarting the dev server (`yarn dev`) |
|
|
82
|
+
| | `docker compose up` |
|
|
83
|
+
|
|
84
|
+
When in doubt about whether an action mutates state, treat it as mutating and ask first. Once the user confirms, apply the fix and verify it.
|
|
85
|
+
|
|
64
86
|
---
|
|
65
87
|
|
|
66
88
|
## 2. Module Issues
|
|
@@ -76,24 +98,24 @@ yarn typecheck
|
|
|
76
98
|
// Must have this entry:
|
|
77
99
|
{ id: '<module_id>', from: '@app' }
|
|
78
100
|
```
|
|
79
|
-
|
|
101
|
+
Proposed fix: Add the entry and run `yarn generate`.
|
|
80
102
|
|
|
81
103
|
2. **Did you run `yarn generate`?**
|
|
82
104
|
Check if `.mercato/generated/` contains your module's entries.
|
|
83
|
-
|
|
105
|
+
Proposed fix: Run `yarn generate`.
|
|
84
106
|
|
|
85
107
|
3. **Is the module folder named correctly?**
|
|
86
108
|
Must be plural, snake_case: `src/modules/<module_id>/`
|
|
87
|
-
|
|
109
|
+
Proposed fix: Rename folder to match module ID.
|
|
88
110
|
|
|
89
111
|
4. **Does `index.ts` export `metadata`?**
|
|
90
112
|
```typescript
|
|
91
113
|
export const metadata: ModuleInfo = { name: '<module_id>', ... }
|
|
92
114
|
```
|
|
93
|
-
|
|
115
|
+
Proposed fix: Add the metadata export.
|
|
94
116
|
|
|
95
117
|
5. **Is the dev server running with latest changes?**
|
|
96
|
-
|
|
118
|
+
Proposed fix: Restart with `yarn dev`.
|
|
97
119
|
|
|
98
120
|
### Module loads but pages 404
|
|
99
121
|
|
|
@@ -104,17 +126,17 @@ yarn typecheck
|
|
|
104
126
|
1. **Are backend page files in the right location?**
|
|
105
127
|
- List page: `backend/page.tsx` (not `backend/index.tsx`)
|
|
106
128
|
- Detail page: `backend/<entities>/[id].tsx` (bracket notation)
|
|
107
|
-
|
|
129
|
+
Proposed fix: Rename to match auto-discovery convention.
|
|
108
130
|
|
|
109
131
|
2. **Do pages export `metadata` with `requireAuth`?**
|
|
110
132
|
```typescript
|
|
111
133
|
export const metadata = { requireAuth: true, features: ['<module_id>.view'] }
|
|
112
134
|
```
|
|
113
|
-
|
|
135
|
+
Proposed fix: Add metadata export.
|
|
114
136
|
|
|
115
137
|
3. **Does the user have the required ACL features?**
|
|
116
138
|
Check `setup.ts` has `defaultRoleFeatures` for the user's role.
|
|
117
|
-
|
|
139
|
+
Proposed fix: Add features to role defaults, re-run setup.
|
|
118
140
|
|
|
119
141
|
---
|
|
120
142
|
|
|
@@ -130,22 +152,22 @@ yarn typecheck
|
|
|
130
152
|
```bash
|
|
131
153
|
yarn db:generate # Probes/creates migration file
|
|
132
154
|
```
|
|
133
|
-
|
|
155
|
+
Proposed fix: Run `yarn db:generate` to inspect the required migration, then keep only the scoped SQL for your module and update `src/modules/<module_id>/migrations/.snapshot-open-mercato.json`.
|
|
134
156
|
|
|
135
157
|
2. **Is the entity declared in the right file with the right imports?**
|
|
136
158
|
Entity classes belong in `src/modules/<module_id>/data/entities.ts` and decorators must come from `@mikro-orm/decorators/legacy`.
|
|
137
|
-
|
|
159
|
+
Proposed fix: move stale `entities/<Entity>.ts` patterns into `data/entities.ts` and fix the imports before regenerating the migration.
|
|
138
160
|
|
|
139
161
|
3. **Did you apply the migration?**
|
|
140
162
|
```bash
|
|
141
163
|
yarn db:migrate # Applies pending migrations
|
|
142
164
|
```
|
|
143
|
-
|
|
165
|
+
Proposed fix: Run `yarn db:migrate`.
|
|
144
166
|
|
|
145
167
|
4. **Is the migration file correct?**
|
|
146
168
|
Check `src/modules/<module_id>/migrations/` for the latest migration.
|
|
147
169
|
Verify it has the expected columns and types.
|
|
148
|
-
|
|
170
|
+
Proposed fix: If wrong, delete the migration file, fix the entity, and regenerate.
|
|
149
171
|
|
|
150
172
|
### Migration generation creates unexpected changes
|
|
151
173
|
|
|
@@ -160,11 +182,11 @@ yarn typecheck
|
|
|
160
182
|
|
|
161
183
|
2. **Did you modify a core module entity without ejecting?**
|
|
162
184
|
Never edit `node_modules/@open-mercato/*`.
|
|
163
|
-
|
|
185
|
+
Proposed fix: Revert changes to node_modules. Use UMES extensions instead, or eject the module.
|
|
164
186
|
|
|
165
187
|
3. **Is a module snapshot stale?**
|
|
166
188
|
Check whether the generated SQL recreates a table or column that already has a committed migration.
|
|
167
|
-
|
|
189
|
+
Proposed fix: update that module's `migrations/.snapshot-open-mercato.json` to include the already-migrated schema, then re-run `yarn db:generate` and expect `no changes`.
|
|
168
190
|
|
|
169
191
|
### Entity changes not reflected
|
|
170
192
|
|
|
@@ -443,9 +465,10 @@ yarn dev # 5. Restart dev server
|
|
|
443
465
|
|
|
444
466
|
## Rules
|
|
445
467
|
|
|
446
|
-
- **ALWAYS**
|
|
468
|
+
- **ALWAYS** present the diagnosed root cause and the proposed fix (commands/code), then **wait for explicit user confirmation before applying any mutating change** (see [Step 4](#step-4-propose-before-fixing)). Only read-only diagnostics may run without asking.
|
|
447
469
|
- **ALWAYS** check server logs / browser console for actual error messages
|
|
448
470
|
- **NEVER** edit files in `.mercato/generated/` or `node_modules/`
|
|
449
471
|
- **NEVER** assume the issue — verify with actual error output
|
|
472
|
+
- Treat `yarn generate` as the most likely first fix, but propose it before running — it regenerates files and is a mutating action
|
|
450
473
|
- Fix the root cause, not the symptom — temporary workarounds become permanent bugs
|
|
451
|
-
- When
|
|
474
|
+
- When proposing a fix, include the exact command or code change needed
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
|
|
2
|
+
import { resolveQueueStrategy } from "@open-mercato/queue";
|
|
3
|
+
const MULTI_INSTANCE_SAFE_STRATEGIES = {
|
|
4
|
+
cache: ["redis"],
|
|
5
|
+
queue: ["async"],
|
|
6
|
+
rateLimit: ["redis"]
|
|
7
|
+
};
|
|
8
|
+
const COMPONENT_ENV_VARS = {
|
|
9
|
+
cache: "CACHE_STRATEGY",
|
|
10
|
+
queue: "QUEUE_STRATEGY",
|
|
11
|
+
rateLimit: "RATE_LIMIT_STRATEGY"
|
|
12
|
+
};
|
|
13
|
+
class SingleInstanceStrategyError extends Error {
|
|
14
|
+
constructor(result) {
|
|
15
|
+
super(
|
|
16
|
+
`Refusing to start: single-instance infrastructure strategies (${result.offenders.map((offender) => `${offender.envVar}=${offender.configured}`).join(", ")}) are configured under a declared multi-instance topology. Switch to a multi-instance-safe strategy or set OM_ALLOW_SINGLE_INSTANCE_STRATEGIES=1 to override.`
|
|
17
|
+
);
|
|
18
|
+
this.name = "SingleInstanceStrategyError";
|
|
19
|
+
this.offenders = result.offenders;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function readInfraStrategySnapshot(env = process.env) {
|
|
23
|
+
return {
|
|
24
|
+
cacheStrategy: env.CACHE_STRATEGY?.trim() || "memory",
|
|
25
|
+
queueStrategy: resolveQueueStrategy(),
|
|
26
|
+
rateLimitStrategy: env.RATE_LIMIT_STRATEGY?.trim() || "memory"
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function resolveMultiInstanceHint(env) {
|
|
30
|
+
if (parseBooleanWithDefault(env.OM_MULTI_INSTANCE, false)) return true;
|
|
31
|
+
const instanceCount = Number.parseInt(env.OM_INSTANCE_COUNT ?? "", 10);
|
|
32
|
+
return Number.isFinite(instanceCount) && instanceCount > 1;
|
|
33
|
+
}
|
|
34
|
+
function evaluateSingleInstanceGuard(snapshot, env = process.env) {
|
|
35
|
+
const configured = {
|
|
36
|
+
cache: snapshot.cacheStrategy,
|
|
37
|
+
queue: snapshot.queueStrategy,
|
|
38
|
+
rateLimit: snapshot.rateLimitStrategy
|
|
39
|
+
};
|
|
40
|
+
const offenders = [];
|
|
41
|
+
for (const component of Object.keys(configured)) {
|
|
42
|
+
const safeValues = MULTI_INSTANCE_SAFE_STRATEGIES[component];
|
|
43
|
+
if (!safeValues.includes(configured[component])) {
|
|
44
|
+
offenders.push({
|
|
45
|
+
component,
|
|
46
|
+
envVar: COMPONENT_ENV_VARS[component],
|
|
47
|
+
configured: configured[component],
|
|
48
|
+
safeValues
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const production = (env.NODE_ENV ?? "").trim() === "production";
|
|
53
|
+
const multiInstance = resolveMultiInstanceHint(env);
|
|
54
|
+
const overridden = parseBooleanWithDefault(env.OM_ALLOW_SINGLE_INSTANCE_STRATEGIES, false);
|
|
55
|
+
let action = "ok";
|
|
56
|
+
if (offenders.length > 0 && production) {
|
|
57
|
+
action = multiInstance && !overridden ? "fail" : "warn";
|
|
58
|
+
}
|
|
59
|
+
return { action, offenders, production, multiInstance, overridden };
|
|
60
|
+
}
|
|
61
|
+
function formatSingleInstanceGuardMessage(result) {
|
|
62
|
+
const offenderLines = result.offenders.map(
|
|
63
|
+
(offender) => ` - ${offender.envVar}=${offender.configured} (multi-instance-safe: ${offender.safeValues.join(", ")})`
|
|
64
|
+
);
|
|
65
|
+
const header = result.action === "fail" ? "[server] Refusing to start: single-instance infrastructure strategies under a multi-instance topology." : "[server] WARNING: single-instance infrastructure strategies detected in production.";
|
|
66
|
+
const guidance = result.action === "fail" ? "[server] Switch each strategy above to a multi-instance-safe value, or set OM_ALLOW_SINGLE_INSTANCE_STRATEGIES=1 to override (accepting duplicate jobs, stale ACLs, and weakened rate limits)." : result.multiInstance ? "[server] OM_ALLOW_SINGLE_INSTANCE_STRATEGIES=1 is set \u2014 proceeding despite the risks above (duplicate jobs, stale ACLs, weakened rate limits)." : "[server] Running multiple instances against these strategies will cause duplicate jobs, stale ACLs, and weakened rate limits. Set OM_MULTI_INSTANCE=1 to make this a hard failure once you scale out.";
|
|
67
|
+
return [header, ...offenderLines, guidance];
|
|
68
|
+
}
|
|
69
|
+
function assertSingleInstanceStrategies(env = process.env, options) {
|
|
70
|
+
const snapshot = options?.snapshot ?? readInfraStrategySnapshot(env);
|
|
71
|
+
const result = evaluateSingleInstanceGuard(snapshot, env);
|
|
72
|
+
const logger = options?.logger ?? console;
|
|
73
|
+
if (result.action === "fail") {
|
|
74
|
+
for (const line of formatSingleInstanceGuardMessage(result)) logger.error(line);
|
|
75
|
+
throw new SingleInstanceStrategyError(result);
|
|
76
|
+
}
|
|
77
|
+
if (result.action === "warn") {
|
|
78
|
+
for (const line of formatSingleInstanceGuardMessage(result)) logger.warn(line);
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
export {
|
|
83
|
+
SingleInstanceStrategyError,
|
|
84
|
+
assertSingleInstanceStrategies,
|
|
85
|
+
evaluateSingleInstanceGuard,
|
|
86
|
+
formatSingleInstanceGuardMessage,
|
|
87
|
+
readInfraStrategySnapshot
|
|
88
|
+
};
|
|
89
|
+
//# sourceMappingURL=single-instance-strategy-guard.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/lib/single-instance-strategy-guard.ts"],
|
|
4
|
+
"sourcesContent": ["import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { resolveQueueStrategy } from '@open-mercato/queue'\n\n/**\n * Boot-time guard that refuses to start (or warns) when an infrastructure\n * strategy that is only safe for a single process/instance is configured under\n * a declared multi-instance topology.\n *\n * Background: `CACHE_STRATEGY`, `RATE_LIMIT_STRATEGY` and `QUEUE_STRATEGY` all\n * default to single-instance modes (`memory`/`memory`/`local`). Running more\n * than one app instance against those defaults silently breaks three\n * correctness invariants at once \u2014 stale RBAC caches after a privilege\n * revocation, rate limits multiplied by the instance count, and duplicate job\n * processing from the leaderless local file queue. None of them warn.\n *\n * Only strategies that coordinate across processes via shared external state\n * are considered multi-instance safe. Everything else (the in-process memory\n * strategies, the local file queue, and disk-backed caches that are not shared\n * across hosts) is treated as single-instance.\n */\nconst MULTI_INSTANCE_SAFE_STRATEGIES = {\n cache: ['redis'],\n queue: ['async'],\n rateLimit: ['redis'],\n} as const\n\nexport type SingleInstanceGuardComponent = 'cache' | 'queue' | 'rateLimit'\n\nexport type SingleInstanceGuardOffender = {\n component: SingleInstanceGuardComponent\n envVar: string\n configured: string\n safeValues: readonly string[]\n}\n\nexport type SingleInstanceGuardAction = 'ok' | 'warn' | 'fail'\n\nexport type SingleInstanceGuardResult = {\n action: SingleInstanceGuardAction\n offenders: SingleInstanceGuardOffender[]\n production: boolean\n multiInstance: boolean\n overridden: boolean\n}\n\nexport type InfraStrategySnapshot = {\n cacheStrategy: string\n queueStrategy: string\n rateLimitStrategy: string\n}\n\nconst COMPONENT_ENV_VARS: Record<SingleInstanceGuardComponent, string> = {\n cache: 'CACHE_STRATEGY',\n queue: 'QUEUE_STRATEGY',\n rateLimit: 'RATE_LIMIT_STRATEGY',\n}\n\nexport class SingleInstanceStrategyError extends Error {\n readonly offenders: SingleInstanceGuardOffender[]\n\n constructor(result: SingleInstanceGuardResult) {\n super(\n `Refusing to start: single-instance infrastructure strategies (${result.offenders\n .map((offender) => `${offender.envVar}=${offender.configured}`)\n .join(', ')}) are configured under a declared multi-instance topology. ` +\n 'Switch to a multi-instance-safe strategy or set OM_ALLOW_SINGLE_INSTANCE_STRATEGIES=1 to override.',\n )\n this.name = 'SingleInstanceStrategyError'\n this.offenders = result.offenders\n }\n}\n\n/**\n * Read the configured infrastructure strategies. Queue resolution reuses the\n * canonical `resolveQueueStrategy()` so the default stays single-sourced; cache\n * and rate-limit values are read directly with their documented defaults\n * (`packages/cache/src/service.ts`, `packages/shared/src/lib/ratelimit/config.ts`)\n * because the guard's safe-set policy \u2014 not the resolver default \u2014 decides what\n * counts as single-instance.\n */\nexport function readInfraStrategySnapshot(env: NodeJS.ProcessEnv = process.env): InfraStrategySnapshot {\n return {\n cacheStrategy: env.CACHE_STRATEGY?.trim() || 'memory',\n queueStrategy: resolveQueueStrategy(),\n rateLimitStrategy: env.RATE_LIMIT_STRATEGY?.trim() || 'memory',\n }\n}\n\nfunction resolveMultiInstanceHint(env: NodeJS.ProcessEnv): boolean {\n if (parseBooleanWithDefault(env.OM_MULTI_INSTANCE, false)) return true\n const instanceCount = Number.parseInt(env.OM_INSTANCE_COUNT ?? '', 10)\n return Number.isFinite(instanceCount) && instanceCount > 1\n}\n\nexport function evaluateSingleInstanceGuard(\n snapshot: InfraStrategySnapshot,\n env: NodeJS.ProcessEnv = process.env,\n): SingleInstanceGuardResult {\n const configured: Record<SingleInstanceGuardComponent, string> = {\n cache: snapshot.cacheStrategy,\n queue: snapshot.queueStrategy,\n rateLimit: snapshot.rateLimitStrategy,\n }\n\n const offenders: SingleInstanceGuardOffender[] = []\n for (const component of Object.keys(configured) as SingleInstanceGuardComponent[]) {\n const safeValues: readonly string[] = MULTI_INSTANCE_SAFE_STRATEGIES[component]\n if (!safeValues.includes(configured[component])) {\n offenders.push({\n component,\n envVar: COMPONENT_ENV_VARS[component],\n configured: configured[component],\n safeValues,\n })\n }\n }\n\n const production = (env.NODE_ENV ?? '').trim() === 'production'\n const multiInstance = resolveMultiInstanceHint(env)\n const overridden = parseBooleanWithDefault(env.OM_ALLOW_SINGLE_INSTANCE_STRATEGIES, false)\n\n let action: SingleInstanceGuardAction = 'ok'\n if (offenders.length > 0 && production) {\n action = multiInstance && !overridden ? 'fail' : 'warn'\n }\n\n return { action, offenders, production, multiInstance, overridden }\n}\n\nexport function formatSingleInstanceGuardMessage(result: SingleInstanceGuardResult): string[] {\n const offenderLines = result.offenders.map(\n (offender) =>\n ` - ${offender.envVar}=${offender.configured} (multi-instance-safe: ${offender.safeValues.join(', ')})`,\n )\n const header =\n result.action === 'fail'\n ? '[server] Refusing to start: single-instance infrastructure strategies under a multi-instance topology.'\n : '[server] WARNING: single-instance infrastructure strategies detected in production.'\n const guidance =\n result.action === 'fail'\n ? '[server] Switch each strategy above to a multi-instance-safe value, or set OM_ALLOW_SINGLE_INSTANCE_STRATEGIES=1 to override (accepting duplicate jobs, stale ACLs, and weakened rate limits).'\n : result.multiInstance\n ? '[server] OM_ALLOW_SINGLE_INSTANCE_STRATEGIES=1 is set \u2014 proceeding despite the risks above (duplicate jobs, stale ACLs, weakened rate limits).'\n : '[server] Running multiple instances against these strategies will cause duplicate jobs, stale ACLs, and weakened rate limits. Set OM_MULTI_INSTANCE=1 to make this a hard failure once you scale out.'\n return [header, ...offenderLines, guidance]\n}\n\nexport type SingleInstanceGuardLogger = Pick<Console, 'warn' | 'error'>\n\n/**\n * Evaluate the guard and enforce it: throw on `fail`, log prominently on\n * `warn`, and stay silent on `ok`. Safe to call on every `start`; it never\n * fires for dev boots or single-instance production deployments.\n */\nexport function assertSingleInstanceStrategies(\n env: NodeJS.ProcessEnv = process.env,\n options?: { snapshot?: InfraStrategySnapshot; logger?: SingleInstanceGuardLogger },\n): SingleInstanceGuardResult {\n const snapshot = options?.snapshot ?? readInfraStrategySnapshot(env)\n const result = evaluateSingleInstanceGuard(snapshot, env)\n const logger = options?.logger ?? console\n\n if (result.action === 'fail') {\n for (const line of formatSingleInstanceGuardMessage(result)) logger.error(line)\n throw new SingleInstanceStrategyError(result)\n }\n if (result.action === 'warn') {\n for (const line of formatSingleInstanceGuardMessage(result)) logger.warn(line)\n }\n\n return result\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,+BAA+B;AACxC,SAAS,4BAA4B;AAmBrC,MAAM,iCAAiC;AAAA,EACrC,OAAO,CAAC,OAAO;AAAA,EACf,OAAO,CAAC,OAAO;AAAA,EACf,WAAW,CAAC,OAAO;AACrB;AA2BA,MAAM,qBAAmE;AAAA,EACvE,OAAO;AAAA,EACP,OAAO;AAAA,EACP,WAAW;AACb;AAEO,MAAM,oCAAoC,MAAM;AAAA,EAGrD,YAAY,QAAmC;AAC7C;AAAA,MACE,iEAAiE,OAAO,UACrE,IAAI,CAAC,aAAa,GAAG,SAAS,MAAM,IAAI,SAAS,UAAU,EAAE,EAC7D,KAAK,IAAI,CAAC;AAAA,IAEf;AACA,SAAK,OAAO;AACZ,SAAK,YAAY,OAAO;AAAA,EAC1B;AACF;AAUO,SAAS,0BAA0B,MAAyB,QAAQ,KAA4B;AACrG,SAAO;AAAA,IACL,eAAe,IAAI,gBAAgB,KAAK,KAAK;AAAA,IAC7C,eAAe,qBAAqB;AAAA,IACpC,mBAAmB,IAAI,qBAAqB,KAAK,KAAK;AAAA,EACxD;AACF;AAEA,SAAS,yBAAyB,KAAiC;AACjE,MAAI,wBAAwB,IAAI,mBAAmB,KAAK,EAAG,QAAO;AAClE,QAAM,gBAAgB,OAAO,SAAS,IAAI,qBAAqB,IAAI,EAAE;AACrE,SAAO,OAAO,SAAS,aAAa,KAAK,gBAAgB;AAC3D;AAEO,SAAS,4BACd,UACA,MAAyB,QAAQ,KACN;AAC3B,QAAM,aAA2D;AAAA,IAC/D,OAAO,SAAS;AAAA,IAChB,OAAO,SAAS;AAAA,IAChB,WAAW,SAAS;AAAA,EACtB;AAEA,QAAM,YAA2C,CAAC;AAClD,aAAW,aAAa,OAAO,KAAK,UAAU,GAAqC;AACjF,UAAM,aAAgC,+BAA+B,SAAS;AAC9E,QAAI,CAAC,WAAW,SAAS,WAAW,SAAS,CAAC,GAAG;AAC/C,gBAAU,KAAK;AAAA,QACb;AAAA,QACA,QAAQ,mBAAmB,SAAS;AAAA,QACpC,YAAY,WAAW,SAAS;AAAA,QAChC;AAAA,MACF,CAAC;AAAA,IACH;AAAA,EACF;AAEA,QAAM,cAAc,IAAI,YAAY,IAAI,KAAK,MAAM;AACnD,QAAM,gBAAgB,yBAAyB,GAAG;AAClD,QAAM,aAAa,wBAAwB,IAAI,qCAAqC,KAAK;AAEzF,MAAI,SAAoC;AACxC,MAAI,UAAU,SAAS,KAAK,YAAY;AACtC,aAAS,iBAAiB,CAAC,aAAa,SAAS;AAAA,EACnD;AAEA,SAAO,EAAE,QAAQ,WAAW,YAAY,eAAe,WAAW;AACpE;AAEO,SAAS,iCAAiC,QAA6C;AAC5F,QAAM,gBAAgB,OAAO,UAAU;AAAA,IACrC,CAAC,aACC,OAAO,SAAS,MAAM,IAAI,SAAS,UAAU,0BAA0B,SAAS,WAAW,KAAK,IAAI,CAAC;AAAA,EACzG;AACA,QAAM,SACJ,OAAO,WAAW,SACd,2GACA;AACN,QAAM,WACJ,OAAO,WAAW,SACd,mMACA,OAAO,gBACL,wJACA;AACR,SAAO,CAAC,QAAQ,GAAG,eAAe,QAAQ;AAC5C;AASO,SAAS,+BACd,MAAyB,QAAQ,KACjC,SAC2B;AAC3B,QAAM,WAAW,SAAS,YAAY,0BAA0B,GAAG;AACnE,QAAM,SAAS,4BAA4B,UAAU,GAAG;AACxD,QAAM,SAAS,SAAS,UAAU;AAElC,MAAI,OAAO,WAAW,QAAQ;AAC5B,eAAW,QAAQ,iCAAiC,MAAM,EAAG,QAAO,MAAM,IAAI;AAC9E,UAAM,IAAI,4BAA4B,MAAM;AAAA,EAC9C;AACA,MAAI,OAAO,WAAW,QAAQ;AAC5B,eAAW,QAAQ,iCAAiC,MAAM,EAAG,QAAO,KAAK,IAAI;AAAA,EAC/E;AAEA,SAAO;AACT;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
function createPerJobWorkerHandler(workers, createContainer) {
|
|
2
|
+
return async (job, ctx) => {
|
|
3
|
+
const container = await createContainer();
|
|
4
|
+
try {
|
|
5
|
+
for (const worker of workers) {
|
|
6
|
+
await worker.handler(job, { ...ctx, resolve: container.resolve.bind(container) });
|
|
7
|
+
}
|
|
8
|
+
} finally {
|
|
9
|
+
try {
|
|
10
|
+
const em = container.resolve("em");
|
|
11
|
+
em?.clear?.();
|
|
12
|
+
} catch {
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export {
|
|
18
|
+
createPerJobWorkerHandler
|
|
19
|
+
};
|
|
20
|
+
//# sourceMappingURL=worker-job-handler.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/lib/worker-job-handler.ts"],
|
|
4
|
+
"sourcesContent": ["import type { JobContext, JobHandler } from '@open-mercato/queue'\nimport type { ModuleWorker } from '@open-mercato/shared/modules/registry'\n\nexport type WorkerJobContainer = {\n resolve: <T = unknown>(name: string) => T\n}\n\nexport type WorkerJobContainerFactory = () => Promise<WorkerJobContainer>\n\ntype ClearableEntityManager = {\n clear?: () => void\n}\n\n/**\n * Builds a queue job handler that isolates every job in its own request\n * container, instead of sharing a single process-wide `EntityManager` fork\n * across all concurrent jobs.\n *\n * Under the async (BullMQ) strategy jobs run with real concurrency, so a\n * shared EM would interleave unit-of-work flushes between unrelated jobs and\n * never release its identity map. Creating one container per job removes both\n * the cross-job flush race and the unbounded identity-map growth, while\n * keeping the `ctx.resolve` contract and DI keys unchanged. See issue #2970.\n */\nexport function createPerJobWorkerHandler(\n workers: ModuleWorker[],\n createContainer: WorkerJobContainerFactory,\n): JobHandler {\n return async (job, ctx: JobContext) => {\n const container = await createContainer()\n try {\n for (const worker of workers) {\n await worker.handler(job, { ...ctx, resolve: container.resolve.bind(container) })\n }\n } finally {\n try {\n const em = container.resolve('em') as ClearableEntityManager | null\n em?.clear?.()\n } catch {\n // Best-effort: clearing the identity map is a memory optimization and\n // must never mask a job's own outcome.\n }\n }\n }\n}\n"],
|
|
5
|
+
"mappings": "AAwBO,SAAS,0BACd,SACA,iBACY;AACZ,SAAO,OAAO,KAAK,QAAoB;AACrC,UAAM,YAAY,MAAM,gBAAgB;AACxC,QAAI;AACF,iBAAW,UAAU,SAAS;AAC5B,cAAM,OAAO,QAAQ,KAAK,EAAE,GAAG,KAAK,SAAS,UAAU,QAAQ,KAAK,SAAS,EAAE,CAAC;AAAA,MAClF;AAAA,IACF,UAAE;AACA,UAAI;AACF,cAAM,KAAK,UAAU,QAAQ,IAAI;AACjC,YAAI,QAAQ;AAAA,MACd,QAAQ;AAAA,MAGR;AAAA,IACF;AAAA,EACF;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/dist/mercato.js
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
resolveLazyRestart
|
|
11
11
|
} from "./lib/auto-spawn-workers.js";
|
|
12
12
|
import { startLazyWorkerSupervisor } from "./lib/queue-worker-supervisor.js";
|
|
13
|
+
import { createPerJobWorkerHandler } from "./lib/worker-job-handler.js";
|
|
13
14
|
import {
|
|
14
15
|
resolveAutoSpawnSchedulerMode,
|
|
15
16
|
resolveLazySchedulerPollMs,
|
|
@@ -25,6 +26,7 @@ import {
|
|
|
25
26
|
import { parseModuleInstallArgs } from "./lib/module-install-args.js";
|
|
26
27
|
import { resolveNextBuildIdCandidate } from "./lib/next-build-id.js";
|
|
27
28
|
import { acquireServerStartLock } from "./lib/server-start-lock.js";
|
|
29
|
+
import { assertSingleInstanceStrategies } from "./lib/single-instance-strategy-guard.js";
|
|
28
30
|
import { createDevEnvReloader, watchDevEnvFiles } from "./lib/dev-env-reload.js";
|
|
29
31
|
const lazyIntegration = () => import("./lib/testing/integration.js");
|
|
30
32
|
import path from "node:path";
|
|
@@ -1224,7 +1226,6 @@ async function run(argv = process.argv) {
|
|
|
1224
1226
|
return;
|
|
1225
1227
|
}
|
|
1226
1228
|
const { createRequestContainer } = await import("@open-mercato/shared/lib/di/container");
|
|
1227
|
-
const container = await createRequestContainer();
|
|
1228
1229
|
console.log(`[worker] Starting workers for all queues: ${discoveredQueues.join(", ")}`);
|
|
1229
1230
|
const workerPromises = discoveredQueues.map(async (queue) => {
|
|
1230
1231
|
const queueWorkers = allWorkers.filter((w) => w.queue === queue);
|
|
@@ -1236,11 +1237,7 @@ async function run(argv = process.argv) {
|
|
|
1236
1237
|
connection: queueRedisUrl ? { url: queueRedisUrl } : void 0,
|
|
1237
1238
|
concurrency,
|
|
1238
1239
|
background: true,
|
|
1239
|
-
handler:
|
|
1240
|
-
for (const worker of queueWorkers) {
|
|
1241
|
-
await worker.handler(job, { ...ctx, resolve: container.resolve.bind(container) });
|
|
1242
|
-
}
|
|
1243
|
-
}
|
|
1240
|
+
handler: createPerJobWorkerHandler(queueWorkers, createRequestContainer)
|
|
1244
1241
|
});
|
|
1245
1242
|
});
|
|
1246
1243
|
await Promise.all(workerPromises);
|
|
@@ -1251,7 +1248,6 @@ async function run(argv = process.argv) {
|
|
|
1251
1248
|
const queueWorkers = allWorkers.filter((w) => w.queue === queueName);
|
|
1252
1249
|
if (queueWorkers.length > 0) {
|
|
1253
1250
|
const { createRequestContainer } = await import("@open-mercato/shared/lib/di/container");
|
|
1254
|
-
const container = await createRequestContainer();
|
|
1255
1251
|
const concurrency = concurrencyOverride ?? Math.max(...queueWorkers.map((w) => w.concurrency), 1);
|
|
1256
1252
|
console.log(`[worker] Found ${queueWorkers.length} worker(s) for queue "${queueName}"`);
|
|
1257
1253
|
const queueRedisUrl = getRedisUrl("QUEUE");
|
|
@@ -1259,11 +1255,7 @@ async function run(argv = process.argv) {
|
|
|
1259
1255
|
queueName,
|
|
1260
1256
|
connection: queueRedisUrl ? { url: queueRedisUrl } : void 0,
|
|
1261
1257
|
concurrency,
|
|
1262
|
-
handler:
|
|
1263
|
-
for (const worker of queueWorkers) {
|
|
1264
|
-
await worker.handler(job, { ...ctx, resolve: container.resolve.bind(container) });
|
|
1265
|
-
}
|
|
1266
|
-
}
|
|
1258
|
+
handler: createPerJobWorkerHandler(queueWorkers, createRequestContainer)
|
|
1267
1259
|
});
|
|
1268
1260
|
} else {
|
|
1269
1261
|
console.error(`No workers found for queue "${queueName}"`);
|
|
@@ -1781,6 +1773,7 @@ async function run(argv = process.argv) {
|
|
|
1781
1773
|
const autoSpawnSchedulerMode = resolveAutoSpawnSchedulerMode(process.env);
|
|
1782
1774
|
const queueStrategy = process.env.QUEUE_STRATEGY || "local";
|
|
1783
1775
|
const runtimeEnv = buildServerProcessEnvironment(process.env);
|
|
1776
|
+
assertSingleInstanceStrategies(runtimeEnv);
|
|
1784
1777
|
const schedulerCommand = lookupModuleCommand(getCliModules(), "scheduler", "start");
|
|
1785
1778
|
const serverStartLock = acquireServerStartLock(appDir, {
|
|
1786
1779
|
port: runtimeEnv.PORT ?? process.env.PORT ?? null
|