@c9up/bay 0.1.12 → 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 -1
  7. package/dist/QueueManager.d.ts.map +1 -1
  8. package/dist/QueueManager.js +49 -5
  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 +48 -5
  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
@@ -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
+ };