Skip to content

Commit fa0c86b

Browse files
committed
Merge pull request #1271 from dahlia/bugfix/postgres-init-retry
Allow PostgreSQL queue initialization to retry after failure
2 parents bef46b3 + 8b83911 commit fa0c86b

4 files changed

Lines changed: 132 additions & 2 deletions

File tree

‎CHANGES.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@ To be released.
3939
[#1244]: https://github.com/fedify-dev/fedify/issues/1244
4040
[#1261]: https://github.com/fedify-dev/fedify/pull/1261
4141

42+
### @fedify/postgres
43+
44+
- Fixed `PostgresMessageQueue` remaining unusable after a transient
45+
initialization failure. Later calls now retry initialization, so the same
46+
queue instance can recover when the database becomes available again.
47+
[[#1268], [#1271]]
48+
49+
[#1268]: https://github.com/fedify-dev/fedify/issues/1268
50+
[#1271]: https://github.com/fedify-dev/fedify/pull/1271
51+
4252

4353
Version 2.0.31
4454
--------------
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
links:
3+
'#1268': https://github.com/fedify-dev/fedify/issues/1268
4+
'#1271': https://github.com/fedify-dev/fedify/pull/1271
5+
---
6+
- Fixed `PostgresMessageQueue` remaining unusable after a transient
7+
initialization failure. Later calls now retry initialization, so the same
8+
queue instance can recover when the database becomes available again.
9+
[[#1268], [#1271]]

‎packages/postgres/src/mq.test.ts‎

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import { test } from "@fedify/fixture";
22
import { PostgresMessageQueue } from "@fedify/postgres/mq";
33
import { getRandomKey, testMessageQueue } from "@fedify/testing";
44
import * as temporal from "@js-temporal/polyfill";
5-
import { deepStrictEqual, rejects } from "node:assert/strict";
5+
import {
6+
deepStrictEqual,
7+
notStrictEqual,
8+
rejects,
9+
strictEqual,
10+
} from "node:assert/strict";
611
import process from "node:process";
712
import { test as nodeTest } from "node:test";
813
import postgres from "postgres";
@@ -11,6 +16,107 @@ const Temporal = globalThis.Temporal ?? temporal.Temporal;
1116

1217
const dbUrl = process.env.POSTGRES_URL;
1318

19+
// Gate both attempts so concurrent callers cannot accidentally pass by running
20+
// separate initializations that happen to finish before the assertions.
21+
for (
22+
const { phase, failAt, initialized } of [
23+
{ phase: "first DDL", failAt: 1, initialized: false },
24+
{ phase: "partial DDL", failAt: 2, initialized: false },
25+
{ phase: "JSON probe", failAt: 4, initialized: false },
26+
{ phase: "JSON probe without DDL", failAt: 1, initialized: true },
27+
]
28+
) {
29+
test(`PostgresMessageQueue.initialize() retries after ${phase} failure`, async () => {
30+
const failure = Object.assign(new Error("statement timeout"), {
31+
code: "57014",
32+
});
33+
const failureStarted = Promise.withResolvers<void>();
34+
const failedQuery = Promise.withResolvers<{ test: string }[]>();
35+
const retryQuery = Promise.withResolvers<{ test: string }[]>();
36+
let queries = 0;
37+
const statements: string[] = [];
38+
const result = [{ test: '{"foo":1}' }];
39+
const sql = Object.assign(
40+
(strings: TemplateStringsArray | string) => {
41+
if (!Array.isArray(strings)) return strings;
42+
statements.push(strings.join(""));
43+
queries++;
44+
if (queries === failAt) {
45+
failureStarted.resolve();
46+
return failedQuery.promise;
47+
}
48+
if (queries === failAt + 1) return retryQuery.promise;
49+
return Promise.resolve(result);
50+
},
51+
{ json: (value: unknown) => value },
52+
) as unknown as postgres.Sql;
53+
const mq = new PostgresMessageQueue(sql, { initialized });
54+
const first = mq.initialize();
55+
const concurrent = mq.initialize();
56+
strictEqual(first, concurrent, "pending callers must share one promise");
57+
const outcomes = Promise.allSettled([first, concurrent]);
58+
await failureStarted.promise;
59+
const expectedStatement = phase === "first DDL"
60+
? "CREATE TABLE"
61+
: phase === "partial DDL"
62+
? "ALTER TABLE"
63+
: "SELECT";
64+
strictEqual(statements.at(-1)?.includes(expectedStatement), true);
65+
failedQuery.reject(failure);
66+
const rejected = await outcomes;
67+
for (const outcome of rejected) {
68+
strictEqual(outcome.status, "rejected");
69+
if (outcome.status === "rejected") strictEqual(outcome.reason, failure);
70+
}
71+
await new Promise((resolve) => setTimeout(resolve, 0));
72+
strictEqual(queries, failAt, "a rejection must not automatically retry");
73+
74+
const retry = mq.initialize();
75+
const concurrentRetry = mq.initialize();
76+
notStrictEqual(retry, first, "a later call must start a new attempt");
77+
strictEqual(retry, concurrentRetry);
78+
strictEqual(queries, failAt + 1);
79+
retryQuery.resolve(result);
80+
await Promise.all([retry, concurrentRetry]);
81+
const expectedQueries = failAt + (initialized ? 1 : 4);
82+
strictEqual(queries, expectedQueries);
83+
await mq.initialize();
84+
strictEqual(
85+
queries,
86+
expectedQueries,
87+
"successful initialization is cached",
88+
);
89+
});
90+
}
91+
92+
test("PostgresMessageQueue.enqueue() recovers after initialization failure", async () => {
93+
const failure = new Error("database unavailable");
94+
let queries = 0;
95+
let notifications = 0;
96+
const sql = Object.assign(
97+
(strings: TemplateStringsArray | string) => {
98+
if (!Array.isArray(strings)) return strings;
99+
queries++;
100+
if (queries === 1) return Promise.reject(failure);
101+
return Promise.resolve([{ test: '{"foo":1}' }]);
102+
},
103+
{
104+
json: (value: unknown) => value,
105+
notify: () => {
106+
notifications++;
107+
return Promise.resolve();
108+
},
109+
},
110+
) as unknown as postgres.Sql;
111+
const mq = new PostgresMessageQueue(sql);
112+
await rejects(mq.enqueue("first"), (error: unknown) => error === failure);
113+
strictEqual(queries, 1);
114+
strictEqual(notifications, 0);
115+
await mq.enqueue("second");
116+
strictEqual(queries, 6, "retry runs four initialization queries and INSERT");
117+
strictEqual(notifications, 1);
118+
});
119+
14120
test("PostgresMessageQueue", { ignore: dbUrl == null }, () => {
15121
if (dbUrl == null) return; // Bun does not support skip option
16122

‎packages/postgres/src/mq.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -449,10 +449,15 @@ export class PostgresMessageQueue implements MessageQueue {
449449

450450
/**
451451
* Initializes the message queue table if it does not already exist.
452+
* Concurrent calls share one initialization attempt. If it fails, a later
453+
* call retries initialization.
452454
*/
453455
initialize(): Promise<void> {
454456
if (this.#initialized) return Promise.resolve();
455-
return (this.#initPromise ??= this.#doInitialize());
457+
return (this.#initPromise ??= this.#doInitialize().catch((error) => {
458+
this.#initPromise = undefined;
459+
throw error;
460+
}));
456461
}
457462

458463
async #doInitialize(): Promise<void> {

0 commit comments

Comments
 (0)