@fedify/postgres 2.0.35-dev.2459 → 2.0.35-dev.2468
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/dist/kv.cjs +59 -9
- package/dist/kv.js +59 -9
- package/package.json +3 -3
package/dist/kv.cjs
CHANGED
|
@@ -8,6 +8,9 @@ const logger = (0, require("@logtape/logtape").getLogger)([
|
|
|
8
8
|
"postgres",
|
|
9
9
|
"kv"
|
|
10
10
|
]);
|
|
11
|
+
const expiryBatchSize = 1e3;
|
|
12
|
+
const expiryInterval = 6e4;
|
|
13
|
+
const expiryCatchUpInterval = 1e3;
|
|
11
14
|
/**
|
|
12
15
|
* A key–value store that uses PostgreSQL as the underlying storage.
|
|
13
16
|
*
|
|
@@ -29,6 +32,8 @@ var PostgresKvStore = class {
|
|
|
29
32
|
#skipDdl;
|
|
30
33
|
#initialized = false;
|
|
31
34
|
#driverSerializesJson = false;
|
|
35
|
+
#expiring;
|
|
36
|
+
#nextExpiry = 0;
|
|
32
37
|
/**
|
|
33
38
|
* Creates a new PostgreSQL key–value store.
|
|
34
39
|
* @param sql The PostgreSQL client to use.
|
|
@@ -39,18 +44,58 @@ var PostgresKvStore = class {
|
|
|
39
44
|
this.#tableName = options.tableName ?? "fedify_kv_v2";
|
|
40
45
|
this.#skipDdl = options.initialized ?? false;
|
|
41
46
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
47
|
+
#expire() {
|
|
48
|
+
const callerOwned = "savepoint" in this.#sql && typeof this.#sql.savepoint === "function" || "release" in this.#sql && typeof this.#sql.release === "function";
|
|
49
|
+
if (this.#expiring) return callerOwned ? this.#expiring : void 0;
|
|
50
|
+
if (performance.now() < this.#nextExpiry) return;
|
|
51
|
+
this.#expiring = (async () => {
|
|
52
|
+
let interval = expiryInterval;
|
|
53
|
+
try {
|
|
54
|
+
if (await this.#sweep() === expiryBatchSize) interval = expiryCatchUpInterval;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (callerOwned) throw error;
|
|
57
|
+
logger.warn("Failed to expire keys in {tableName}: {error}", {
|
|
58
|
+
tableName: this.#tableName,
|
|
59
|
+
error
|
|
60
|
+
});
|
|
61
|
+
} finally {
|
|
62
|
+
this.#nextExpiry = performance.now() + interval * (1 + Math.random());
|
|
63
|
+
this.#expiring = void 0;
|
|
64
|
+
}
|
|
65
|
+
})();
|
|
66
|
+
return callerOwned ? this.#expiring : void 0;
|
|
67
|
+
}
|
|
68
|
+
async #sweep() {
|
|
69
|
+
const [result] = await this.#sql`
|
|
70
|
+
WITH sweep_lock AS MATERIALIZED (
|
|
71
|
+
SELECT pg_try_advisory_xact_lock(
|
|
72
|
+
(hashtext('fedify.postgres.kv.expire')::bigint << 32) |
|
|
73
|
+
(SELECT typrelid::bigint FROM pg_catalog.pg_type
|
|
74
|
+
WHERE oid = pg_typeof(NULL::${this.#sql(this.#tableName)})::oid)
|
|
75
|
+
) AS acquired
|
|
76
|
+
), candidates AS MATERIALIZED (
|
|
77
|
+
SELECT tableoid AS relation, ctid AS tid
|
|
78
|
+
FROM ${this.#sql(this.#tableName)}
|
|
79
|
+
WHERE (SELECT acquired FROM sweep_lock)
|
|
80
|
+
AND ttl IS NOT NULL AND created + ttl < statement_timestamp()
|
|
81
|
+
LIMIT ${expiryBatchSize} FOR UPDATE SKIP LOCKED
|
|
82
|
+
), deleted AS (
|
|
83
|
+
DELETE FROM ${this.#sql(this.#tableName)}
|
|
84
|
+
WHERE ctid = ANY(ARRAY(SELECT tid FROM candidates))
|
|
85
|
+
AND (tableoid, ctid) IN (SELECT relation, tid FROM candidates)
|
|
86
|
+
AND ttl IS NOT NULL AND created + ttl < statement_timestamp()
|
|
87
|
+
RETURNING 1
|
|
88
|
+
)
|
|
89
|
+
SELECT count(*)::int AS count FROM deleted;
|
|
46
90
|
`;
|
|
91
|
+
return result.count;
|
|
47
92
|
}
|
|
48
93
|
async get(key) {
|
|
49
94
|
await this.initialize();
|
|
50
95
|
const result = await this.#sql`
|
|
51
96
|
SELECT value
|
|
52
97
|
FROM ${this.#sql(this.#tableName)}
|
|
53
|
-
WHERE key = ${key} AND (ttl IS NULL OR created + ttl >
|
|
98
|
+
WHERE key = ${key} AND (ttl IS NULL OR created + ttl > statement_timestamp());
|
|
54
99
|
`;
|
|
55
100
|
if (result.length < 1) return void 0;
|
|
56
101
|
return result[0].value;
|
|
@@ -60,14 +105,18 @@ var PostgresKvStore = class {
|
|
|
60
105
|
const ttl = options?.ttl == null ? null : options.ttl.toString();
|
|
61
106
|
const jsonValue = value === null ? this.#sql`'null'::jsonb` : this.#json(value);
|
|
62
107
|
await this.#sql`
|
|
63
|
-
INSERT INTO ${this.#sql(this.#tableName)} (key, value, ttl)
|
|
108
|
+
INSERT INTO ${this.#sql(this.#tableName)} (key, value, created, ttl)
|
|
64
109
|
VALUES (
|
|
65
110
|
${key},
|
|
66
111
|
${jsonValue},
|
|
112
|
+
statement_timestamp(),
|
|
67
113
|
${ttl}
|
|
68
114
|
)
|
|
69
115
|
ON CONFLICT (key)
|
|
70
|
-
DO UPDATE SET
|
|
116
|
+
DO UPDATE SET
|
|
117
|
+
value = EXCLUDED.value,
|
|
118
|
+
created = statement_timestamp(),
|
|
119
|
+
ttl = EXCLUDED.ttl;
|
|
71
120
|
`;
|
|
72
121
|
await this.#expire();
|
|
73
122
|
}
|
|
@@ -89,7 +138,7 @@ var PostgresKvStore = class {
|
|
|
89
138
|
if (prefix == null || prefix.length === 0) results = await this.#sql`
|
|
90
139
|
SELECT key, value
|
|
91
140
|
FROM ${this.#sql(this.#tableName)}
|
|
92
|
-
WHERE ttl IS NULL OR created + ttl >
|
|
141
|
+
WHERE ttl IS NULL OR created + ttl > statement_timestamp()
|
|
93
142
|
ORDER BY key
|
|
94
143
|
`;
|
|
95
144
|
else {
|
|
@@ -99,7 +148,7 @@ var PostgresKvStore = class {
|
|
|
99
148
|
FROM ${this.#sql(this.#tableName)}
|
|
100
149
|
WHERE array_length(key, 1) >= ${prefixLength}
|
|
101
150
|
AND key[1:${prefixLength}] = ${prefix}::text[]
|
|
102
|
-
AND (ttl IS NULL OR created + ttl >
|
|
151
|
+
AND (ttl IS NULL OR created + ttl > statement_timestamp())
|
|
103
152
|
ORDER BY key
|
|
104
153
|
`;
|
|
105
154
|
}
|
|
@@ -135,6 +184,7 @@ var PostgresKvStore = class {
|
|
|
135
184
|
* does not exist.
|
|
136
185
|
*/
|
|
137
186
|
async drop() {
|
|
187
|
+
await this.#expiring;
|
|
138
188
|
await this.#sql`DROP TABLE IF EXISTS ${this.#sql(this.#tableName)};`;
|
|
139
189
|
}
|
|
140
190
|
#json(value) {
|
package/dist/kv.js
CHANGED
|
@@ -7,6 +7,9 @@ const logger = getLogger([
|
|
|
7
7
|
"postgres",
|
|
8
8
|
"kv"
|
|
9
9
|
]);
|
|
10
|
+
const expiryBatchSize = 1e3;
|
|
11
|
+
const expiryInterval = 6e4;
|
|
12
|
+
const expiryCatchUpInterval = 1e3;
|
|
10
13
|
/**
|
|
11
14
|
* A key–value store that uses PostgreSQL as the underlying storage.
|
|
12
15
|
*
|
|
@@ -28,6 +31,8 @@ var PostgresKvStore = class {
|
|
|
28
31
|
#skipDdl;
|
|
29
32
|
#initialized = false;
|
|
30
33
|
#driverSerializesJson = false;
|
|
34
|
+
#expiring;
|
|
35
|
+
#nextExpiry = 0;
|
|
31
36
|
/**
|
|
32
37
|
* Creates a new PostgreSQL key–value store.
|
|
33
38
|
* @param sql The PostgreSQL client to use.
|
|
@@ -38,18 +43,58 @@ var PostgresKvStore = class {
|
|
|
38
43
|
this.#tableName = options.tableName ?? "fedify_kv_v2";
|
|
39
44
|
this.#skipDdl = options.initialized ?? false;
|
|
40
45
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
#expire() {
|
|
47
|
+
const callerOwned = "savepoint" in this.#sql && typeof this.#sql.savepoint === "function" || "release" in this.#sql && typeof this.#sql.release === "function";
|
|
48
|
+
if (this.#expiring) return callerOwned ? this.#expiring : void 0;
|
|
49
|
+
if (performance.now() < this.#nextExpiry) return;
|
|
50
|
+
this.#expiring = (async () => {
|
|
51
|
+
let interval = expiryInterval;
|
|
52
|
+
try {
|
|
53
|
+
if (await this.#sweep() === expiryBatchSize) interval = expiryCatchUpInterval;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (callerOwned) throw error;
|
|
56
|
+
logger.warn("Failed to expire keys in {tableName}: {error}", {
|
|
57
|
+
tableName: this.#tableName,
|
|
58
|
+
error
|
|
59
|
+
});
|
|
60
|
+
} finally {
|
|
61
|
+
this.#nextExpiry = performance.now() + interval * (1 + Math.random());
|
|
62
|
+
this.#expiring = void 0;
|
|
63
|
+
}
|
|
64
|
+
})();
|
|
65
|
+
return callerOwned ? this.#expiring : void 0;
|
|
66
|
+
}
|
|
67
|
+
async #sweep() {
|
|
68
|
+
const [result] = await this.#sql`
|
|
69
|
+
WITH sweep_lock AS MATERIALIZED (
|
|
70
|
+
SELECT pg_try_advisory_xact_lock(
|
|
71
|
+
(hashtext('fedify.postgres.kv.expire')::bigint << 32) |
|
|
72
|
+
(SELECT typrelid::bigint FROM pg_catalog.pg_type
|
|
73
|
+
WHERE oid = pg_typeof(NULL::${this.#sql(this.#tableName)})::oid)
|
|
74
|
+
) AS acquired
|
|
75
|
+
), candidates AS MATERIALIZED (
|
|
76
|
+
SELECT tableoid AS relation, ctid AS tid
|
|
77
|
+
FROM ${this.#sql(this.#tableName)}
|
|
78
|
+
WHERE (SELECT acquired FROM sweep_lock)
|
|
79
|
+
AND ttl IS NOT NULL AND created + ttl < statement_timestamp()
|
|
80
|
+
LIMIT ${expiryBatchSize} FOR UPDATE SKIP LOCKED
|
|
81
|
+
), deleted AS (
|
|
82
|
+
DELETE FROM ${this.#sql(this.#tableName)}
|
|
83
|
+
WHERE ctid = ANY(ARRAY(SELECT tid FROM candidates))
|
|
84
|
+
AND (tableoid, ctid) IN (SELECT relation, tid FROM candidates)
|
|
85
|
+
AND ttl IS NOT NULL AND created + ttl < statement_timestamp()
|
|
86
|
+
RETURNING 1
|
|
87
|
+
)
|
|
88
|
+
SELECT count(*)::int AS count FROM deleted;
|
|
45
89
|
`;
|
|
90
|
+
return result.count;
|
|
46
91
|
}
|
|
47
92
|
async get(key) {
|
|
48
93
|
await this.initialize();
|
|
49
94
|
const result = await this.#sql`
|
|
50
95
|
SELECT value
|
|
51
96
|
FROM ${this.#sql(this.#tableName)}
|
|
52
|
-
WHERE key = ${key} AND (ttl IS NULL OR created + ttl >
|
|
97
|
+
WHERE key = ${key} AND (ttl IS NULL OR created + ttl > statement_timestamp());
|
|
53
98
|
`;
|
|
54
99
|
if (result.length < 1) return void 0;
|
|
55
100
|
return result[0].value;
|
|
@@ -59,14 +104,18 @@ var PostgresKvStore = class {
|
|
|
59
104
|
const ttl = options?.ttl == null ? null : options.ttl.toString();
|
|
60
105
|
const jsonValue = value === null ? this.#sql`'null'::jsonb` : this.#json(value);
|
|
61
106
|
await this.#sql`
|
|
62
|
-
INSERT INTO ${this.#sql(this.#tableName)} (key, value, ttl)
|
|
107
|
+
INSERT INTO ${this.#sql(this.#tableName)} (key, value, created, ttl)
|
|
63
108
|
VALUES (
|
|
64
109
|
${key},
|
|
65
110
|
${jsonValue},
|
|
111
|
+
statement_timestamp(),
|
|
66
112
|
${ttl}
|
|
67
113
|
)
|
|
68
114
|
ON CONFLICT (key)
|
|
69
|
-
DO UPDATE SET
|
|
115
|
+
DO UPDATE SET
|
|
116
|
+
value = EXCLUDED.value,
|
|
117
|
+
created = statement_timestamp(),
|
|
118
|
+
ttl = EXCLUDED.ttl;
|
|
70
119
|
`;
|
|
71
120
|
await this.#expire();
|
|
72
121
|
}
|
|
@@ -88,7 +137,7 @@ var PostgresKvStore = class {
|
|
|
88
137
|
if (prefix == null || prefix.length === 0) results = await this.#sql`
|
|
89
138
|
SELECT key, value
|
|
90
139
|
FROM ${this.#sql(this.#tableName)}
|
|
91
|
-
WHERE ttl IS NULL OR created + ttl >
|
|
140
|
+
WHERE ttl IS NULL OR created + ttl > statement_timestamp()
|
|
92
141
|
ORDER BY key
|
|
93
142
|
`;
|
|
94
143
|
else {
|
|
@@ -98,7 +147,7 @@ var PostgresKvStore = class {
|
|
|
98
147
|
FROM ${this.#sql(this.#tableName)}
|
|
99
148
|
WHERE array_length(key, 1) >= ${prefixLength}
|
|
100
149
|
AND key[1:${prefixLength}] = ${prefix}::text[]
|
|
101
|
-
AND (ttl IS NULL OR created + ttl >
|
|
150
|
+
AND (ttl IS NULL OR created + ttl > statement_timestamp())
|
|
102
151
|
ORDER BY key
|
|
103
152
|
`;
|
|
104
153
|
}
|
|
@@ -134,6 +183,7 @@ var PostgresKvStore = class {
|
|
|
134
183
|
* does not exist.
|
|
135
184
|
*/
|
|
136
185
|
async drop() {
|
|
186
|
+
await this.#expiring;
|
|
137
187
|
await this.#sql`DROP TABLE IF EXISTS ${this.#sql(this.#tableName)};`;
|
|
138
188
|
}
|
|
139
189
|
#json(value) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fedify/postgres",
|
|
3
|
-
"version": "2.0.35-dev.
|
|
3
|
+
"version": "2.0.35-dev.2468+595e3cce",
|
|
4
4
|
"description": "PostgreSQL drivers for Fedify",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"fedify",
|
|
@@ -74,14 +74,14 @@
|
|
|
74
74
|
},
|
|
75
75
|
"peerDependencies": {
|
|
76
76
|
"postgres": "^3.4.7",
|
|
77
|
-
"@fedify/fedify": "^2.0.35-dev.
|
|
77
|
+
"@fedify/fedify": "^2.0.35-dev.2468+595e3cce"
|
|
78
78
|
},
|
|
79
79
|
"devDependencies": {
|
|
80
80
|
"@std/async": "npm:@jsr/std__async@^1.0.13",
|
|
81
81
|
"tsdown": "^0.21.6",
|
|
82
82
|
"typescript": "^6.0.0",
|
|
83
83
|
"@fedify/fixture": "^2.0.0",
|
|
84
|
-
"@fedify/testing": "^2.0.35-dev.
|
|
84
|
+
"@fedify/testing": "^2.0.35-dev.2468+595e3cce"
|
|
85
85
|
},
|
|
86
86
|
"scripts": {
|
|
87
87
|
"build:self": "tsdown",
|