arcway 0.1.12 → 0.1.14
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/README.md +1 -1
- package/client/head.js +1 -1
- package/client/provider.js +32 -7
- package/client/router.js +46 -7
- package/package.json +4 -1
- package/server/boot/index.js +29 -32
- package/server/boot/infrastructure.js +47 -0
- package/server/config/modules/jobs.js +10 -0
- package/server/config/modules/server.js +1 -1
- package/server/context.js +1 -1
- package/server/graphql/index.js +0 -2
- package/server/jobs/drivers/knex-queue.js +6 -5
- package/server/jobs/drivers/memory-queue.js +6 -5
- package/server/jobs/drivers/run-handler.js +22 -0
- package/server/jobs/runner.js +17 -6
- package/server/jobs/worker-entry.js +122 -0
- package/server/jobs/worker-pool.js +253 -0
- package/server/mcp/index.js +1 -1
- package/server/pages/build-cache.js +203 -0
- package/server/pages/build-server.js +107 -69
- package/server/pages/build.js +120 -72
- package/server/pages/handler.js +9 -10
- package/server/pages/hmr.js +19 -13
- package/server/pages/ssr.js +3 -4
- package/server/pages/watcher.js +4 -3
- package/server/router/api-router.js +1 -1
- package/server/web-server.js +1 -1
- package/server/ws/index.js +1 -12
package/README.md
CHANGED
package/client/head.js
CHANGED
package/client/provider.js
CHANGED
|
@@ -3,7 +3,7 @@ import { WsManager } from './ws.js';
|
|
|
3
3
|
|
|
4
4
|
const SOLO_CTX_KEY = '__provider_context__';
|
|
5
5
|
const WS_CTX_KEY = '__ws_context__';
|
|
6
|
-
const DEFAULT_CONFIG = { pathPrefix: '' };
|
|
6
|
+
const DEFAULT_CONFIG = { pathPrefix: '/api' };
|
|
7
7
|
|
|
8
8
|
const ApiContext = (globalThis[SOLO_CTX_KEY] ??= createContext(DEFAULT_CONFIG));
|
|
9
9
|
const WsContext = (globalThis[WS_CTX_KEY] ??= createContext(null));
|
|
@@ -18,8 +18,25 @@ function useWsManager() {
|
|
|
18
18
|
|
|
19
19
|
const useSoloContext = useApiContext;
|
|
20
20
|
|
|
21
|
-
function
|
|
22
|
-
const
|
|
21
|
+
function toWsProtocol(loc, wsPath) {
|
|
22
|
+
const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
23
|
+
return `${proto}//${loc.host}${wsPath}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function resolveWsUrl(input) {
|
|
27
|
+
if (typeof window === 'undefined') return null;
|
|
28
|
+
if (!input) return toWsProtocol(window.location, '/ws');
|
|
29
|
+
if (/^wss?:\/\//.test(input)) return input;
|
|
30
|
+
if (input.startsWith('/')) return toWsProtocol(window.location, input);
|
|
31
|
+
return input;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function ApiProvider({ children, pathPrefix = '/api', headers, wsUrl }) {
|
|
35
|
+
const resolvedWsUrl = useMemo(() => resolveWsUrl(wsUrl), [wsUrl]);
|
|
36
|
+
const config = useMemo(
|
|
37
|
+
() => ({ pathPrefix, headers, wsUrl: resolvedWsUrl }),
|
|
38
|
+
[pathPrefix, headers, resolvedWsUrl],
|
|
39
|
+
);
|
|
23
40
|
|
|
24
41
|
const wsManagerRef = useRef(null);
|
|
25
42
|
const wsManager = useMemo(() => {
|
|
@@ -27,11 +44,11 @@ function ApiProvider({ children, pathPrefix = '', headers, wsUrl }) {
|
|
|
27
44
|
wsManagerRef.current.disconnect();
|
|
28
45
|
wsManagerRef.current = null;
|
|
29
46
|
}
|
|
30
|
-
if (!
|
|
31
|
-
const manager = new WsManager({ url:
|
|
47
|
+
if (!resolvedWsUrl) return null;
|
|
48
|
+
const manager = new WsManager({ url: resolvedWsUrl });
|
|
32
49
|
wsManagerRef.current = manager;
|
|
33
50
|
return manager;
|
|
34
|
-
}, [
|
|
51
|
+
}, [resolvedWsUrl]);
|
|
35
52
|
|
|
36
53
|
useEffect(() => {
|
|
37
54
|
if (wsManager) {
|
|
@@ -50,4 +67,12 @@ function ApiProvider({ children, pathPrefix = '', headers, wsUrl }) {
|
|
|
50
67
|
const Provider = ApiProvider;
|
|
51
68
|
const SoloProvider = ApiProvider;
|
|
52
69
|
|
|
53
|
-
export {
|
|
70
|
+
export {
|
|
71
|
+
ApiProvider,
|
|
72
|
+
Provider,
|
|
73
|
+
SoloProvider,
|
|
74
|
+
resolveWsUrl,
|
|
75
|
+
useApiContext,
|
|
76
|
+
useSoloContext,
|
|
77
|
+
useWsManager,
|
|
78
|
+
};
|
package/client/router.js
CHANGED
|
@@ -51,7 +51,9 @@ function useRouter() {
|
|
|
51
51
|
if (typeof window !== 'undefined') window.location.reload();
|
|
52
52
|
},
|
|
53
53
|
}),
|
|
54
|
-
|
|
54
|
+
// queryVersion bumps on every query-only navigation or same-path popstate,
|
|
55
|
+
// so memoised consumers (notably `useSearchParams`) re-read the URL.
|
|
56
|
+
[ctx.pathname, ctx.params, ctx.navigate, ctx.queryVersion],
|
|
55
57
|
);
|
|
56
58
|
}
|
|
57
59
|
|
|
@@ -86,6 +88,10 @@ function Router({
|
|
|
86
88
|
});
|
|
87
89
|
const [isNavigating, setIsNavigating] = useState(false);
|
|
88
90
|
const [isPending, startTransition] = useTransition();
|
|
91
|
+
// Bumped on query-only navigations (both push/replace and popstate) so that
|
|
92
|
+
// `useRouter`/`useSearchParams` consumers re-render even though pathname and
|
|
93
|
+
// params are unchanged.
|
|
94
|
+
const [queryVersion, setQueryVersion] = useState(0);
|
|
89
95
|
const manifestRef = useRef(null);
|
|
90
96
|
|
|
91
97
|
useEffect(() => {
|
|
@@ -114,7 +120,28 @@ function Router({
|
|
|
114
120
|
async (to, options) => {
|
|
115
121
|
const scroll = options?.scroll !== false;
|
|
116
122
|
const replace = options?.replace === true;
|
|
117
|
-
|
|
123
|
+
|
|
124
|
+
// `matchClientRoute` only knows how to match pathnames, so any query
|
|
125
|
+
// string has to be stripped before the lookup. Splitting also lets us
|
|
126
|
+
// special-case same-path/different-query navigations as a history-only
|
|
127
|
+
// update — no bundle loading, no scroll reset, no full reload.
|
|
128
|
+
const qIdx = to.indexOf('?');
|
|
129
|
+
const pathOnly = qIdx === -1 ? to : to.slice(0, qIdx);
|
|
130
|
+
const search = qIdx === -1 ? '' : to.slice(qIdx);
|
|
131
|
+
const currentSearch =
|
|
132
|
+
typeof window !== 'undefined' ? window.location.search : '';
|
|
133
|
+
|
|
134
|
+
if (pathOnly === pathname && search === currentSearch) return;
|
|
135
|
+
|
|
136
|
+
if (pathOnly === pathname) {
|
|
137
|
+
if (replace) {
|
|
138
|
+
window.history.replaceState(null, '', to);
|
|
139
|
+
} else {
|
|
140
|
+
window.history.pushState(null, '', to);
|
|
141
|
+
}
|
|
142
|
+
setQueryVersion((v) => v + 1);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
118
145
|
|
|
119
146
|
const manifest = manifestRef.current;
|
|
120
147
|
if (!manifest) {
|
|
@@ -122,7 +149,7 @@ function Router({
|
|
|
122
149
|
return;
|
|
123
150
|
}
|
|
124
151
|
|
|
125
|
-
const matched = matchClientRoute(manifest,
|
|
152
|
+
const matched = matchClientRoute(manifest, pathOnly);
|
|
126
153
|
if (!matched) {
|
|
127
154
|
window.location.href = to;
|
|
128
155
|
return;
|
|
@@ -137,7 +164,7 @@ function Router({
|
|
|
137
164
|
|
|
138
165
|
const targetLoadings = await loadLoadingComponents(matched.route);
|
|
139
166
|
if (targetLoadings.length > 0) {
|
|
140
|
-
setPathname(
|
|
167
|
+
setPathname(pathOnly);
|
|
141
168
|
setPageState((prev) => ({
|
|
142
169
|
...prev,
|
|
143
170
|
loadings: targetLoadings,
|
|
@@ -146,13 +173,14 @@ function Router({
|
|
|
146
173
|
}
|
|
147
174
|
|
|
148
175
|
try {
|
|
149
|
-
const loaded = await loadPage(manifest,
|
|
176
|
+
const loaded = await loadPage(manifest, pathOnly);
|
|
150
177
|
if (!loaded) {
|
|
151
178
|
window.location.href = to;
|
|
152
179
|
return;
|
|
153
180
|
}
|
|
154
181
|
|
|
155
|
-
applyLoaded(loaded,
|
|
182
|
+
applyLoaded(loaded, pathOnly);
|
|
183
|
+
if (search !== currentSearch) setQueryVersion((v) => v + 1);
|
|
156
184
|
|
|
157
185
|
if (scroll) {
|
|
158
186
|
window.scrollTo(0, 0);
|
|
@@ -168,11 +196,20 @@ function Router({
|
|
|
168
196
|
useEffect(() => {
|
|
169
197
|
async function onPopState() {
|
|
170
198
|
const newPath = window.location.pathname;
|
|
199
|
+
|
|
200
|
+
// Same-path back/forward is a query-only history step (or identical URL);
|
|
201
|
+
// skip the page load and just force re-render of search-param readers.
|
|
202
|
+
if (newPath === pathname) {
|
|
203
|
+
setQueryVersion((v) => v + 1);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
171
207
|
const manifest = manifestRef.current;
|
|
172
208
|
|
|
173
209
|
if (!manifest) {
|
|
174
210
|
setPathname(newPath);
|
|
175
211
|
setPageState((prev) => ({ ...prev, params: {} }));
|
|
212
|
+
setQueryVersion((v) => v + 1);
|
|
176
213
|
return;
|
|
177
214
|
}
|
|
178
215
|
|
|
@@ -180,6 +217,7 @@ function Router({
|
|
|
180
217
|
const loaded = await loadPage(manifest, newPath);
|
|
181
218
|
if (loaded) {
|
|
182
219
|
applyLoaded(loaded, newPath);
|
|
220
|
+
setQueryVersion((v) => v + 1);
|
|
183
221
|
} else {
|
|
184
222
|
window.location.reload();
|
|
185
223
|
}
|
|
@@ -190,7 +228,7 @@ function Router({
|
|
|
190
228
|
|
|
191
229
|
window.addEventListener('popstate', onPopState);
|
|
192
230
|
return () => window.removeEventListener('popstate', onPopState);
|
|
193
|
-
}, [applyLoaded]);
|
|
231
|
+
}, [pathname, applyLoaded]);
|
|
194
232
|
|
|
195
233
|
const { component: PageComponent, layouts, loadings, params } = pageState;
|
|
196
234
|
|
|
@@ -228,6 +266,7 @@ function Router({
|
|
|
228
266
|
pathname,
|
|
229
267
|
params,
|
|
230
268
|
navigate: navigateToPage,
|
|
269
|
+
queryVersion,
|
|
231
270
|
},
|
|
232
271
|
},
|
|
233
272
|
content,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "arcway",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"description": "A convention-based framework for building modular monoliths with strict domain boundaries.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
},
|
|
31
31
|
"scripts": {
|
|
32
32
|
"test": "vitest run --project=unit",
|
|
33
|
+
"test:watch": "VITEST_MAX_WORKERS=2 vitest --project=unit",
|
|
34
|
+
"test:serial": "VITEST_MAX_WORKERS=1 vitest run --project=unit",
|
|
35
|
+
"test:coverage": "vitest run --project=unit --coverage",
|
|
33
36
|
"test:storybook": "TMPDIR=~/tmp PLAYWRIGHT_BROWSERS_PATH=~/.cache/playwright vitest run --project=storybook",
|
|
34
37
|
"test:all": "TMPDIR=~/tmp PLAYWRIGHT_BROWSERS_PATH=~/.cache/playwright vitest run",
|
|
35
38
|
"format": "prettier --write 'server/**/*.js' 'client/**/*.js' 'tests/**/*.js'",
|
package/server/boot/index.js
CHANGED
|
@@ -1,16 +1,10 @@
|
|
|
1
1
|
import { makeConfig } from '../config/loader.js';
|
|
2
|
-
import {
|
|
3
|
-
import Redis from '../redis/index.js';
|
|
4
|
-
import Events from '../events/index.js';
|
|
2
|
+
import { createInfrastructure, destroyInfrastructure } from './infrastructure.js';
|
|
5
3
|
import { EventHandler } from '../events/handler.js';
|
|
6
4
|
import { JobRunner } from '../jobs/runner.js';
|
|
7
|
-
import
|
|
5
|
+
import { WorkerPool } from '../jobs/worker-pool.js';
|
|
8
6
|
import { loadEnvFiles } from '../env.js';
|
|
9
7
|
import { McpRouter } from '../mcp/index.js';
|
|
10
|
-
import Queue from '../queue/index.js';
|
|
11
|
-
import Cache from '../cache/index.js';
|
|
12
|
-
import Files from '../files/index.js';
|
|
13
|
-
import { Mail } from '../mail/index.js';
|
|
14
8
|
import WebServer from '../web-server.js';
|
|
15
9
|
import { FileWatcher } from '../filewatcher/index.js';
|
|
16
10
|
import { ApiRouter } from '../router/api-router.js';
|
|
@@ -27,30 +21,13 @@ async function boot(options) {
|
|
|
27
21
|
const envFiles = loadEnvFiles(rootDir, mode);
|
|
28
22
|
const config = await makeConfig(rootDir, { overrides: options.configOverrides, mode });
|
|
29
23
|
|
|
30
|
-
const
|
|
24
|
+
const infrastructure = await createInfrastructure(config, { runMigrations: true });
|
|
25
|
+
const { db, redis, queue, cache, files, mail, events, log } = infrastructure;
|
|
26
|
+
|
|
31
27
|
const mcpRouter = new McpRouter(config.mcp, { log });
|
|
32
28
|
|
|
33
29
|
if (envFiles.length > 0) log.info('Env files loaded', { envFiles });
|
|
34
30
|
|
|
35
|
-
const db = await createDB(config.database, { log });
|
|
36
|
-
await db.runMigrations();
|
|
37
|
-
|
|
38
|
-
const redis = new Redis(config.redis, { log });
|
|
39
|
-
await redis.connect();
|
|
40
|
-
|
|
41
|
-
const queue = new Queue(config.queue, { db, redis, log });
|
|
42
|
-
await queue.init();
|
|
43
|
-
|
|
44
|
-
const cache = new Cache(config.cache, { redis, log });
|
|
45
|
-
await cache.init();
|
|
46
|
-
|
|
47
|
-
const files = new Files(config.files, { log });
|
|
48
|
-
await files.init();
|
|
49
|
-
|
|
50
|
-
const mail = new Mail(config.mail, { db, log, queue });
|
|
51
|
-
|
|
52
|
-
const events = new Events(config.events, { redis, log });
|
|
53
|
-
|
|
54
31
|
const eventHandler = new EventHandler(config.events, {
|
|
55
32
|
events,
|
|
56
33
|
log,
|
|
@@ -58,7 +35,28 @@ async function boot(options) {
|
|
|
58
35
|
});
|
|
59
36
|
await eventHandler.init();
|
|
60
37
|
|
|
61
|
-
|
|
38
|
+
// Workers rebuild their own infrastructure from a JSON-safe config snapshot,
|
|
39
|
+
// so anything non-serializable on the main-thread config (streams, functions)
|
|
40
|
+
// must not ride through workerData. makeConfig already yields plain objects.
|
|
41
|
+
const workerPool =
|
|
42
|
+
config.jobs?.workerPoolSize > 0
|
|
43
|
+
? new WorkerPool({
|
|
44
|
+
size: config.jobs.workerPoolSize,
|
|
45
|
+
workerData: { config },
|
|
46
|
+
defaultTimeoutMs: config.jobs?.staleTimeoutMs ?? null,
|
|
47
|
+
})
|
|
48
|
+
: null;
|
|
49
|
+
|
|
50
|
+
const jobRunner = new JobRunner(config.jobs, {
|
|
51
|
+
db,
|
|
52
|
+
queue,
|
|
53
|
+
cache,
|
|
54
|
+
files,
|
|
55
|
+
mail,
|
|
56
|
+
events,
|
|
57
|
+
log,
|
|
58
|
+
workerPool,
|
|
59
|
+
});
|
|
62
60
|
await jobRunner.init();
|
|
63
61
|
|
|
64
62
|
const fileWatcher = new FileWatcher(rootDir, { log });
|
|
@@ -115,13 +113,12 @@ async function boot(options) {
|
|
|
115
113
|
|
|
116
114
|
const shutdown = async () => {
|
|
117
115
|
await jobRunner.shutdown();
|
|
116
|
+
if (workerPool) await workerPool.shutdown();
|
|
118
117
|
await pagesRouter.close();
|
|
119
118
|
await apiRouter.close();
|
|
120
119
|
await webServer.close();
|
|
121
120
|
await fileWatcher.close();
|
|
122
|
-
await
|
|
123
|
-
await redis.disconnect();
|
|
124
|
-
await db.destroy();
|
|
121
|
+
await destroyInfrastructure(infrastructure);
|
|
125
122
|
await mcpRouter.cleanup(rootDir);
|
|
126
123
|
};
|
|
127
124
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createDB } from '../db/index.js';
|
|
2
|
+
import Redis from '../redis/index.js';
|
|
3
|
+
import Queue from '../queue/index.js';
|
|
4
|
+
import Cache from '../cache/index.js';
|
|
5
|
+
import Files from '../files/index.js';
|
|
6
|
+
import { Mail } from '../mail/index.js';
|
|
7
|
+
import Events from '../events/index.js';
|
|
8
|
+
import Logger from '../logger/index.js';
|
|
9
|
+
|
|
10
|
+
// Shared service construction for both the main-thread boot path and per-job
|
|
11
|
+
// worker threads (see server/jobs/worker-entry.js). Workers call this with
|
|
12
|
+
// `runMigrations: false` — only the main process should touch schema.
|
|
13
|
+
async function createInfrastructure(config, { runMigrations = false } = {}) {
|
|
14
|
+
const log = new Logger(config.logger);
|
|
15
|
+
|
|
16
|
+
const db = await createDB(config.database, { log });
|
|
17
|
+
if (runMigrations) await db.runMigrations();
|
|
18
|
+
|
|
19
|
+
const redis = new Redis(config.redis, { log });
|
|
20
|
+
await redis.connect();
|
|
21
|
+
|
|
22
|
+
const queue = new Queue(config.queue, { db, redis, log });
|
|
23
|
+
await queue.init();
|
|
24
|
+
|
|
25
|
+
const cache = new Cache(config.cache, { redis, log });
|
|
26
|
+
await cache.init();
|
|
27
|
+
|
|
28
|
+
const files = new Files(config.files, { log });
|
|
29
|
+
await files.init();
|
|
30
|
+
|
|
31
|
+
const mail = new Mail(config.mail, { db, log, queue });
|
|
32
|
+
|
|
33
|
+
const events = new Events(config.events, { redis, log });
|
|
34
|
+
|
|
35
|
+
return { db, redis, queue, cache, files, mail, events, log };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function destroyInfrastructure(services) {
|
|
39
|
+
if (!services) return;
|
|
40
|
+
// Order matches boot/index.js teardown: events → redis → db. Mail/queue/
|
|
41
|
+
// cache/files share redis/db and do not own independent connections.
|
|
42
|
+
await services.events?.disconnect?.();
|
|
43
|
+
await services.redis?.disconnect?.();
|
|
44
|
+
await services.db?.destroy?.();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export { createInfrastructure, destroyInfrastructure };
|
|
@@ -1,5 +1,14 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
|
|
4
|
+
// Leave one core free for the main thread (dispatch/polling/HTTP). 0 disables
|
|
5
|
+
// worker threads entirely and runs handlers inline on the main thread.
|
|
6
|
+
function defaultWorkerPoolSize() {
|
|
7
|
+
const cores =
|
|
8
|
+
typeof os.availableParallelism === 'function' ? os.availableParallelism() : os.cpus().length;
|
|
9
|
+
return Math.max(1, cores - 1);
|
|
10
|
+
}
|
|
11
|
+
|
|
3
12
|
const DEFAULTS = {
|
|
4
13
|
enabled: true,
|
|
5
14
|
backoffMs: 1000,
|
|
@@ -14,6 +23,7 @@ function resolve(config, { rootDir } = {}) {
|
|
|
14
23
|
if (jobs.dir && !path.isAbsolute(jobs.dir)) {
|
|
15
24
|
jobs.dir = path.resolve(rootDir, jobs.dir);
|
|
16
25
|
}
|
|
26
|
+
if (jobs.workerPoolSize === undefined) jobs.workerPoolSize = defaultWorkerPoolSize();
|
|
17
27
|
return { ...config, jobs };
|
|
18
28
|
}
|
|
19
29
|
|
package/server/context.js
CHANGED
package/server/graphql/index.js
CHANGED
|
@@ -2,9 +2,7 @@ import { discoverGraphQL } from './discovery.js';
|
|
|
2
2
|
import { mergeGraphQLSchemas, mergeGraphQLResolvers } from './merge.js';
|
|
3
3
|
import { createLoaderFactory } from './loaders.js';
|
|
4
4
|
import { createGraphQLHandler } from './handler.js';
|
|
5
|
-
import { attachGraphQLSubscriptions } from './subscriptions.js';
|
|
6
5
|
export {
|
|
7
|
-
attachGraphQLSubscriptions,
|
|
8
6
|
createGraphQLHandler,
|
|
9
7
|
createLoaderFactory,
|
|
10
8
|
discoverGraphQL,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { buildContext } from '../../context.js';
|
|
2
1
|
import { validateEnqueue, toError, calculateBackoff } from '../queue.js';
|
|
3
2
|
import { checkDbThroughput } from '../throughput.js';
|
|
4
3
|
import LeaseManager from './lease.js';
|
|
4
|
+
import { runHandler } from './run-handler.js';
|
|
5
5
|
const DEFAULT_STALE_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
6
6
|
class KnexJobQueue {
|
|
7
7
|
db;
|
|
@@ -11,12 +11,14 @@ class KnexJobQueue {
|
|
|
11
11
|
registered = new Map();
|
|
12
12
|
_size = 0;
|
|
13
13
|
leaseManager;
|
|
14
|
+
workerPool;
|
|
14
15
|
constructor(db, options) {
|
|
15
16
|
this.db = db;
|
|
16
17
|
this.tableName = options?.tableName ?? 'arcway_jobs';
|
|
17
18
|
this.backoffMs = options?.backoffMs ?? 1000;
|
|
18
19
|
this.staleTimeoutMs = options?.staleTimeoutMs ?? DEFAULT_STALE_TIMEOUT_MS;
|
|
19
20
|
this.leaseManager = new LeaseManager(db, { tableName: options?.leaseTableName });
|
|
21
|
+
this.workerPool = options?.workerPool ?? null;
|
|
20
22
|
}
|
|
21
23
|
/** Create the jobs table if it doesn't exist. Must be called before use. */
|
|
22
24
|
async init() {
|
|
@@ -39,9 +41,9 @@ class KnexJobQueue {
|
|
|
39
41
|
await this.leaseManager.init();
|
|
40
42
|
await this.syncSize();
|
|
41
43
|
}
|
|
42
|
-
register(domain, definition, store) {
|
|
44
|
+
register(domain, definition, store, filePath) {
|
|
43
45
|
const qualifiedName = `${domain}/${definition.name}`;
|
|
44
|
-
this.registered.set(qualifiedName, { domain, definition, store });
|
|
46
|
+
this.registered.set(qualifiedName, { domain, definition, store, filePath });
|
|
45
47
|
}
|
|
46
48
|
async enqueue(qualifiedName, payload, options) {
|
|
47
49
|
const { reg, validatedPayload, maxRetries, delay } = validateEnqueue(
|
|
@@ -163,8 +165,7 @@ class KnexJobQueue {
|
|
|
163
165
|
|
|
164
166
|
try {
|
|
165
167
|
console.log(`[job] ${qualifiedName} attempt ${attempt}/${maxAttempts}`);
|
|
166
|
-
|
|
167
|
-
await reg.definition.handler(ctx);
|
|
168
|
+
await runHandler(reg, payload, this.workerPool);
|
|
168
169
|
console.log(`[job] ${qualifiedName} completed`);
|
|
169
170
|
await this._updateJob(jobId, { status: 'completed', attempt });
|
|
170
171
|
this._size--;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { buildContext } from '../../context.js';
|
|
2
1
|
import { validateEnqueue, toError, calculateBackoff } from '../queue.js';
|
|
3
2
|
import { MemoryThroughputTracker } from '../throughput.js';
|
|
4
3
|
import LeaseManager from './lease.js';
|
|
4
|
+
import { runHandler } from './run-handler.js';
|
|
5
5
|
|
|
6
6
|
class JobDispatcher {
|
|
7
7
|
queue = [];
|
|
@@ -9,15 +9,17 @@ class JobDispatcher {
|
|
|
9
9
|
backoffMs;
|
|
10
10
|
throughputTracker = new MemoryThroughputTracker();
|
|
11
11
|
leaseManager;
|
|
12
|
+
workerPool;
|
|
12
13
|
|
|
13
14
|
constructor(options) {
|
|
14
15
|
this.backoffMs = options?.backoffMs ?? 1000;
|
|
15
16
|
this.leaseManager = new LeaseManager(null);
|
|
17
|
+
this.workerPool = options?.workerPool ?? null;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
register(domain, definition, store) {
|
|
20
|
+
register(domain, definition, store, filePath) {
|
|
19
21
|
const qualifiedName = `${domain}/${definition.name}`;
|
|
20
|
-
this.registered.set(qualifiedName, { domain, definition, store });
|
|
22
|
+
this.registered.set(qualifiedName, { domain, definition, store, filePath });
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
async enqueue(qualifiedName, payload, options) {
|
|
@@ -89,8 +91,7 @@ class JobDispatcher {
|
|
|
89
91
|
|
|
90
92
|
try {
|
|
91
93
|
console.log(`[job] ${qualifiedName} attempt ${job.attempt}/${maxAttempts}`);
|
|
92
|
-
|
|
93
|
-
await reg.definition.handler(ctx);
|
|
94
|
+
await runHandler(reg, job.payload, this.workerPool);
|
|
94
95
|
job.status = 'completed';
|
|
95
96
|
console.log(`[job] ${qualifiedName} completed`);
|
|
96
97
|
this.throughputTracker.record(qualifiedName);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { buildContext } from '../../context.js';
|
|
2
|
+
|
|
3
|
+
// Dispatch a job handler either inline (main thread, legacy / tests / continuous
|
|
4
|
+
// jobs) or through the WorkerPool. The worker builds its own ctx from the
|
|
5
|
+
// workerData config snapshot and calls handler(ctx) — see worker-entry.js.
|
|
6
|
+
// We only route through the pool when both a pool *and* a filePath are
|
|
7
|
+
// available; system jobs register without a filePath and stay inline on
|
|
8
|
+
// purpose (they use namespaced services that don't round-trip through
|
|
9
|
+
// workerData).
|
|
10
|
+
async function runHandler(reg, payload, workerPool) {
|
|
11
|
+
if (workerPool && reg.filePath) {
|
|
12
|
+
await workerPool.run(
|
|
13
|
+
{ handlerPath: reg.filePath, payload, withContext: true },
|
|
14
|
+
{ timeoutMs: reg.definition.staleTimeout },
|
|
15
|
+
);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const ctx = buildContext(reg.store, { payload });
|
|
19
|
+
await reg.definition.handler(ctx);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export { runHandler };
|
package/server/jobs/runner.js
CHANGED
|
@@ -21,7 +21,13 @@ async function discoverJobs(jobsDir) {
|
|
|
21
21
|
if (!job.name) {
|
|
22
22
|
job.name = relativePath.replace(/\\/g, '/').replace(/\.js$/, '');
|
|
23
23
|
}
|
|
24
|
-
jobs.push({
|
|
24
|
+
jobs.push({
|
|
25
|
+
definition: job,
|
|
26
|
+
fileName: name,
|
|
27
|
+
filePath,
|
|
28
|
+
cooldownMs: job.cooldownMs,
|
|
29
|
+
staleTimeout: job.staleTimeout,
|
|
30
|
+
});
|
|
25
31
|
}
|
|
26
32
|
return jobs;
|
|
27
33
|
}
|
|
@@ -39,10 +45,14 @@ class JobRunner {
|
|
|
39
45
|
_stopped = false;
|
|
40
46
|
_lastEnqueuedMinute = new Map();
|
|
41
47
|
|
|
42
|
-
constructor(config, { db, queue, cache, files, mail, events, log } = {}) {
|
|
48
|
+
constructor(config, { db, queue, cache, files, mail, events, log, workerPool } = {}) {
|
|
43
49
|
this._config = config;
|
|
44
50
|
this._log = log;
|
|
45
|
-
this._dispatcher = new JobDispatcher({
|
|
51
|
+
this._dispatcher = new JobDispatcher({
|
|
52
|
+
backoffMs: config?.backoffMs,
|
|
53
|
+
staleTimeoutMs: config?.staleTimeoutMs,
|
|
54
|
+
workerPool,
|
|
55
|
+
});
|
|
46
56
|
this._appContext = { db, queue, cache, files, mail, events, log };
|
|
47
57
|
}
|
|
48
58
|
|
|
@@ -62,10 +72,11 @@ class JobRunner {
|
|
|
62
72
|
const jobsDir = this._config?.dir;
|
|
63
73
|
if (!jobsDir) return;
|
|
64
74
|
|
|
65
|
-
// Discover and register user jobs
|
|
75
|
+
// Discover and register user jobs. `filePath` is captured so the
|
|
76
|
+
// dispatcher can dynamic-import the handler inside a worker thread.
|
|
66
77
|
const discovered = await discoverJobs(jobsDir);
|
|
67
|
-
for (const { definition, fileName } of discovered) {
|
|
68
|
-
this._dispatcher.register('app', definition, this._appContext);
|
|
78
|
+
for (const { definition, fileName, filePath } of discovered) {
|
|
79
|
+
this._dispatcher.register('app', definition, this._appContext, filePath);
|
|
69
80
|
this._jobs.push({
|
|
70
81
|
jobName: definition.name,
|
|
71
82
|
fileName,
|