@edgehero/pi-dispatch 5.1.0 → 5.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.
@@ -86,12 +86,18 @@ export function withdrawCancel({ redis, jobId, interventionOrigin = "human" }) {
86
86
  * already own a could-not-reach-Valkey message, and swallowing here would turn an unreachable Valkey into
87
87
  * a lying "no worker acknowledged".
88
88
  *
89
- * `pollMs`/`sleep` are injectable so tests never wait on a wall clock.
89
+ * `pollMs`/`sleep` are injectable so tests never wait on a wall clock. The wait ends at `ackTimeoutMs` by whichever runs
90
+ * out first: the sleeps it took, or the time `now` says has passed (issue #846). Counting sleeps alone, a GET that waited
91
+ * out a Valkey restart in the client's queue was not counted at all, so a 10 s wait could last as long as the outage did.
90
92
  */
91
- export async function requestCancel({ redis, jobId, interventionOrigin = "human", ackTimeoutMs = 10_000, pollMs = 250, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
93
+ export async function requestCancel({ redis, jobId, interventionOrigin = "human", ackTimeoutMs = 10_000, pollMs = 250, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now, onWritten = null }) {
92
94
  const requestKey = cancelRequestKeyOf(jobId, interventionOrigin);
93
95
  const ackKey = interventionOrigin === "agent" ? agentCancelAckKey(jobId) : cancelAckKey(jobId);
94
96
  await redis.set(requestKey, interventionOrigin === "agent" ? "agent-cancel" : "operator-cancel", "PX", CANCEL_REQ_TTL_MS);
97
+ // When the request's TTL started, for a caller that names until when it may stand (the CLI, issue #846), and the start
98
+ // of the wait for its acknowledgment, so a slow SET is not counted against it.
99
+ onWritten?.();
100
+ const deadline = now() + ackTimeoutMs;
95
101
  for (let waited = 0; ; waited += pollMs) {
96
102
  let host;
97
103
  try {
@@ -102,7 +108,7 @@ export async function requestCancel({ redis, jobId, interventionOrigin = "human"
102
108
  throw err;
103
109
  }
104
110
  if (host !== null && host !== undefined) return { ack: host };
105
- if (waited + pollMs > ackTimeoutMs) break;
111
+ if (waited + pollMs > ackTimeoutMs || now() >= deadline) break;
106
112
  await sleep(pollMs);
107
113
  }
108
114
  // Losing this DEL leaves the request standing for its 60s TTL, where a worker can still read it, so that is no "nothing was
@@ -137,17 +143,38 @@ export function ranBefore(job) {
137
143
 
138
144
  /**
139
145
  * Whether the hold is still there, read back after its delete failed: its CLOCK (`since`), the field that makes the hash
140
- * a hold, since the worker's own counters can re-create the key without it. False when it cannot be read.
146
+ * a hold, since the worker's own counters can re-create the key without it. True or false as read, null when it cannot
147
+ * be read (issue #867: a hold read back gone is known gone, one not read back is not known either way).
141
148
  */
142
149
  async function holdInPlace(redis, jobId) {
143
- if (typeof redis?.hexists !== "function") return false;
150
+ if (typeof redis?.hexists !== "function") return null;
144
151
  try {
145
152
  return (await redis.hexists(jobKey(jobId), "since")) === 1;
146
153
  } catch {
147
- return false;
154
+ return null;
148
155
  }
149
156
  }
150
157
 
158
+ /**
159
+ * Whether a command's failure means it was never sent: a bounded client's timeout that came while the command still
160
+ * waited for a connection (`makeRedisClient`'s `BoundedRedis` takes such a command out of its queue, so it is never sent
161
+ * later either), told apart from one that reached a socket by the missing `valkeyMayStillRun`.
162
+ */
163
+ function neverSent(err) {
164
+ return err?.code === "ETIMEDOUT" && err?.message === "Command timed out" && err?.valkeyMayStillRun !== true;
165
+ }
166
+
167
+ /**
168
+ * Whether a failure is Valkey giving no answer (issue #867): a bounded client's timeout, or a command it sent whose answer
169
+ * was lost (`valkeyMayStillRun`). An answer that changed nothing carries the failure's words as `noAnswer`, so a caller can
170
+ * say Valkey did not answer, and when to ask again, rather than quote it as a refusal; a caller that does not read it
171
+ * says what it said before.
172
+ */
173
+ function noAnswer(err) {
174
+ return err?.code === "ETIMEDOUT" || err?.valkeyMayStillRun === true;
175
+ }
176
+ const noAnswerOf = (err) => (noAnswer(err) ? { noAnswer: err?.message ?? String(err) } : {});
177
+
151
178
  /**
152
179
  * Remove a job that is held on a `run.waitFor` condition: hold keys first, then the job (issue #230's
153
180
  * sequence, issue #287's shared home). This is the inner body of the admin's `cancelHeldJob`, moved here
@@ -159,8 +186,14 @@ async function holdInPlace(redis, jobId) {
159
186
  * been sent for deletion (most often `remove` refusing a job a worker locked in between): the hold is gone, or partly
160
187
  * gone, while the job's own removal failed, which a caller must never word as "nothing was changed". A first delete
161
188
  * that failed answers `{ invalid }` only when the hold is read back in place afterwards (Valkey refused the command:
162
- * NOPERM, READONLY, MISCONF); a hold not read back, or a read that fails, keeps the mark, since a refusal can answer a
163
- * resend of a delete that already ran. Never throws.
189
+ * NOPERM, READONLY, MISCONF), or when it was never sent (`neverSent`); a hold not read back, or a read that fails, keeps
190
+ * the mark, since a refusal can answer a resend of a delete that already ran. Never throws.
191
+ * Issue #867: a cleared answer whose failure was a lost answer (`valkeyMayStillRun`: the command reached a Valkey that
192
+ * then did not answer, so it may still run) says which write that was, `unanswered`: "hold" for the hold's first delete,
193
+ * when the hold could not be read back either (the hold may or may not be gone, and the job was not asked to go), "job"
194
+ * for the job's own removal (the hold is gone, and the job may or may not be). A job whose state cannot be read is
195
+ * refused as that, never as one that left the hold. An answer that changed nothing because Valkey gave none (a read past
196
+ * its bound, a delete never sent) carries the failure's words as `noAnswer`.
164
197
  * Writes no run record of its own (INT-RUN-HISTORY-FILE-CONTRACT records terminal states of runs, and a removed hold
165
198
  * is none); `attemptsMade` is what the job had done before, for the caller's sentence (`ranBefore`, issue #477): a job
166
199
  * held again on a retry has made attempts, and whatever they recorded stays. Once the job is removed, its model attempt
@@ -171,6 +204,9 @@ async function holdInPlace(redis, jobId) {
171
204
  export async function removeHeldJob({ redis, queue, jobId, timeoutMs = 2_000, dropDefer = false, touchStore = undefined, touchOutbox = null, now = Date.now, interventionOrigin = "human" }) {
172
205
  // Set as the first hold key's delete is sent, before it is answered: a delete lost in transit may still have landed.
173
206
  let cleared = false;
207
+ // The write in flight, for `unanswered`: the hold's first delete, then the job's removal; null between them, where a
208
+ // failure leaves the hold known gone and the job not asked.
209
+ let step = null;
174
210
  try {
175
211
  const hash = await redis.hgetall(jobKey(jobId));
176
212
  // A hold is a hash with a CLOCK, not merely a non-empty hash: the worker's own counters create this
@@ -182,7 +218,12 @@ export async function removeHeldJob({ redis, queue, jobId, timeoutMs = 2_000, dr
182
218
  // while the job wakes, runs and completes -- and bullmq will happily `remove()` a completed job. That
183
219
  // would answer `applied: true` to an operator who approved a dialog reading "It will never run", for
184
220
  // a job whose run record is already on disk.
185
- const state = await job.getState().catch(() => null);
221
+ let unread = null;
222
+ const state = await job.getState().catch((err) => ((unread = err), null));
223
+ // Issue #867: a read that failed (on a bounded queue, one Valkey did not answer) says nothing of where the job is,
224
+ // so it is no job that "has already left the hold": nothing was sent, and that is what is said, before the
225
+ // failure's own words, which a caller may cut.
226
+ if (unread !== null) return { invalid: `the state of job ${jobId} could not be read, so nothing was changed: ${unread?.message ?? String(unread)}`, ...noAnswerOf(unread) };
186
227
  if (state !== "delayed" && state !== "waiting" && state !== "prioritized") {
187
228
  return { invalid: `job ${jobId} is ${state ?? "in an unknown state"}, not waiting -- it has already left the hold` };
188
229
  }
@@ -190,28 +231,40 @@ export async function removeHeldJob({ redis, queue, jobId, timeoutMs = 2_000, dr
190
231
  // mid-sequence, an orphaned hash would keep a row on the panel for a job that no longer exists; an
191
232
  // orphaned JOB is merely a job that still runs, which is the state the operator was already in.
192
233
  cleared = true;
234
+ step = "hold";
193
235
  try {
194
236
  await redis.del(jobKey(jobId));
195
237
  } catch (err) {
196
238
  // A failed DEL may still have run: ioredis resends an unanswered command after a reconnect, and the refusal
197
239
  // (an ACL without DEL, a read-only replica, a failed save) can be the answer to that resend. So the hold is
198
240
  // read back, and only a hold still in place makes "nothing was changed" true; a read that fails keeps the mark.
199
- if (await holdInPlace(redis, jobId)) cleared = false;
241
+ // A DEL never sent changed nothing either, whatever the read says. One read back gone ran.
242
+ const inPlace = await holdInPlace(redis, jobId);
243
+ if (inPlace === true || neverSent(err)) cleared = false;
244
+ else if (inPlace === false) step = null;
200
245
  throw err;
201
246
  }
247
+ step = null;
202
248
  await redis.srem(HELD_SET, jobId).catch(() => {});
203
249
  if (hash.dedupId) {
204
250
  const holder = await redis.get(leaseKey(hash.dedupId));
205
251
  if (holder === jobId) await redis.del(leaseKey(hash.dedupId));
206
252
  }
207
253
  const store = touchStore === undefined ? (typeof redis?.eval === "function" ? makeTouchStore({ redis, now }) : null) : touchStore;
208
- const removal = interventionOrigin === "agent" ? (await job.remove(), { ok: true }) : await trackTouchAction({ store, outbox: touchOutbox, event: stopTouch(job, queue, jobId, now), act: async () => { await job.remove(); return { ok: true }; }, succeeded: () => !(job.attemptsMade > 0) && isWorkload(job) });
254
+ const remove = async () => {
255
+ step = "job";
256
+ await job.remove();
257
+ return { ok: true };
258
+ };
259
+ const removal = interventionOrigin === "agent" ? await remove() : await trackTouchAction({ store, outbox: touchOutbox, event: stopTouch(job, queue, jobId, now), act: remove, succeeded: () => !(job.attemptsMade > 0) && isWorkload(job) });
260
+ step = null;
209
261
  await Promise.all([dropDefer ? dropDeferRecord(redis, jobId, timeoutMs) : null, dropAttemptOf(redis, queue, jobId, timeoutMs)]);
210
262
  // Issue #477: what the job had done, for the caller's sentence (`ranBefore`); read off the job just removed.
211
263
  return { ok: true, jobId, attemptsMade: Number.isInteger(job.attemptsMade) && job.attemptsMade > 0 ? job.attemptsMade : 0, ...(removal.touchWarning ? { touchWarning: removal.touchWarning } : {}) };
212
264
  } catch (err) {
213
265
  const invalid = err?.message ?? String(err);
214
- return cleared ? { invalid, cleared: true } : { invalid };
266
+ if (!cleared) return { invalid, ...noAnswerOf(err) };
267
+ return { invalid, cleared: true, ...(err?.valkeyMayStillRun === true && step !== null ? { unanswered: step } : {}) };
215
268
  }
216
269
  }
217
270
 
@@ -232,8 +285,10 @@ export const QUEUED_STATES = Object.freeze(["delayed", "waiting", "prioritized",
232
285
  * any other state -> nothing is changed.
233
286
  *
234
287
  * A HOLD IS JUDGED FIRST: a held job IS delayed, and the plain path would strand its `wait:*` keys as a panel row for a
235
- * job that no longer exists. A hash that cannot be read is not a hold, as the CLI has always read it. `state` is the
236
- * state the caller already read, or null to read it here.
288
+ * job that no longer exists. So a hash that cannot be read refuses, nothing changed (issue #867): it was read as no hold,
289
+ * and a read that timed out on a Valkey that then woke removed a held job by the plain path, leaving its hash and its
290
+ * `wait:held` member as a panel row for a job that was gone. A client with no `hgetall` (a bare test wiring) has no hold
291
+ * to read. `state` is the state the caller already read, or null to read it here.
237
292
  *
238
293
  * `guard` (optional; the CLI passes none) is a caller's last check, asked with `job` right before the removal is sent:
239
294
  * before `removeHeldJob` for a held job, and after the state check, before `remove()`, otherwise. It answers null to go
@@ -247,10 +302,13 @@ export const QUEUED_STATES = Object.freeze(["delayed", "waiting", "prioritized",
247
302
  * (issue #812), beside the record, so the two deletes share one bound: here for a waiting job, inside `removeHeldJob`
248
303
  * (`dropDefer`) for a held one. Returns, and never throws:
249
304
  * `{ ok: true, held: true, jobId, attemptsMade }` a held job, removed;
250
- * `{ invalid }` `removeHeldJob`'s own refusal of a held job, nothing changed;
251
- * `{ invalid, cleared: true }` `removeHeldJob` failed after its hold's keys were deleted: the
305
+ * `{ invalid, holdUnread, noAnswer? }` the hold's hash could not be read (`holdUnread`, the failure's
306
+ * words; `noAnswer` when Valkey gave none): nothing was sent;
307
+ * `{ invalid, noAnswer? }` `removeHeldJob`'s own refusal of a held job, nothing changed;
308
+ * `{ invalid, cleared: true, unanswered? }` `removeHeldJob` failed after its hold's keys were deleted: the
252
309
  * hold is gone and the job's own removal failed (never "nothing
253
- * was changed");
310
+ * was changed"), or a write of it was not answered
311
+ * (`unanswered`, removeHeldJob's);
254
312
  * `{ refused, error? }` the guard's answer (`refused`), or its throw (`refused` null,
255
313
  * `error` the throw); nothing was sent;
256
314
  * `{ notQueued: true, state }` not waiting to start (`active`, `completed`, `unknown`...);
@@ -258,17 +316,24 @@ export const QUEUED_STATES = Object.freeze(["delayed", "waiting", "prioritized",
258
316
  * `{ raced: true, state, error }` `remove()` threw (most often: a worker took the job between
259
317
  * the read and the remove, or it is a job scheduler's next
260
318
  * run, which BullMQ refuses to remove); `state` is read again.
319
+ * An `error` with `valkeyMayStillRun` was sent and not answered,
320
+ * so the remove may yet run (issue #867).
261
321
  * `attemptsMade` is the job's own count for the caller's sentence (`ranBefore`), 0 when it is not a positive whole number.
262
322
  */
263
323
  export async function removeQueuedJob({ redis, queue, job, jobId, state = null, timeoutMs = 2_000, guard = null, touchStore = undefined, touchOutbox = null, now = Date.now, interventionOrigin = "human" }) {
264
- const hash = await Promise.resolve()
324
+ let holdUnread = null;
325
+ const hash = typeof redis?.hgetall !== "function" ? null : await Promise.resolve()
265
326
  .then(() => redis.hgetall(jobKey(jobId)))
266
- .catch(() => null);
327
+ .catch((err) => ((holdUnread = err), null));
328
+ if (holdUnread !== null) {
329
+ const words = holdUnread?.message ?? String(holdUnread);
330
+ return { invalid: `could not read whether job ${jobId} is held on a wait condition, so nothing was changed: ${words}`, holdUnread: words, ...noAnswerOf(holdUnread) };
331
+ }
267
332
  if (hash?.since) {
268
333
  const stop = await guarded(guard, job);
269
334
  if (stop !== null) return stop;
270
335
  const res = await removeHeldJob({ redis, queue, jobId, timeoutMs, dropDefer: true, touchStore, touchOutbox, now, interventionOrigin });
271
- if (!res.ok) return res.cleared === true ? { invalid: res.invalid, cleared: true } : { invalid: res.invalid };
336
+ if (!res.ok) return res.cleared === true ? { invalid: res.invalid, cleared: true, ...(res.unanswered ? { unanswered: res.unanswered } : {}) } : { invalid: res.invalid, ...(res.noAnswer !== undefined ? { noAnswer: res.noAnswer } : {}) };
272
337
  return { ok: true, held: true, jobId: res.jobId, attemptsMade: res.attemptsMade, ...(res.touchWarning ? { touchWarning: res.touchWarning } : {}) };
273
338
  }
274
339
  if (!job) return { notQueued: true, state: "unknown" };
@@ -2,8 +2,8 @@ import { AbstractConnector, Command, Redis } from "ioredis";
2
2
  import { Socket, connect as netConnect } from "node:net";
3
3
  import { connect as tlsConnect } from "node:tls";
4
4
  import { valkeyAuthRefusal } from "./valkey-auth.mjs";
5
- import { valkeySchemeOf } from "./podman-stack.mjs";
6
- import { decodedUserinfo, judgeValkeyAtStart as judgeEndpointAtStart, judgedEndpoint, killSwitchValkeyUrls, defaultValkeyContext, urlShown, useValkeyContext, valkeyClientContext, valkeyContextFromKeys, VALKEY_CONTEXT_KEYS, valkeyPasswordFor, valkeyDbRangeSentence, valkeyRefusal, valkeyUrlFor, valkeyUrlProblem } from "./valkey-endpoint.mjs";
5
+ import { valkeySchemeOf, valkeyTarget } from "./podman-stack.mjs";
6
+ import { DEFAULT_VALKEY_URL, decodedUserinfo, judgeValkeyAtStart as judgeEndpointAtStart, judgedEndpoint, killSwitchValkeyUrls, defaultValkeyContext, urlShown, useValkeyContext, valkeyClientContext, valkeyContextFromKeys, VALKEY_CONTEXT_KEYS, valkeyPasswordFor, valkeyDbRangeSentence, valkeyRefusal, valkeyUrlFor, valkeyUrlProblem } from "./valkey-endpoint.mjs";
7
7
 
8
8
  // Re-exported for the receiver and the admin, which import this module from the package (issues #464 and #468).
9
9
  export { killSwitchValkeyUrls, defaultValkeyContext, urlShown, useValkeyContext, valkeyClientContext, valkeyContextFromKeys, VALKEY_CONTEXT_KEYS, valkeyPasswordFor, valkeyUrlFor, valkeyUrlProblem };
@@ -89,6 +89,18 @@ if (!Redis.prototype[SCRUBS]) {
89
89
  */
90
90
  export const DB_REFUSED = Symbol.for("pi-dispatch.valkey-db-refused");
91
91
  const SELECT_HOOK = Symbol.for("pi-dispatch.valkey-select-refusal");
92
+
93
+ /**
94
+ * The database a client holds a refusal of (`DB_REFUSED`), as `{ db }` (null where the client no longer says which), or
95
+ * null when it holds none. Issue #845: the refusal is held for the client's life, so only a restart of a long-lived
96
+ * process tries the database again, and the console says so on every page from this.
97
+ */
98
+ export function refusedDatabaseOf(client) {
99
+ if (!client?.[DB_REFUSED]) return null;
100
+ const db = client.condition?.select ?? client.options?.db;
101
+ return { db: Number.isSafeInteger(db) ? db : null };
102
+ }
103
+
92
104
  /** Whether `err` is a server's refusal of a SELECT for a database it does not have. */
93
105
  function isSelectRangeError(err) {
94
106
  return Boolean(err) && typeof err === "object" && String(err.command?.name ?? "").toLowerCase() === "select" && /DB index is out of range|invalid DB index/i.test(String(err.message ?? ""));
@@ -172,16 +184,30 @@ export function givesUp(connection) {
172
184
  *
173
185
  * `freshContext` is for a persistent client of a long-running surface that names no context: each connect judges by the
174
186
  * context as it is then, not as it was at construction, so an operator's fix of the deployment's `.env` reaches a client
175
- * that has been refused since it started. Off by default, so a worker's judgement stays the one it booted on. The password
176
- * is still the one read at construction, since ioredis has settled it before the connector runs.
187
+ * that has been refused since it started. Off by default, so a worker's judgement stays the one it booted on. Its password
188
+ * is read again before each connect too (issue #845, `freshPassword` below), so a password rotated in that `.env`, or one
189
+ * a bad line kept from being read at start, is what the next connect sends. Not its URL: a changed `VALKEY_URL` is
190
+ * another Valkey, which a running client is never moved to (the console says it needs a restart for that instead).
191
+ *
192
+ * `commandTimeoutMs` (issue #867) is a bound on each command of a `failFast` client, for a one-shot BullMQ queue that
193
+ * must not wait for as long as a Valkey that is connected and stopped answering (`kill -STOP`, a partition) stays so:
194
+ * `failFast` refuses while nothing is connected, and a command already sent had no bound. A command past it rejects with
195
+ * ioredis' "Command timed out"; one that was sent may still run when Valkey wakes, so its error says so
196
+ * (`valkeyMayStillRun`, the hook below), and it is never sent again on a later connection. It needs `failFast`: that
197
+ * client has no offline queue, and on one that has, ioredis sends a command that timed out there once Valkey returns.
198
+ * `makeRedisClient` takes such a command out of its own queue (`BoundedRedis`), but BullMQ builds this client itself, so
199
+ * nothing could. Never for a Worker: its blocking reads wait longer than any bound by design, and its connection is a
200
+ * persistent one, which this refuses.
177
201
  */
178
- export function parseConnection(url, { failFast = false, servername = null, context = null, judge = null, withoutPassword = false, freshContext = false } = {}) {
202
+ export function parseConnection(url, { failFast = false, servername = null, context = null, judge = null, withoutPassword = false, freshContext = false, commandTimeoutMs = null } = {}) {
179
203
  const u = new URL(url);
180
204
  // PR #478's gate: a path that is not a database number was `db: NaN`, and the SELECT ioredis then sent failed outside
181
205
  // every caller's await (an unhandled rejection). Refused here, where every client of the project is made, as the
182
206
  // configuration refusal it is; `judgeValkeyAtStart` says it first at each start.
183
207
  const dbProblem = valkeyUrlProblem(url);
184
208
  if (dbProblem) throw valkeyRefusal(dbProblem);
209
+ const bounded = Number.isSafeInteger(commandTimeoutMs) && commandTimeoutMs > 0;
210
+ if (bounded && !failFast) throw new TypeError("parseConnection: a command bound (commandTimeoutMs) is for a failFast client only, which has no offline queue to send a command that timed out from later");
185
211
  const where = context ?? defaultValkeyContext();
186
212
  // Issue #468: the password this client sends, by `valkeyPasswordFor`'s rule (the URL's own, else VALKEY_PASSWORD from
187
213
  // the environment, else from the deployment `.env` for a loopback host). Here and nowhere else, so every client of the
@@ -194,7 +220,9 @@ export function parseConnection(url, { failFast = false, servername = null, cont
194
220
  // reports. `context` is where the client stands (`valkeyClientContext`), the process's own when not given.
195
221
  Connector: JudgedConnector,
196
222
  // `judge` is a test seam only: the function asked for the address (judgedEndpoint when not given).
197
- [JUDGE]: { url, context: where, ...(judge ? { judge } : {}), ...(freshContext && !context ? { freshContext: true } : {}), ...(failFast ? { failFast: true } : {}) },
223
+ // `freshPassword` (issue #845): a client that judges by the context as it is at each connect sends the password that
224
+ // context gives then, unless it was built to send none (`withoutPassword`, a probe of what another account sees).
225
+ [JUDGE]: { url, context: where, ...(judge ? { judge } : {}), ...(freshContext && !context ? { freshContext: true, ...(withoutPassword ? {} : { freshPassword: true }) } : {}), ...(failFast ? { failFast: true } : {}), ...(bounded ? { commandBound: true } : {}) },
198
226
  // WHATWG URL keeps an IPv6 literal's brackets in `hostname` (`[::1]`), and net.connect then looks up the name
199
227
  // "[::1]", which never resolves, so a `redis://[::1]:6379` VALKEY_URL never connected (issue #464, measured on
200
228
  // Fedora 44: ETIMEDOUT). The address is the part inside them.
@@ -216,9 +244,44 @@ export function parseConnection(url, { failFast = false, servername = null, cont
216
244
  retryStrategy: (attempts) => (attempts > 2 ? null : 200), // give up after ~2 tries
217
245
  }
218
246
  : {}),
247
+ // ioredis' resend of what was on a connection that died is off with the bound: the caller was told such a command
248
+ // failed, and a resend on the next connection would make it run after that.
249
+ ...(bounded ? { commandTimeout: commandTimeoutMs, autoResendUnfulfilledCommands: false } : {}),
219
250
  };
220
251
  }
221
252
 
253
+ /**
254
+ * What `BoundedRedis` does for its own clients, done for a client `parseConnection` built with a command bound (issue
255
+ * #867), which BullMQ constructs itself as a plain ioredis client, so it cannot be a subclass. On the prototype, like the
256
+ * scrub above, and inert for every client without the bound (a worker's, a receiver's, every `makeRedisClient` client).
257
+ *
258
+ * Two things. The command's timer is unref'd, since ioredis' is ref'd and a one-shot that has said its last line must not
259
+ * wait out a bound on a command nobody awaits. And a command that was WRITTEN (it is the newest entry of the client's
260
+ * command queue right after the send; without an offline queue, one that was not is refused at once and never sent) and
261
+ * then rejected with anything but Valkey's own reply carries `valkeyMayStillRun`: it reached a socket, so Valkey may run
262
+ * it when it wakes, or ran it before its answer was lost. Measured against the pinned ioredis: a SET past the bound on a
263
+ * `kill -STOP`ed Valkey rejects with "Command timed out" and is applied once the process is continued. Besides the
264
+ * timeout that is "Connection is closed.", for a command on a connection whose retries ran out. A refusal this project
265
+ * raised itself (`valkeyRefused`, a database the server does not have) is no lost answer.
266
+ */
267
+ const COMMAND_BOUND_HOOK = Symbol.for("pi-dispatch.valkey-command-bound");
268
+ if (!Redis.prototype[COMMAND_BOUND_HOOK]) {
269
+ const sendCommand = Redis.prototype.sendCommand;
270
+ Redis.prototype.sendCommand = function sendCommandWithBound(command, stream) {
271
+ const answer = sendCommand.call(this, command, stream);
272
+ if (this.options?.[JUDGE]?.commandBound !== true) return answer;
273
+ command._commandTimeoutTimer?.unref?.();
274
+ if (this.commandQueue?.peekBack?.()?.command !== command) return answer;
275
+ answer?.catch?.((err) => {
276
+ if (!err || typeof err !== "object" || err.name === "ReplyError" || err.valkeyRefused) return;
277
+ if (err.message === "Command timed out") err.code ??= "ETIMEDOUT";
278
+ err.valkeyMayStillRun = true;
279
+ });
280
+ return answer;
281
+ };
282
+ Redis.prototype[COMMAND_BOUND_HOOK] = true;
283
+ }
284
+
222
285
  /**
223
286
  * A client whose commands have a bound. Its caller has been told a command that timed out failed, so that command must
224
287
  * not go out later, and ioredis would send it: the timer rejects it and leaves it in the offline queue for when Valkey
@@ -250,6 +313,13 @@ class BoundedRedis extends Redis {
250
313
  }
251
314
  }
252
315
 
316
+ /**
317
+ * How long one command of a short-lived client may wait for its answer (`commandTimeoutMs`), in the offline queue or on a
318
+ * Valkey that stopped answering: the console's own clients wait as long, and `pi-dispatch cancel` (issue #846) takes it
319
+ * from here, so a cancel against a Valkey that went away ends with a sentence rather than waiting as long as it is gone.
320
+ */
321
+ export const VALKEY_COMMAND_TIMEOUT_MS = 5_000;
322
+
253
323
  /**
254
324
  * A raw ioredis client (the budget's INCR/EXPIRE, the registry and fleet reads, doctor's PING). Built from
255
325
  * `parseConnection`'s options, so it connects through `JudgedConnector` like every other client. `lazyConnect` and
@@ -375,6 +445,60 @@ export function authRefusalFor(state, url, context) {
375
445
  */
376
446
  const JUDGE = "piDispatchValkeyJudge";
377
447
 
448
+ /** The connector's method that reads a `freshPassword` client's password again (`JudgedConnector`, the hook below it). */
449
+ const FRESH_PASSWORD = Symbol.for("pi-dispatch.valkey-fresh-password");
450
+
451
+ /** Set on a connector whose last refresh declined a `.env` password other than the one its client holds (see there). */
452
+ const PASSWORD_DECLINED = Symbol.for("pi-dispatch.valkey-password-declined");
453
+
454
+ /**
455
+ * Whether two Valkey URLs name one Valkey server, by `valkeyTarget`'s host and port (the scheme alias, a database path
456
+ * and a trailing `/` aside): a password belongs to the server. False for one that does not parse. `localhost` and
457
+ * `127.0.0.1` are two names here, as for `valkeyEndpointTag`: which address a name reaches is the judge's to say.
458
+ */
459
+ function sameValkeyServer(a, b) {
460
+ const x = valkeyTarget(a);
461
+ const y = valkeyTarget(b);
462
+ return !x.error && !y.error && x.host === y.host && x.port === y.port;
463
+ }
464
+
465
+ /**
466
+ * What a `freshPassword` client's refresh does with `context` as it is now (`JudgedConnector`'s method below has the
467
+ * rules): `{ take: true, password }` to send `password` (null: none) from the next connect on; `{ take: false, declined }`
468
+ * to keep the one it holds (`held`), where `declined` is true when the `.env` gives another password than `held` for a
469
+ * Valkey the client does not dial as it dials it, false when nothing is being held back, and null when the context
470
+ * cannot tell (it could not be read, finds no file, or is another deployment's). Pure: one rule for the refresh before
471
+ * each connect and for `passwordDeclinedOf`, so the two never disagree about what is held back.
472
+ */
473
+ function freshPasswordVerdict(judge, context, held) {
474
+ if (!context || context.error || context.absent === true) return { take: false, declined: null };
475
+ if ((context.envPath ?? null) !== (judge?.context?.envPath ?? null)) return { take: false, declined: null };
476
+ const { password, from } = valkeyPasswordFor(judge.url, context);
477
+ const fromFile = from !== "VALKEY_URL" && from !== "the environment";
478
+ if (fromFile && !sameValkeyServer(context.url?.file ?? DEFAULT_VALKEY_URL, judge.url)) return { take: false, declined: (password ?? null) !== (held ?? null) };
479
+ return { take: true, password: password ?? null };
480
+ }
481
+
482
+ /**
483
+ * Issue #845, review of PR #878: whether a `freshPassword` client holds back a password the deployment's `.env` gives,
484
+ * other than the one it holds, because the file does not name the Valkey the client dials as it dials it. True only
485
+ * while a reconnect has declined one (recorded by the refresh, which runs on connect alone) AND the `.env` as it is now
486
+ * still gives such a password (`freshPasswordVerdict` over the context read now): a file put back to the held password,
487
+ * or changed to name the client's Valkey, clears it at once, with no reconnect, and so does a file that cannot be read
488
+ * now (the console says that instead). Reads the client's context once; touches no Valkey.
489
+ */
490
+ export function passwordDeclinedOf(client) {
491
+ const connector = client?.connector;
492
+ if (connector?.[PASSWORD_DECLINED] !== true) return false;
493
+ const judge = connector.options?.[JUDGE];
494
+ if (judge?.freshPassword !== true) return false;
495
+ try {
496
+ return freshPasswordVerdict(judge, defaultValkeyContext(), connector.options.password).declined === true;
497
+ } catch {
498
+ return false;
499
+ }
500
+ }
501
+
378
502
  /**
379
503
  * ioredis' connector for every client of this project (issue #464, gate round 2 follow-up): on each connect it asks
380
504
  * `judgedEndpoint` for the address, then dials that literal (with a `rediss:` URL's name kept as the TLS servername),
@@ -401,6 +525,57 @@ export class JudgedConnector extends AbstractConnector {
401
525
  return !this[DB_REFUSED];
402
526
  }
403
527
 
528
+ /**
529
+ * Issue #845: a `freshPassword` client's password, as `valkeyPasswordFor` reads it from the context as it is now, put
530
+ * where ioredis reads it for the connect about to start (the hook below). Here and not in `connect()`: by the time
531
+ * ioredis asks the connector for a stream it has already built that connect's AUTH from `options.password`. The options
532
+ * are the client's own object (ioredis hands its `options` to the Connector it constructs), so this is what the client
533
+ * sends. A password from VALKEY_URL's userinfo comes back as it is, so it stays fixed. The last password is kept by a
534
+ * context that:
535
+ * - throws, or names an `error` (a `.env` that cannot be read, or a line in it the service's loader reads
536
+ * differently): it cannot say which password the file holds, and dropping the one that worked would refuse a client
537
+ * that is fine;
538
+ * - finds no file (`absent`): a `.env` gone, for good or for the moment an editor replaces it, is not a file that
539
+ * sets no password, so it does not drop one either (the console says to put the file back before a restart);
540
+ * - would answer from the `.env` while that file does not name the Valkey this client dials: a password the file
541
+ * gives (or the lack of one) belongs to the Valkey the file's VALKEY_URL names, the default one when it names
542
+ * none, and a held client never moves to another Valkey. Review of PR #878, measured with two Valkeys: one
543
+ * deployment moving to another Valkey changes both lines together, a symlinked deployment folder repointed reads
544
+ * another deployment's file through the same path, and a shell that exports VALKEY_URL keeps the client on its URL
545
+ * whatever the file says; in each the new Valkey's password went to the old one, which refused it, and the pages
546
+ * lost the old Valkey too. The check is against the URL the client dials, not the context's own choice of URL
547
+ * (`valkeyUrlFor`, which prefers this shell's and so never moved), and by the server each names (`sameValkeyServer`:
548
+ * `valkeyTarget`'s host and port, so a scheme alias, a database path or a trailing `/` is the same Valkey, while
549
+ * `localhost` and `127.0.0.1` stay two names, as `valkeyEndpointTag` keeps them). A rotation keeps the file's URL,
550
+ * so it is taken, and so is a password a bad line hid at start once the line is fixed, when the file names the
551
+ * client's Valkey. A password declined here that is not the one the client holds is recorded (`PASSWORD_DECLINED`,
552
+ * `passwordDeclinedOf`), so the console names VALKEY_PASSWORD as needing a restart rather than going on refused in
553
+ * silence (a shell that exports `redis://localhost:6440` over a file's `redis://127.0.0.1:6440`, say);
554
+ * - is read from another `.env` path than the one the client was built with: the pi extension's context follows the
555
+ * deployment pointer, and that alone says the file is another deployment's.
556
+ * A password from this process's environment, or from VALKEY_URL's own userinfo, is not the file's, and is taken as it
557
+ * comes. A context that passes all of these and gives no password drops it, as a restart would.
558
+ */
559
+ [FRESH_PASSWORD]() {
560
+ const judge = this.options?.[JUDGE];
561
+ if (judge?.freshPassword !== true) return;
562
+ let context;
563
+ try {
564
+ context = defaultValkeyContext();
565
+ } catch {
566
+ return;
567
+ }
568
+ const verdict = freshPasswordVerdict(judge, context, this.options.password);
569
+ if (!verdict.take) {
570
+ // Recorded only where the context could tell; `passwordDeclinedOf` reads it against the file as it is then.
571
+ if (verdict.declined !== null) this[PASSWORD_DECLINED] = verdict.declined;
572
+ return;
573
+ }
574
+ this[PASSWORD_DECLINED] = false;
575
+ if (verdict.password) this.options.password = verdict.password;
576
+ else delete this.options.password;
577
+ }
578
+
404
579
  connect() {
405
580
  const { options } = this;
406
581
  // A database the server refused stays refused: no reconnect goes ready on another one.
@@ -438,6 +613,39 @@ export class JudgedConnector extends AbstractConnector {
438
613
  /** How `JudgedConnector` dials, net and tls: a seam, so a test sees the options a pinned connect is made with. */
439
614
  JudgedConnector.dial = { net: netConnect, tls: tlsConnect };
440
615
 
616
+ /**
617
+ * Where a `freshPassword` client's password is read again (issue #845): before EVERY connect, its first and each
618
+ * reconnect, on the prototype like the hooks above, so a client ioredis or BullMQ duplicates keeps it too. Measured
619
+ * against the pinned ioredis 5.11.1 (`worker/test/valkey-clients.test.mjs` holds each line): `connect()` runs
620
+ * `_connect()`, which builds the connect's AUTH (`this.condition.auth`) from `this.options.password` before it asks the
621
+ * connector for a stream; a refused AUTH (WRONGPASS) and a refused ready check (NOAUTH) go to `recoverFromFatalError`,
622
+ * which disconnects to reconnect, and the reconnect timer calls `self.connect()`. So a rotated password reaches a client
623
+ * that has been refused since the rotation at its next try, by its own retry strategy, with no rebuild.
624
+ *
625
+ * Delegated to the client's connector, so the context read is the one of the copy of this module that built the client
626
+ * (a bundle carries its own copy beside the shared ioredis, and the first copy loaded installs this hook). Inert for every
627
+ * other client. A throw here would escape ioredis' reconnect timer, which does not catch one, so it is caught: the last
628
+ * password is kept.
629
+ *
630
+ * REJECTED: rebuilding the client when its password changes, which every holder of one (the console's audit copy, the
631
+ * fleet reader, a page's reads) would have to learn to swap; wrapping `connect` on each client `makeRedisClient` makes,
632
+ * which misses a duplicate and a client BullMQ builds from `parseConnection`'s options; and reading the password in
633
+ * `JudgedConnector.connect`, which is too late for that connect's AUTH (above).
634
+ */
635
+ const FRESH_PASSWORD_HOOK = Symbol.for("pi-dispatch.valkey-fresh-password-hook");
636
+ if (!Redis.prototype[FRESH_PASSWORD_HOOK]) {
637
+ const connect = Redis.prototype.connect;
638
+ Redis.prototype.connect = function connectWithFreshPassword(...args) {
639
+ try {
640
+ this.connector?.[FRESH_PASSWORD]?.();
641
+ } catch {
642
+ // The last password stays.
643
+ }
644
+ return connect.apply(this, args);
645
+ };
646
+ Redis.prototype[FRESH_PASSWORD_HOOK] = true;
647
+ }
648
+
441
649
  /**
442
650
  * Whether `connection` (options for BullMQ, or an ioredis client) connects through `JudgedConnector`. `makeQueue` and the
443
651
  * worker refuse any other, so no Queue or Worker of this project can be built on an unjudged connection.
@@ -49,7 +49,7 @@ export function parseSecretBundleProfilesSafe(raw) {
49
49
  * - `realExecutablePath`: the resolver's own probe (secrets.mjs `realExecutable`), or null when pi did not load;
50
50
  * - `resolveBundle`: `(binding) => resolve result`, only with `--secrets`.
51
51
  */
52
- export async function secretBundleChecks({ bundles = {}, triggersPresent = true, projects = [], projectsError = null, envRaw, overlayBundles = {}, roots = [], realExecutablePath = null, resolveBundle = null } = {}) {
52
+ export async function secretBundleChecks({ bundles = {}, triggersPresent = true, projects = [], projectsError = null, projectsUnread = false, envRaw, overlayBundles = {}, roots = [], realExecutablePath = null, resolveBundle = null } = {}) {
53
53
  const checks = [];
54
54
  const { bundles: envBundles, error } = parseSecretBundleProfilesSafe(envRaw);
55
55
  if (error) {
@@ -91,7 +91,11 @@ export async function secretBundleChecks({ bundles = {}, triggersPresent = true,
91
91
  } else if (!fromEnv && !Object.hasOwn(usableOverlay, b.profile)) {
92
92
  checks.push({ ok: false, label: `secret bundle ${id} names profile ${b.profile}, declared in the settings overlay but not usable`, fix: roots.length === 0 ? "PI_SECRET_RESOLVER_ROOTS is unset, so the worker uses no overlay-declared resolver: set it, or declare the profile in PI_SECRET_BUNDLES" : "the resolver must be an executable file whose real path sits inside PI_SECRET_RESOLVER_ROOTS" });
93
93
  }
94
- if (projectsError !== null) {
94
+ if (projectsUnread) {
95
+ // Issue #859: the `.env` line naming the projects file could not be read (its own line says why), so whether the
96
+ // service's file holds the project is not known: a warning, never the ✗ a file with no such project earns.
97
+ checks.push({ ok: false, warn: true, label: `secret bundle ${id} names project ${b.project}, and whether the projects file the service reads holds it is not known: doctor could not read what the deployment .env sets for PI_PROJECTS_FILE`, fix: "fix what the boot-file line above names, then re-run doctor" });
98
+ } else if (projectsError !== null) {
95
99
  checks.push({ ok: false, label: `secret bundle ${id} names project ${b.project}, and projects.json does not load`, fix: "fix projects.json (its own line above says why) -- a bundle applies to a project's jobs only once the worker knows the project" });
96
100
  } else if (!projectIds.has(b.project)) {
97
101
  checks.push({ ok: false, label: `secret bundle ${id} names project ${b.project}, which projects.json does not hold`, fix: "add the project to PI_PROJECTS_FILE, or correct the bundle's project -- until then the bundle reaches no job, while the file reads as granting it" });
@@ -77,7 +77,7 @@ export function cheaperRouteChecks(roles, routes, models, endpoints, builtin = g
77
77
  return checks;
78
78
  }
79
79
 
80
- export async function doctorModelProposalChecks(env, { cwd, credentials = {}, now = Date.now, read = safeRead, fetch = fetchLifecycle, endpoints: suppliedEndpoints } = {}) {
80
+ export async function doctorModelProposalChecks(env, { cwd, credentials = {}, now = Date.now, read = safeRead, fetch = fetchLifecycle, endpoints: suppliedEndpoints, declaredEndpoints = null } = {}) {
81
81
  let config;
82
82
  try { config = parseProposalConfig(read(join(cwd, "model-proposals.json"))); }
83
83
  catch (error) {
@@ -102,7 +102,9 @@ export async function doctorModelProposalChecks(env, { cwd, credentials = {}, no
102
102
  if (config.routes?.length) try {
103
103
  const parsed = parseModelsJson(read(join(env.PI_GLOBAL_PI_DIR, "models.json")));
104
104
  if (parsed.error) throw new Error("overlay unavailable");
105
- const endpoints = suppliedEndpoints ?? loadModelEndpoints({ modelEndpointsFile: env.PI_MODEL_ENDPOINTS_FILE }, { cwd });
105
+ // Issue #859: the endpoints as doctor's other endpoint checks read them (`declaredEndpointsIn`: this shell's file, else
106
+ // the deployment `.env`'s), handed in and asked only here; this shell's alone when a caller hands none.
107
+ const endpoints = suppliedEndpoints ?? (typeof declaredEndpoints === "function" ? declaredEndpoints() : loadModelEndpoints({ modelEndpointsFile: env.PI_MODEL_ENDPOINTS_FILE }, { cwd }));
106
108
  checks.push(...cheaperRouteChecks(roles, config.routes, parsed.value, endpoints));
107
109
  } catch { checks.push({ proposal: { kind: "unavailable" }, ok: false, warn: true, label: "Model proposals: declared route prices or endpoints unavailable", fix: "check the overlay models.json and declared endpoints; no route was changed" }); }
108
110
  return checks;