@c9up/bay 0.1.11 → 0.1.13

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.
Files changed (42) hide show
  1. package/README.md +66 -1
  2. package/dist/BayProvider.d.ts +24 -30
  3. package/dist/BayProvider.d.ts.map +1 -1
  4. package/dist/BayProvider.js +58 -9
  5. package/dist/BayProvider.js.map +1 -1
  6. package/dist/QueueManager.d.ts +7 -5
  7. package/dist/QueueManager.d.ts.map +1 -1
  8. package/dist/QueueManager.js +75 -31
  9. package/dist/QueueManager.js.map +1 -1
  10. package/dist/configure.d.ts +18 -0
  11. package/dist/configure.d.ts.map +1 -0
  12. package/dist/configure.js +31 -0
  13. package/dist/configure.js.map +1 -0
  14. package/dist/drivers/RedisDriver.d.ts +6 -0
  15. package/dist/drivers/RedisDriver.d.ts.map +1 -1
  16. package/dist/drivers/RedisDriver.js +49 -17
  17. package/dist/drivers/RedisDriver.js.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/nodeEnv.d.ts +16 -0
  23. package/dist/nodeEnv.d.ts.map +1 -0
  24. package/dist/nodeEnv.js +32 -0
  25. package/dist/nodeEnv.js.map +1 -0
  26. package/dist/services/main.d.ts +5 -0
  27. package/dist/services/main.d.ts.map +1 -1
  28. package/dist/services/main.js +7 -0
  29. package/dist/services/main.js.map +1 -1
  30. package/dist/stores.d.ts +41 -0
  31. package/dist/stores.d.ts.map +1 -0
  32. package/dist/stores.js +46 -0
  33. package/dist/stores.js.map +1 -0
  34. package/package.json +5 -1
  35. package/src/BayProvider.ts +84 -19
  36. package/src/QueueManager.ts +77 -32
  37. package/src/configure.ts +45 -0
  38. package/src/drivers/RedisDriver.ts +69 -22
  39. package/src/index.ts +1 -0
  40. package/src/nodeEnv.ts +30 -0
  41. package/src/services/main.ts +8 -0
  42. package/src/stores.ts +59 -0
@@ -40,19 +40,22 @@ export interface QueueDriver {
40
40
  }
41
41
 
42
42
  export class QueueManager {
43
- private driver: QueueDriver;
44
- private handlers: Map<string, JobHandler | (new () => JobHandler)> =
45
- new Map();
46
- private running = false;
47
- private inflightPromise: Promise<boolean> | null = null;
43
+ #driver: QueueDriver;
44
+ #handlers: Map<string, JobHandler | (new () => JobHandler)> = new Map();
45
+ #running = false;
46
+ /** The running loop, so `stop()` can wait for it to finish. */
47
+ #loopPromise: Promise<void> | undefined;
48
+ /** Cuts the sleep between polls short. */
49
+ #wake: (() => void) | undefined;
50
+ #inflightPromise: Promise<boolean> | null = null;
48
51
 
49
52
  constructor(driver: QueueDriver) {
50
- this.driver = driver;
53
+ this.#driver = driver;
51
54
  }
52
55
 
53
56
  /** Register a job handler. */
54
57
  register(name: string, handler: JobHandler | (new () => JobHandler)): void {
55
- this.handlers.set(name, handler);
58
+ this.#handlers.set(name, handler);
56
59
  }
57
60
 
58
61
  /** Dispatch a job to the queue. */
@@ -74,21 +77,24 @@ export class QueueManager {
74
77
  status: "pending",
75
78
  createdAt: Date.now(),
76
79
  };
77
- await this.driver.push(job);
80
+ await this.#driver.push(job);
78
81
  return id;
79
82
  }
80
83
 
81
84
  /** Process the next job in the queue. */
82
85
  async processOne(): Promise<boolean> {
83
- const job = await this.driver.pop();
86
+ const job = await this.#driver.pop();
84
87
  if (!job) return false;
85
88
 
86
- const handlerOrClass = this.handlers.get(job.name);
89
+ const handlerOrClass = this.#handlers.get(job.name);
87
90
  if (!handlerOrClass) {
88
91
  process.stderr.write(
89
92
  `QueueManager: no handler registered for job '${job.name}'\n`,
90
93
  );
91
- await this.driver.fail(job, `No handler registered for job: ${job.name}`);
94
+ await this.#driver.fail(
95
+ job,
96
+ `No handler registered for job: ${job.name}`,
97
+ );
92
98
  return true;
93
99
  }
94
100
 
@@ -103,16 +109,16 @@ export class QueueManager {
103
109
  try {
104
110
  await handler.handle(job.payload);
105
111
  job.status = "completed";
106
- await this.driver.complete(job);
112
+ await this.#driver.complete(job);
107
113
  } catch (err) {
108
114
  const errorMsg = err instanceof Error ? err.message : String(err);
109
115
  if (job.attempts < job.maxAttempts) {
110
116
  job.status = "pending";
111
- await this.driver.retry(job);
117
+ await this.#driver.retry(job);
112
118
  } else {
113
119
  job.status = "failed";
114
120
  job.error = errorMsg;
115
- await this.driver.fail(job, errorMsg);
121
+ await this.#driver.fail(job, errorMsg);
116
122
  }
117
123
  }
118
124
 
@@ -132,34 +138,65 @@ export class QueueManager {
132
138
  if (recoverStaleMs <= 0) {
133
139
  throw new Error("recoverStaleMs must be positive");
134
140
  }
135
- if (this.running) {
141
+ if (this.#running) {
136
142
  throw new Error("QueueManager is already running");
137
143
  }
138
- this.running = true;
144
+ this.#running = true;
145
+ const loop = this.#loop(pollIntervalMs, recoverStaleMs);
146
+ this.#loopPromise = loop;
147
+ try {
148
+ await loop;
149
+ } finally {
150
+ this.#loopPromise = undefined;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * The polling loop itself.
156
+ *
157
+ * Between jobs it sleeps, and that sleep is CANCELLABLE: `stop()` wakes it
158
+ * rather than waiting out the interval. Without that, stopping returned
159
+ * while the loop was still pending — up to a full poll interval of a worker
160
+ * that was supposed to be gone, and a timer holding the process open.
161
+ */
162
+ async #loop(pollIntervalMs: number, recoverStaleMs: number): Promise<void> {
139
163
  await this.#tryRecoverStale();
140
164
  let lastRecover = Date.now();
141
- while (this.running) {
165
+ while (this.#running) {
142
166
  try {
143
- this.inflightPromise = this.processOne();
144
- const processed = await this.inflightPromise;
145
- if (!processed) {
146
- await new Promise((r) => setTimeout(r, pollIntervalMs));
147
- }
167
+ this.#inflightPromise = this.processOne();
168
+ const processed = await this.#inflightPromise;
169
+ if (!processed) await this.#sleep(pollIntervalMs);
148
170
  } catch (err) {
149
171
  process.stderr.write(
150
172
  `QueueManager processOne error: ${err instanceof Error ? err.message : String(err)}\n`,
151
173
  );
152
- await new Promise((r) => setTimeout(r, pollIntervalMs));
174
+ await this.#sleep(pollIntervalMs);
153
175
  } finally {
154
- this.inflightPromise = null;
176
+ this.#inflightPromise = null;
155
177
  }
156
- if (this.running && Date.now() - lastRecover >= recoverStaleMs) {
178
+ if (this.#running && Date.now() - lastRecover >= recoverStaleMs) {
157
179
  await this.#tryRecoverStale();
158
180
  lastRecover = Date.now();
159
181
  }
160
182
  }
161
183
  }
162
184
 
185
+ /** Wait, unless `stop()` says otherwise first. */
186
+ #sleep(ms: number): Promise<void> {
187
+ return new Promise((resolve) => {
188
+ const timer = setTimeout(() => {
189
+ this.#wake = undefined;
190
+ resolve();
191
+ }, ms);
192
+ this.#wake = () => {
193
+ clearTimeout(timer);
194
+ this.#wake = undefined;
195
+ resolve();
196
+ };
197
+ });
198
+ }
199
+
163
200
  /** recoverStale() wrapper that swallows driver errors — used by the work loop. */
164
201
  async #tryRecoverStale(): Promise<void> {
165
202
  try {
@@ -178,29 +215,37 @@ export class QueueManager {
178
215
  * Called automatically by work(); also safe to schedule manually.
179
216
  */
180
217
  async recoverStale(): Promise<number> {
181
- return (await this.driver.recoverStale?.()) ?? 0;
218
+ return (await this.#driver.recoverStale?.()) ?? 0;
182
219
  }
183
220
 
184
221
  /** Await the currently in-flight processOne, if any. */
185
222
  async drain(): Promise<void> {
186
- if (this.inflightPromise) {
187
- await this.inflightPromise.catch(() => {});
223
+ if (this.#inflightPromise) {
224
+ await this.#inflightPromise.catch(() => {});
188
225
  }
189
226
  }
190
227
 
191
- /** Stop the worker. */
228
+ /**
229
+ * Stop the worker and wait for it to actually be gone.
230
+ *
231
+ * Awaits the LOOP, not just the job in flight: a stop that returns while
232
+ * the loop is still sleeping leaves a worker running past the teardown that
233
+ * asked it to stop.
234
+ */
192
235
  async stop(): Promise<void> {
193
- this.running = false;
236
+ this.#running = false;
237
+ this.#wake?.();
194
238
  await this.drain();
239
+ if (this.#loopPromise) await this.#loopPromise.catch(() => {});
195
240
  }
196
241
 
197
242
  /** Get failed jobs. */
198
243
  async failedJobs(): Promise<Job[]> {
199
- return this.driver.failed();
244
+ return this.#driver.failed();
200
245
  }
201
246
 
202
247
  /** Get queue size. */
203
248
  async size(): Promise<number> {
204
- return this.driver.size();
249
+ return this.#driver.size();
205
250
  }
206
251
  }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `ream configure @c9up/bay` — wire the job queue in one command.
3
+ *
4
+ * The provider alone is not enough: it reads `config/queue.ts`, and a package
5
+ * registered without one falls back to a default that is rarely the one an
6
+ * application wants. Writing both together is what makes `ream add` mean
7
+ * installed AND working.
8
+ */
9
+
10
+ interface Codemods {
11
+ addProvider(importPath: string): Promise<void>;
12
+ addEnvVars(vars: Record<string, string>): Promise<void>;
13
+ writeFile(
14
+ filePath: string,
15
+ content: string,
16
+ options?: { force?: boolean },
17
+ ): Promise<void>;
18
+ }
19
+
20
+ export async function configure(codemods: Codemods): Promise<void> {
21
+ // The config below reads these, so they are declared here. Writing the file
22
+ // without them leaves an application whose config asks the environment for
23
+ // something nothing ever put there.
24
+ await codemods.addEnvVars({
25
+ QUEUE_STORE: "memory",
26
+ });
27
+
28
+ await codemods.addProvider("@c9up/bay/provider");
29
+ await codemods.writeFile(
30
+ "config/queue.ts",
31
+ `import { defineConfig, stores } from '@c9up/bay'
32
+ import env from '#start/env'
33
+
34
+ export default defineConfig({
35
+ // Which store to run on. Memory forgets everything on restart, which is
36
+ // what a single process in development wants and nothing else does.
37
+ default: env.get('QUEUE_STORE', 'memory'),
38
+
39
+ stores: {
40
+ memory: stores.memory(),
41
+ redis: stores.redis({ connection: 'main' }),
42
+ },
43
+ })`,
44
+ );
45
+ }
@@ -18,6 +18,7 @@
18
18
  * needs a thin adapter.
19
19
  */
20
20
 
21
+ import { inProduction } from "../nodeEnv.js";
21
22
  import type { Job, QueueDriver } from "../QueueManager.js";
22
23
 
23
24
  export interface RedisClient {
@@ -65,13 +66,35 @@ export type RedisClientSource =
65
66
  * has no client to inspect yet.
66
67
  */
67
68
  const warned = new WeakSet<object>();
68
- function warnWithoutLmove(client: RedisClient): void {
69
- if (typeof client.lmove === "function" || warned.has(client)) return;
69
+ function checkLmove(client: RedisClient, allowNonAtomicPop: boolean): void {
70
+ if (typeof client.lmove === "function") return;
71
+
72
+ // A queue's whole promise is that a job it accepted gets run. Without LMOVE
73
+ // the pop is `lpop` then `rpush`, and a crash between the two deletes the
74
+ // job from pending before it reaches processing: nothing recovers it,
75
+ // because nothing knows it existed. That is a different product, and in
76
+ // production it must be asked for rather than fallen into.
77
+ if (inProduction() && !allowNonAtomicPop) {
78
+ throw new Error(
79
+ "[bay] this Redis client has no LMOVE (Redis < 6.2), so pop() would be a non-atomic lpop+rpush — " +
80
+ "a crash between the two loses the in-flight job, turning at-least-once delivery into at-most-once.\n" +
81
+ " Upgrade to Redis 6.2 or later, or pass `allowNonAtomicPop: true` to state that losing a job is acceptable here.",
82
+ );
83
+ }
84
+ if (warned.has(client)) return;
70
85
  warned.add(client);
86
+
87
+ // Said even when the deployment opted in: agreeing to lose a job once, in a
88
+ // config file, is not the same as being reminded that this process is
89
+ // running that way. The line has to be in the logs of the incident.
90
+ const optedIn = inProduction() && allowNonAtomicPop;
71
91
  console.warn(
72
92
  "[bay] RedisDriver: client lacks LMOVE (Redis <6.2). pop() falls back to " +
73
93
  "a non-atomic lpop+rpush, downgrading delivery from at-least-once to " +
74
- "at-most-once — a crash between the two commands loses the in-flight job.",
94
+ "at-most-once — a crash between the two commands loses the in-flight job." +
95
+ (optedIn
96
+ ? "\n Running this way in PRODUCTION because allowNonAtomicPop was set."
97
+ : ""),
75
98
  );
76
99
  }
77
100
 
@@ -101,7 +124,7 @@ export class RedisDriver implements QueueDriver {
101
124
  if (this.#resolved) return this.#resolved;
102
125
  if (typeof this.#source !== "function") {
103
126
  this.#resolved = this.#source;
104
- warnWithoutLmove(this.#resolved);
127
+ checkLmove(this.#resolved, this.#allowNonAtomicPop);
105
128
  return this.#resolved;
106
129
  }
107
130
  if (!this.#pending) {
@@ -109,7 +132,7 @@ export class RedisDriver implements QueueDriver {
109
132
  this.#pending = Promise.resolve(resolver())
110
133
  .then((client) => {
111
134
  this.#resolved = client;
112
- warnWithoutLmove(client);
135
+ checkLmove(client, this.#allowNonAtomicPop);
113
136
  return client;
114
137
  })
115
138
  // Cleared on failure too. Clearing only on success left the
@@ -125,18 +148,30 @@ export class RedisDriver implements QueueDriver {
125
148
 
126
149
  constructor(
127
150
  source: RedisClientSource,
128
- options?: { prefix?: string; visibilityTimeoutMs?: number },
151
+ options?: {
152
+ prefix?: string;
153
+ visibilityTimeoutMs?: number;
154
+ /**
155
+ * Accept the non-atomic pop on a Redis older than 6.2, in
156
+ * production. Off by default: losing an accepted job is a choice a
157
+ * deployment makes, not one a version check makes for it.
158
+ */
159
+ allowNonAtomicPop?: boolean;
160
+ },
129
161
  ) {
130
162
  this.#source = source;
131
163
  // A client handed in directly can be checked now, so the warning keeps
132
164
  // landing at construction as it always did. A named connection has no
133
165
  // client yet — it is checked when the connection resolves.
134
- if (typeof source !== "function") warnWithoutLmove(source);
166
+ if (typeof source !== "function") {
167
+ checkLmove(source, options?.allowNonAtomicPop ?? false);
168
+ }
135
169
  // Normalised rather than documented: every key is built by concatenation
136
170
  // (`${prefix}pending`), so a prefix without a trailing separator yields
137
171
  // "myapppending" — unreadable, and able to collide with a neighbouring
138
172
  // prefix. Nothing warned, because nothing failed.
139
173
  this.#prefix = withSeparator(options?.prefix ?? "queue:");
174
+ this.#allowNonAtomicPop = options?.allowNonAtomicPop ?? false;
140
175
  const visibilityTimeout = options?.visibilityTimeoutMs ?? 30_000;
141
176
  // A non-positive / non-integer timeout makes pop()'s `SET … PX <ms>` fail
142
177
  // on a real Redis; the catch then removes the job from `processing` and
@@ -152,6 +187,7 @@ export class RedisDriver implements QueueDriver {
152
187
 
153
188
  #pendingKey = () => `${this.#prefix}pending`;
154
189
  #processingKey = () => `${this.#prefix}processing`;
190
+ #allowNonAtomicPop = false;
155
191
  #failedKey = () => `${this.#prefix}failed`;
156
192
  #leaseKey = (jobId: string) => `${this.#prefix}lease:${jobId}`;
157
193
 
@@ -177,26 +213,37 @@ export class RedisDriver implements QueueDriver {
177
213
  }
178
214
 
179
215
  if (!raw) return null;
216
+
217
+ // Only a payload that can never be run is purged. Everything past this
218
+ // point is a REAL job that already sits in `processing`, and deleting
219
+ // it there is the one thing that loses it for good: it is gone from
220
+ // pending too, and recoverStale() scans processing, so nothing would
221
+ // ever find it again.
222
+ let parsed: unknown;
180
223
  try {
181
- const parsed: unknown = JSON.parse(raw);
182
- if (!isValidJob(parsed)) {
183
- // Malformed payload — purge from `processing` so it can't sit
184
- // there indefinitely as a poison pill. recoverStale() also
185
- // catches survivors but pop()'s own move is the primary path.
186
- await client.lrem(this.#processingKey(), 1, raw);
187
- return null;
188
- }
189
- await client.set(
190
- this.#leaseKey(parsed.id),
191
- raw,
192
- "PX",
193
- String(this.#visibilityTimeout),
194
- );
195
- return parsed;
224
+ parsed = JSON.parse(raw);
196
225
  } catch {
226
+ // A poison pill: unparseable, and it would sit in processing
227
+ // forever blocking nothing but wasting every recovery pass.
228
+ await client.lrem(this.#processingKey(), 1, raw);
229
+ return null;
230
+ }
231
+ if (!isValidJob(parsed)) {
197
232
  await client.lrem(this.#processingKey(), 1, raw);
198
233
  return null;
199
234
  }
235
+
236
+ // A lease that cannot be written is a transient Redis failure, not a
237
+ // bad job. The error propagates and the job STAYS in processing with
238
+ // no lease, which is precisely the state recoverStale() puts back in
239
+ // pending — so the delivery guarantee survives the blip.
240
+ await client.set(
241
+ this.#leaseKey(parsed.id),
242
+ raw,
243
+ "PX",
244
+ String(this.#visibilityTimeout),
245
+ );
246
+ return parsed;
200
247
  }
201
248
 
202
249
  async complete(job: Job): Promise<void> {
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export type { RedisClient } from "./drivers/RedisDriver.js";
12
12
  export { RedisDriver } from "./drivers/RedisDriver.js";
13
13
  export type { Job, JobHandler, QueueDriver } from "./QueueManager.js";
14
14
  export { QueueManager } from "./QueueManager.js";
15
+ export { type QueueStoreFactory, stores } from "./stores.js";
15
16
 
16
17
  import type { BayProviderConfig } from "./BayProvider.js";
17
18
 
package/src/nodeEnv.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Reading `NODE_ENV`, with the aliases people actually set.
3
+ *
4
+ * `NODE_ENV=prod` is ordinary in a Dockerfile or a platform dashboard. Read
5
+ * verbatim it answers "not production" — and here that decides whether a queue
6
+ * silently accepts a delivery guarantee weaker than the one it advertises.
7
+ *
8
+ * Duplicated rather than imported: bay depends on no other package in this
9
+ * workspace, and a safety decision that only holds when an optional peer is
10
+ * installed is not a decision.
11
+ */
12
+
13
+ const DEV_ENVS = ["dev", "develop", "development"];
14
+ const PROD_ENVS = ["prod", "production"];
15
+ const TEST_ENVS = ["test", "testing"];
16
+
17
+ /** The canonical name for whatever `NODE_ENV` holds. */
18
+ export function normalizeNodeEnv(value: string | undefined): string {
19
+ if (!value || typeof value !== "string") return "unknown";
20
+ const env = value.toLowerCase();
21
+ if (DEV_ENVS.includes(env)) return "development";
22
+ if (PROD_ENVS.includes(env)) return "production";
23
+ if (TEST_ENVS.includes(env)) return "test";
24
+ return env;
25
+ }
26
+
27
+ /** Whether this process is running in production, under any spelling. */
28
+ export function inProduction(): boolean {
29
+ return normalizeNodeEnv(process.env.NODE_ENV) === "production";
30
+ }
@@ -27,6 +27,14 @@ export function getQueue(): QueueManager | undefined {
27
27
  return instance;
28
28
  }
29
29
 
30
+ /**
31
+ * @internal Release the singleton, so a shut-down application does not leave a
32
+ * dead queue reachable through `services/main`.
33
+ */
34
+ export function clearQueue(): void {
35
+ instance = undefined;
36
+ }
37
+
30
38
  const queue: QueueManager = new Proxy({} as QueueManager, {
31
39
  get(_target, prop) {
32
40
  // A module loader inspects what it imports before anyone uses it: it reads
package/src/stores.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The queue store factories a config file names — `{ default, stores }`.
3
+ *
4
+ * The shape AdonisJS gives a package with several backends and one selected:
5
+ * a `stores` namespace imported beside `defineConfig`, each entry that
6
+ * namespace's result, and the selection read from the environment. Bay had a
7
+ * single `driver: "memory"` and told everyone else to build the manager by
8
+ * hand, which meant Redis could not be reached from a config file at all.
9
+ *
10
+ * import { defineConfig, stores } from '@c9up/bay'
11
+ *
12
+ * export default defineConfig({
13
+ * default: env.get('QUEUE_STORE'),
14
+ * stores: {
15
+ * memory: stores.memory(),
16
+ * redis: stores.redis({ connection: 'main' }),
17
+ * },
18
+ * })
19
+ *
20
+ * Factories are lazy: only the store an application actually uses is built, so
21
+ * naming a Redis queue in a config that runs in memory opens no connection.
22
+ */
23
+
24
+ import { MemoryDriver } from "./drivers/MemoryDriver.js";
25
+ import type { RedisClientSource } from "./drivers/RedisDriver.js";
26
+ import { RedisDriver } from "./drivers/RedisDriver.js";
27
+ import type { QueueDriver } from "./QueueManager.js";
28
+ import { quasarConnection } from "./quasar.js";
29
+
30
+ /** A driver, built on first use. */
31
+ export type QueueStoreFactory = () => QueueDriver;
32
+
33
+ export const stores = {
34
+ /** In memory. Jobs do not survive a restart — for tests and dev. */
35
+ memory(): QueueStoreFactory {
36
+ return () => new MemoryDriver();
37
+ },
38
+
39
+ /**
40
+ * Redis. `connection` takes an ioredis-shaped client, a function answering
41
+ * one, or the NAME of a `@c9up/quasar` connection — the last resolved at
42
+ * first use, without bay importing quasar, which stays an optional peer.
43
+ */
44
+ redis(options: {
45
+ connection: RedisClientSource | string;
46
+ prefix?: string;
47
+ visibilityTimeoutMs?: number;
48
+ }): QueueStoreFactory {
49
+ const source: RedisClientSource =
50
+ typeof options.connection === "string"
51
+ ? quasarConnection(options.connection)
52
+ : options.connection;
53
+ return () =>
54
+ new RedisDriver(source, {
55
+ prefix: options.prefix,
56
+ visibilityTimeoutMs: options.visibilityTimeoutMs,
57
+ });
58
+ },
59
+ };