@c9up/bay 0.1.13 → 0.2.1
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 +122 -16
- package/dist/BayProvider.d.ts +51 -11
- package/dist/BayProvider.d.ts.map +1 -1
- package/dist/BayProvider.js +36 -13
- package/dist/BayProvider.js.map +1 -1
- package/dist/Job.d.ts +99 -0
- package/dist/Job.d.ts.map +1 -0
- package/dist/Job.js +78 -0
- package/dist/Job.js.map +1 -0
- package/dist/QueueManager.d.ts +140 -21
- package/dist/QueueManager.d.ts.map +1 -1
- package/dist/QueueManager.js +247 -53
- package/dist/QueueManager.js.map +1 -1
- package/dist/adapters.d.ts +68 -0
- package/dist/adapters.d.ts.map +1 -0
- package/dist/adapters.js +56 -0
- package/dist/adapters.js.map +1 -0
- package/dist/augmentations.d.ts +28 -0
- package/dist/augmentations.d.ts.map +1 -0
- package/dist/augmentations.js +17 -0
- package/dist/augmentations.js.map +1 -0
- package/dist/configure.d.ts +1 -0
- package/dist/configure.d.ts.map +1 -1
- package/dist/configure.js +24 -7
- package/dist/configure.js.map +1 -1
- package/dist/console/contract.d.ts +60 -0
- package/dist/console/contract.d.ts.map +1 -0
- package/dist/console/contract.js +36 -0
- package/dist/console/contract.js.map +1 -0
- package/dist/console/index.d.ts +29 -0
- package/dist/console/index.d.ts.map +1 -0
- package/dist/console/index.js +45 -0
- package/dist/console/index.js.map +1 -0
- package/dist/console/makeJob.d.ts +32 -0
- package/dist/console/makeJob.d.ts.map +1 -0
- package/dist/console/makeJob.js +118 -0
- package/dist/console/makeJob.js.map +1 -0
- package/dist/console/queueWork.d.ts +18 -0
- package/dist/console/queueWork.d.ts.map +1 -0
- package/dist/console/queueWork.js +58 -0
- package/dist/console/queueWork.js.map +1 -0
- package/dist/drivers/MemoryDriver.d.ts +14 -8
- package/dist/drivers/MemoryDriver.d.ts.map +1 -1
- package/dist/drivers/MemoryDriver.js +61 -7
- package/dist/drivers/MemoryDriver.js.map +1 -1
- package/dist/drivers/RedisDriver.d.ts +60 -8
- package/dist/drivers/RedisDriver.d.ts.map +1 -1
- package/dist/drivers/RedisDriver.js +214 -30
- package/dist/drivers/RedisDriver.js.map +1 -1
- package/dist/index.d.ts +10 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -4
- package/dist/index.js.map +1 -1
- package/dist/jobs.d.ts +55 -0
- package/dist/jobs.d.ts.map +1 -0
- package/dist/jobs.js +135 -0
- package/dist/jobs.js.map +1 -0
- package/dist/quasar.d.ts +6 -9
- package/dist/quasar.d.ts.map +1 -1
- package/dist/quasar.js +27 -55
- package/dist/quasar.js.map +1 -1
- package/dist/testing/FakeQueue.d.ts +15 -9
- package/dist/testing/FakeQueue.d.ts.map +1 -1
- package/dist/testing/FakeQueue.js +13 -3
- package/dist/testing/FakeQueue.js.map +1 -1
- package/dist/vendor/nodeEnv.d.ts +5 -0
- package/dist/vendor/nodeEnv.d.ts.map +1 -0
- package/dist/{nodeEnv.js → vendor/nodeEnv.js} +13 -7
- package/dist/vendor/nodeEnv.js.map +1 -0
- package/dist/vendor/quasarConnection.d.ts +35 -0
- package/dist/vendor/quasarConnection.d.ts.map +1 -0
- package/dist/vendor/quasarConnection.js +62 -0
- package/dist/vendor/quasarConnection.js.map +1 -0
- package/package.json +5 -3
- package/src/BayProvider.ts +92 -26
- package/src/Job.ts +137 -0
- package/src/QueueManager.ts +411 -56
- package/src/adapters.ts +75 -0
- package/src/augmentations.ts +31 -0
- package/src/configure.ts +25 -7
- package/src/console/contract.ts +94 -0
- package/src/console/index.ts +68 -0
- package/src/console/makeJob.ts +139 -0
- package/src/console/queueWork.ts +70 -0
- package/src/drivers/MemoryDriver.ts +66 -14
- package/src/drivers/RedisDriver.ts +303 -43
- package/src/index.ts +35 -6
- package/src/jobs.ts +144 -0
- package/src/quasar.ts +28 -72
- package/src/testing/FakeQueue.ts +25 -15
- package/src/{nodeEnv.ts → vendor/nodeEnv.ts} +14 -7
- package/src/vendor/quasarConnection.ts +129 -0
- package/dist/nodeEnv.d.ts +0 -16
- package/dist/nodeEnv.d.ts.map +0 -1
- package/dist/nodeEnv.js.map +0 -1
- package/dist/stores.d.ts +0 -41
- package/dist/stores.d.ts.map +0 -1
- package/dist/stores.js +0 -46
- package/dist/stores.js.map +0 -1
- package/src/stores.ts +0 -59
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Teach ream's `ContainerBindings` what `container.make('queue')` returns.
|
|
3
|
+
*
|
|
4
|
+
* ream declares that interface open on purpose: it registers its own entries
|
|
5
|
+
* and expects each package to contribute the one it owns. Nothing filled this
|
|
6
|
+
* one in, so resolving by the string token answered `unknown` and every call
|
|
7
|
+
* site had to assert a type it could not prove.
|
|
8
|
+
*
|
|
9
|
+
* Loaded from the package barrel and from the provider, so registering bay is
|
|
10
|
+
* enough — an application writes no `declare module` of its own.
|
|
11
|
+
*
|
|
12
|
+
* Type-only, and ream stays an OPTIONAL peer: nothing here reaches a runtime
|
|
13
|
+
* import, and a `declare module` for a specifier that does not resolve is
|
|
14
|
+
* simply inert.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Referenced so the augmentation below resolves the module it augments.
|
|
18
|
+
import type {} from "@c9up/ream/types";
|
|
19
|
+
import type { QueueManager } from "./QueueManager.js";
|
|
20
|
+
|
|
21
|
+
declare module "@c9up/ream/types" {
|
|
22
|
+
interface ContainerBindings {
|
|
23
|
+
/** The job queue, bound by `BayProvider`. */
|
|
24
|
+
"bay.queue": QueueManager;
|
|
25
|
+
/**
|
|
26
|
+
* The same binding under the name it had before the token carried its
|
|
27
|
+
* package. Kept bound so an existing `container.make(...)` resolves.
|
|
28
|
+
*/
|
|
29
|
+
queue: QueueManager;
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/configure.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
interface Codemods {
|
|
11
11
|
addProvider(importPath: string): Promise<void>;
|
|
12
|
+
registerCommand(importPath: string): Promise<void>;
|
|
12
13
|
addEnvVars(vars: Record<string, string>): Promise<void>;
|
|
13
14
|
writeFile(
|
|
14
15
|
filePath: string,
|
|
@@ -22,24 +23,41 @@ export async function configure(codemods: Codemods): Promise<void> {
|
|
|
22
23
|
// without them leaves an application whose config asks the environment for
|
|
23
24
|
// something nothing ever put there.
|
|
24
25
|
await codemods.addEnvVars({
|
|
25
|
-
|
|
26
|
+
QUEUE_DRIVER: "memory",
|
|
26
27
|
});
|
|
27
28
|
|
|
28
29
|
await codemods.addProvider("@c9up/bay/provider");
|
|
30
|
+
// `queue:work` and `make:job` are the package's, not the binary's: a
|
|
31
|
+
// project reaches them by listing the module, never by upgrading `ream`.
|
|
32
|
+
await codemods.registerCommand("@c9up/bay/commands");
|
|
29
33
|
await codemods.writeFile(
|
|
30
34
|
"config/queue.ts",
|
|
31
|
-
`import { defineConfig,
|
|
35
|
+
`import { defineConfig, drivers } from '@c9up/bay'
|
|
32
36
|
import env from '#start/env'
|
|
33
37
|
|
|
34
38
|
export default defineConfig({
|
|
35
|
-
// Which
|
|
39
|
+
// Which adapter to run on. Memory forgets everything on restart, which is
|
|
36
40
|
// what a single process in development wants and nothing else does.
|
|
37
|
-
default: env.get('
|
|
41
|
+
default: env.get('QUEUE_DRIVER', 'memory'),
|
|
38
42
|
|
|
39
|
-
|
|
40
|
-
memory:
|
|
41
|
-
redis:
|
|
43
|
+
adapters: {
|
|
44
|
+
memory: drivers.memory(),
|
|
45
|
+
redis: drivers.redis({ connection: 'main' }),
|
|
42
46
|
},
|
|
47
|
+
|
|
48
|
+
// What the worker does between jobs: how long it waits after finding
|
|
49
|
+
// nothing, how often it reclaims jobs a crashed worker left behind, how many
|
|
50
|
+
// it runs at once, and which named queues it serves.
|
|
51
|
+
worker: {
|
|
52
|
+
idleDelay: 2_000,
|
|
53
|
+
stalledInterval: 30_000,
|
|
54
|
+
concurrency: 1,
|
|
55
|
+
// queues: ['critical', 'default'],
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
// Where the job classes live. Every module under here is imported at boot,
|
|
59
|
+
// so a worker resolves a queued record by the class's own name.
|
|
60
|
+
locations: ['app/jobs'],
|
|
43
61
|
})`,
|
|
44
62
|
);
|
|
45
63
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The console command contract, declared locally.
|
|
3
|
+
*
|
|
4
|
+
* Bay stays framework-agnostic: it must not import `@c9up/ream`, so it
|
|
5
|
+
* describes the shape Ream's console kernel dispatches against rather than
|
|
6
|
+
* importing it.
|
|
7
|
+
*
|
|
8
|
+
* Ream's decorators (`@args` / `@flags`) live in the framework, so the helpers
|
|
9
|
+
* below build the same metadata without them.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface CommandOptions {
|
|
13
|
+
/** Boot the application before `run()`. Off by default. */
|
|
14
|
+
startApp?: boolean;
|
|
15
|
+
staysAlive?: boolean;
|
|
16
|
+
allowUnknownFlags?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ArgumentMetaData {
|
|
20
|
+
type: "string" | "spread";
|
|
21
|
+
propertyName: string;
|
|
22
|
+
argumentName: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
required: boolean;
|
|
25
|
+
default?: string | string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface FlagMetaData {
|
|
29
|
+
type: "string" | "boolean" | "number" | "array";
|
|
30
|
+
propertyName: string;
|
|
31
|
+
flagName: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
alias: string[];
|
|
34
|
+
default?: string | string[] | number | boolean;
|
|
35
|
+
required: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The static side the kernel reads. */
|
|
39
|
+
export interface BayCommandClass {
|
|
40
|
+
new (): { run(): Promise<void> | void };
|
|
41
|
+
commandName: string;
|
|
42
|
+
description: string;
|
|
43
|
+
options?: CommandOptions;
|
|
44
|
+
args?: readonly ArgumentMetaData[];
|
|
45
|
+
flags?: readonly FlagMetaData[];
|
|
46
|
+
help?: string | string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `startServer` → `start-server`, matching the framework's decorators. */
|
|
50
|
+
function dashCase(value: string): string {
|
|
51
|
+
return value.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function flag(
|
|
55
|
+
propertyName: string,
|
|
56
|
+
type: FlagMetaData["type"],
|
|
57
|
+
options: {
|
|
58
|
+
flagName?: string;
|
|
59
|
+
description?: string;
|
|
60
|
+
alias?: string[];
|
|
61
|
+
default?: FlagMetaData["default"];
|
|
62
|
+
required?: boolean;
|
|
63
|
+
} = {},
|
|
64
|
+
): FlagMetaData {
|
|
65
|
+
return {
|
|
66
|
+
type,
|
|
67
|
+
propertyName,
|
|
68
|
+
flagName: options.flagName ?? dashCase(propertyName),
|
|
69
|
+
description: options.description,
|
|
70
|
+
alias: options.alias ?? [],
|
|
71
|
+
default: options.default,
|
|
72
|
+
required: options.required ?? false,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function argument(
|
|
77
|
+
propertyName: string,
|
|
78
|
+
options: {
|
|
79
|
+
type?: ArgumentMetaData["type"];
|
|
80
|
+
argumentName?: string;
|
|
81
|
+
description?: string;
|
|
82
|
+
required?: boolean;
|
|
83
|
+
default?: ArgumentMetaData["default"];
|
|
84
|
+
} = {},
|
|
85
|
+
): ArgumentMetaData {
|
|
86
|
+
return {
|
|
87
|
+
type: options.type ?? "string",
|
|
88
|
+
propertyName,
|
|
89
|
+
argumentName: options.argumentName ?? dashCase(propertyName),
|
|
90
|
+
description: options.description,
|
|
91
|
+
required: options.required ?? options.default === undefined,
|
|
92
|
+
default: options.default,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@c9up/bay/commands` — the commands bay ships.
|
|
3
|
+
*
|
|
4
|
+
* // reamrc.ts, written by `configure()`
|
|
5
|
+
* commands: [() => import('@c9up/bay/commands')]
|
|
6
|
+
*
|
|
7
|
+
* A module answering `getMetaData()` / `getCommand()`, which is how a package
|
|
8
|
+
* adds commands: by shipping them, never by a change to the `ream` binary.
|
|
9
|
+
*
|
|
10
|
+
* `jobsDir` is read through a getter, not captured: these classes are built
|
|
11
|
+
* when the module is imported — before the application boots — while `run()`
|
|
12
|
+
* happens after it, when the config exists.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { getJobsDir } from "../jobs.js";
|
|
16
|
+
import type { BayCommandClass } from "./contract.js";
|
|
17
|
+
import { makeJobCommand } from "./makeJob.js";
|
|
18
|
+
import { queueWorkCommand } from "./queueWork.js";
|
|
19
|
+
|
|
20
|
+
const COMMANDS: readonly BayCommandClass[] = [
|
|
21
|
+
queueWorkCommand(),
|
|
22
|
+
makeJobCommand({
|
|
23
|
+
get jobsDir() {
|
|
24
|
+
return getJobsDir();
|
|
25
|
+
},
|
|
26
|
+
}),
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/** What the kernel reads to list a command without importing it. */
|
|
30
|
+
interface CommandMetaData {
|
|
31
|
+
commandName: string;
|
|
32
|
+
namespace: string | null;
|
|
33
|
+
description: string;
|
|
34
|
+
help?: string | string[];
|
|
35
|
+
aliases: string[];
|
|
36
|
+
options: Record<string, unknown>;
|
|
37
|
+
args: readonly unknown[];
|
|
38
|
+
flags: readonly unknown[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function serialize(command: BayCommandClass): CommandMetaData {
|
|
42
|
+
const colon = command.commandName.indexOf(":");
|
|
43
|
+
return {
|
|
44
|
+
commandName: command.commandName,
|
|
45
|
+
namespace: colon === -1 ? null : command.commandName.slice(0, colon),
|
|
46
|
+
description: command.description,
|
|
47
|
+
help: command.help,
|
|
48
|
+
aliases: [],
|
|
49
|
+
options: { ...command.options },
|
|
50
|
+
args: command.args ?? [],
|
|
51
|
+
flags: command.flags ?? [],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function getMetaData(): Promise<CommandMetaData[]> {
|
|
56
|
+
return COMMANDS.map(serialize);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function getCommand(
|
|
60
|
+
metadata: CommandMetaData,
|
|
61
|
+
): Promise<BayCommandClass | null> {
|
|
62
|
+
return (
|
|
63
|
+
COMMANDS.find((command) => command.commandName === metadata.commandName) ??
|
|
64
|
+
null
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type { BayCommandClass } from "./contract.js";
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `make:job` — scaffold a job class.
|
|
3
|
+
*
|
|
4
|
+
* ream make:job SendWelcomeEmail → app/jobs/send_welcome_email.ts
|
|
5
|
+
* ream make:job emails/SendWelcomeEmail → app/jobs/emails/send_welcome_email.ts
|
|
6
|
+
*
|
|
7
|
+
* A subdirectory is allowed and is the only reason the name is not a plain
|
|
8
|
+
* identifier: `..` and every other way out of the jobs directory is refused
|
|
9
|
+
* before anything is written.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as fsp from "node:fs/promises";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { argument, type BayCommandClass } from "./contract.js";
|
|
15
|
+
|
|
16
|
+
export interface MakeJobOptions {
|
|
17
|
+
/** Directory the job files are scaffolded into. */
|
|
18
|
+
jobsDir: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `SendWelcomeEmail` → `send_welcome_email`, `HTTPPing` → `http_ping`. */
|
|
22
|
+
export function toSnakeCase(name: string): string {
|
|
23
|
+
return name
|
|
24
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
25
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
|
|
26
|
+
.replace(/[-\s]+/g, "_")
|
|
27
|
+
.toLowerCase();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `send_welcome_email` / `sendWelcomeEmail` → `SendWelcomeEmail`. */
|
|
31
|
+
export function toPascalCase(name: string): string {
|
|
32
|
+
return name
|
|
33
|
+
.split(/[_\-\s]+/)
|
|
34
|
+
.filter((part) => part.length > 0)
|
|
35
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
36
|
+
.join("");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Split `emails/SendWelcomeEmail` into the directory it goes in and the class
|
|
41
|
+
* it declares, refusing anything that would leave the jobs directory.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveJobName(input: string): {
|
|
44
|
+
dir: string;
|
|
45
|
+
className: string;
|
|
46
|
+
fileName: string;
|
|
47
|
+
} {
|
|
48
|
+
const parts = input.split("/").filter((part) => part.length > 0);
|
|
49
|
+
const last = parts.pop();
|
|
50
|
+
if (last === undefined || last.length === 0) {
|
|
51
|
+
throw new Error(`Invalid job name: '${input}'`);
|
|
52
|
+
}
|
|
53
|
+
for (const part of [...parts, last]) {
|
|
54
|
+
// `..` is the traversal; the rest are characters a filename has no
|
|
55
|
+
// business carrying and a shell has opinions about.
|
|
56
|
+
if (part === ".." || /[\\'";`]/.test(part)) {
|
|
57
|
+
throw new Error(`Invalid job name: '${input}'`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const className = toPascalCase(last);
|
|
61
|
+
if (!/^[A-Za-z][A-Za-z0-9]*$/.test(className)) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Invalid job name: '${input}' — a job's name becomes a class name`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
dir: parts.join("/"),
|
|
68
|
+
className,
|
|
69
|
+
fileName: `${toSnakeCase(last)}.ts`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The file a fresh job starts as. */
|
|
74
|
+
export function jobStub(className: string): string {
|
|
75
|
+
return `import { Job } from '@c9up/bay'
|
|
76
|
+
import type { JobOptions } from '@c9up/bay'
|
|
77
|
+
|
|
78
|
+
interface ${className}Payload {
|
|
79
|
+
// What \`dispatch(${className}, …)\` must be given.
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export default class ${className} extends Job<${className}Payload> {
|
|
83
|
+
static options: JobOptions = {
|
|
84
|
+
// queue: 'default',
|
|
85
|
+
// maxRetries: 3,
|
|
86
|
+
// delay: '10s',
|
|
87
|
+
// timeout: '1m',
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async execute(): Promise<void> {
|
|
91
|
+
// The work. Throwing is what makes the attempt fail.
|
|
92
|
+
void this.payload
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async failed(error: Error): Promise<void> {
|
|
96
|
+
// Once the last attempt has failed — the alert or the cleanup.
|
|
97
|
+
void error
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function makeJobCommand(options: MakeJobOptions): BayCommandClass {
|
|
104
|
+
return class MakeJob {
|
|
105
|
+
static commandName = "make:job";
|
|
106
|
+
static description = "Scaffold a background job class";
|
|
107
|
+
// Pure filesystem work: no reason to boot the app and open a connection.
|
|
108
|
+
static options = { startApp: false };
|
|
109
|
+
static args = [
|
|
110
|
+
argument("name", {
|
|
111
|
+
description: "Job class name, optionally under a subdirectory",
|
|
112
|
+
}),
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
declare name: string;
|
|
116
|
+
|
|
117
|
+
async run(): Promise<void> {
|
|
118
|
+
let resolved: ReturnType<typeof resolveJobName>;
|
|
119
|
+
try {
|
|
120
|
+
resolved = resolveJobName(this.name);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
console.error(
|
|
123
|
+
`[bay] ${err instanceof Error ? err.message : String(err)}`,
|
|
124
|
+
);
|
|
125
|
+
process.exitCode = 1;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const dir = path.join(options.jobsDir, resolved.dir);
|
|
130
|
+
const filePath = path.join(dir, resolved.fileName);
|
|
131
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
132
|
+
// `wx`: an existing job is never clobbered, and the error says so.
|
|
133
|
+
await fsp.writeFile(filePath, jobStub(resolved.className), {
|
|
134
|
+
flag: "wx",
|
|
135
|
+
});
|
|
136
|
+
console.log(`Created ${filePath}`);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `queue:work` — run the worker that takes jobs off the queue.
|
|
3
|
+
*
|
|
4
|
+
* The command a deployment runs as its own process, beside the HTTP one:
|
|
5
|
+
*
|
|
6
|
+
* ream queue:work
|
|
7
|
+
* ream queue:work --queue=emails,notifications
|
|
8
|
+
* ream queue:work --concurrency=10
|
|
9
|
+
*
|
|
10
|
+
* It stays alive on purpose — `staysAlive` — and the loop it starts is the
|
|
11
|
+
* application's, so a SIGTERM reaches `BayProvider.shutdown()`, which stops the
|
|
12
|
+
* worker and lets the job in flight finish rather than dropping it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { getQueue } from "../services/main.js";
|
|
16
|
+
import { type BayCommandClass, flag } from "./contract.js";
|
|
17
|
+
|
|
18
|
+
/** Split `--queue=a,b` into names, dropping the empties a trailing comma makes. */
|
|
19
|
+
export function parseQueues(value: string | undefined): string[] | undefined {
|
|
20
|
+
if (value === undefined) return undefined;
|
|
21
|
+
const names = value
|
|
22
|
+
.split(",")
|
|
23
|
+
.map((name) => name.trim())
|
|
24
|
+
.filter((name) => name.length > 0);
|
|
25
|
+
return names.length > 0 ? names : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function queueWorkCommand(): BayCommandClass {
|
|
29
|
+
return class QueueWork {
|
|
30
|
+
static commandName = "queue:work";
|
|
31
|
+
static description = "Process queued jobs until the process is stopped";
|
|
32
|
+
// The worker needs the container: the queue it drains is the one the
|
|
33
|
+
// provider booted, with the driver the config named.
|
|
34
|
+
static options = { startApp: true, staysAlive: true };
|
|
35
|
+
static flags = [
|
|
36
|
+
flag("queue", "string", {
|
|
37
|
+
description:
|
|
38
|
+
"Comma-separated queues to serve, in order (default: the default queue)",
|
|
39
|
+
}),
|
|
40
|
+
flag("concurrency", "number", {
|
|
41
|
+
description: "How many jobs to run at once (default: 1)",
|
|
42
|
+
}),
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
declare queue?: string;
|
|
46
|
+
declare concurrency?: number;
|
|
47
|
+
|
|
48
|
+
async run(): Promise<void> {
|
|
49
|
+
const manager = getQueue();
|
|
50
|
+
if (!manager) {
|
|
51
|
+
// Naming the wiring beats a stack trace out of the service proxy:
|
|
52
|
+
// the usual cause is a project that never added the provider.
|
|
53
|
+
throw new Error(
|
|
54
|
+
"[bay] queue:work found no queue. Add `() => import('@c9up/bay/provider')` " +
|
|
55
|
+
"to the providers in reamrc.ts, or call setQueue(myQueue) at boot.",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const queues = parseQueues(this.queue);
|
|
60
|
+
process.stdout.write(
|
|
61
|
+
`[bay] worker started — queues: ${(queues ?? ["default"]).join(", ")}, concurrency: ${this.concurrency ?? 1}\n`,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
await manager.work({
|
|
65
|
+
queues,
|
|
66
|
+
concurrency: this.concurrency,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -1,27 +1,71 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Memory queue driver — in-process queue for development.
|
|
2
|
+
* Memory queue driver — in-process queue for development and tests.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import
|
|
5
|
+
import { DEFAULT_QUEUE } from "../Job.js";
|
|
6
|
+
import { type JobRecord, type QueueDriver, queueOf } from "../QueueManager.js";
|
|
6
7
|
|
|
7
8
|
export class MemoryDriver implements QueueDriver {
|
|
8
|
-
|
|
9
|
-
#
|
|
9
|
+
/** One list per named queue, created on first use. */
|
|
10
|
+
#pending: Map<string, JobRecord[]> = new Map();
|
|
11
|
+
/**
|
|
12
|
+
* Jobs whose `runAt` has not arrived, oldest deadline first.
|
|
13
|
+
*
|
|
14
|
+
* Kept apart from the queues rather than filtered on the way out: a delayed
|
|
15
|
+
* job at the head of a list would otherwise be skipped over on every poll,
|
|
16
|
+
* and a queue whose head is not due would look empty while it is not.
|
|
17
|
+
*/
|
|
18
|
+
#delayed: JobRecord[] = [];
|
|
19
|
+
#failedJobs: JobRecord[] = [];
|
|
10
20
|
#maxFailedJobs: number;
|
|
11
21
|
|
|
12
22
|
constructor(options?: { maxFailedJobs?: number }) {
|
|
13
23
|
this.#maxFailedJobs = options?.maxFailedJobs ?? 1000;
|
|
14
24
|
}
|
|
15
25
|
|
|
16
|
-
|
|
17
|
-
this.#pending.
|
|
26
|
+
#queue(name: string): JobRecord[] {
|
|
27
|
+
const existing = this.#pending.get(name);
|
|
28
|
+
if (existing !== undefined) return existing;
|
|
29
|
+
const created: JobRecord[] = [];
|
|
30
|
+
this.#pending.set(name, created);
|
|
31
|
+
return created;
|
|
18
32
|
}
|
|
19
33
|
|
|
20
|
-
|
|
21
|
-
|
|
34
|
+
/** Move everything whose delay has elapsed into its queue. */
|
|
35
|
+
#promoteDue(now = Date.now()): void {
|
|
36
|
+
if (this.#delayed.length === 0) return;
|
|
37
|
+
const due = this.#delayed.filter((job) => (job.runAt ?? 0) <= now);
|
|
38
|
+
if (due.length === 0) return;
|
|
39
|
+
this.#delayed = this.#delayed.filter((job) => (job.runAt ?? 0) > now);
|
|
40
|
+
for (const job of due) {
|
|
41
|
+
job.runAt = undefined;
|
|
42
|
+
this.#queue(queueOf(job)).push(job);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async push(job: JobRecord): Promise<void> {
|
|
47
|
+
if (job.runAt !== undefined && job.runAt > Date.now()) {
|
|
48
|
+
this.#delayed.push(job);
|
|
49
|
+
this.#delayed.sort((a, b) => (a.runAt ?? 0) - (b.runAt ?? 0));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
this.#queue(queueOf(job)).push(job);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async pop(
|
|
56
|
+
queues: readonly string[] = [DEFAULT_QUEUE],
|
|
57
|
+
): Promise<JobRecord | null> {
|
|
58
|
+
this.#promoteDue();
|
|
59
|
+
// In the order given: naming `['critical', 'default']` is how a worker
|
|
60
|
+
// says which queue it would rather drain first.
|
|
61
|
+
for (const name of queues) {
|
|
62
|
+
const next = this.#pending.get(name)?.shift();
|
|
63
|
+
if (next !== undefined) return next;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
22
66
|
}
|
|
23
67
|
|
|
24
|
-
async fail(job:
|
|
68
|
+
async fail(job: JobRecord, error: string): Promise<void> {
|
|
25
69
|
job.error = error;
|
|
26
70
|
job.status = "failed";
|
|
27
71
|
this.#failedJobs.push(job);
|
|
@@ -30,20 +74,28 @@ export class MemoryDriver implements QueueDriver {
|
|
|
30
74
|
}
|
|
31
75
|
}
|
|
32
76
|
|
|
33
|
-
async complete(_job:
|
|
77
|
+
async complete(_job: JobRecord): Promise<void> {
|
|
34
78
|
// Nothing to do for memory driver
|
|
35
79
|
}
|
|
36
80
|
|
|
37
|
-
async retry(job:
|
|
81
|
+
async retry(job: JobRecord): Promise<void> {
|
|
38
82
|
job.status = "pending";
|
|
39
|
-
this.#
|
|
83
|
+
this.#queue(queueOf(job)).push(job);
|
|
40
84
|
}
|
|
41
85
|
|
|
42
|
-
async failed(): Promise<
|
|
86
|
+
async failed(): Promise<JobRecord[]> {
|
|
43
87
|
return [...this.#failedJobs];
|
|
44
88
|
}
|
|
45
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Everything waiting, across every queue — a delayed job included.
|
|
92
|
+
*
|
|
93
|
+
* It is queued; it is simply not due. Leaving it out would report an empty
|
|
94
|
+
* queue to anything draining one before shutdown.
|
|
95
|
+
*/
|
|
46
96
|
async size(): Promise<number> {
|
|
47
|
-
|
|
97
|
+
let total = this.#delayed.length;
|
|
98
|
+
for (const jobs of this.#pending.values()) total += jobs.length;
|
|
99
|
+
return total;
|
|
48
100
|
}
|
|
49
101
|
}
|