Skip to content

Commit 5c737d0

Browse files
authored
v1.5.15 — TCP keepalive on remote Postgres connections (#77)
1 parent 3c4b086 commit 5c737d0

5 files changed

Lines changed: 201 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## 1.5.15 — 2026-06-23
4+
5+
### Fixes
6+
- **Keep remote Postgres connections alive in split deployments (#74).** When the worker and the database sit on different network segments, a stateful firewall or NAT can silently reap an idle connection; the worker then hangs on the next read until `read ETIMEDOUT` and doesn't recover (intermittent, since only idle connections get reaped). Immich doesn't expose a keepalive setting, so the worker now preloads a small shim that enables TCP keepAlive on every `pg` connection, keeping the socket warm so it isn't dropped. No-op for same-host setups; Immich's source is untouched. Tunable/disable via `IMMICH_ACCEL_PG_KEEPALIVE` / `IMMICH_ACCEL_PG_KEEPALIVE_MS`. Reported by [@shtefko](https://github.com/shtefko).
7+
38
## 1.5.14 — 2026-06-23
49

510
### Fixes

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.5.14
1+
1.5.15

immich_accelerator/__main__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3473,6 +3473,20 @@ def cmd_start(args):
34733473
f"{existing} {require_arg}".strip() if existing else require_arg
34743474
)
34753475

3476+
# pg keepalive shim (issue #74): in a split deployment a stateful firewall
3477+
# between the worker and a remote Postgres can silently reap an idle
3478+
# connection, after which the next read hangs to ETIMEDOUT and the worker
3479+
# never recovers. Immich doesn't expose a keepalive env var, so we wrap the
3480+
# `pg` module to set keepAlive on every connection. Same --require
3481+
# interposition; Immich's source is untouched. No-op for same-host setups.
3482+
pg_keepalive_shim = Path(__file__).parent / "hooks" / "pg_keepalive_shim.js"
3483+
if pg_keepalive_shim.exists():
3484+
existing = worker_env.get("NODE_OPTIONS", "").strip()
3485+
require_arg = f'--require "{pg_keepalive_shim}"'
3486+
worker_env["NODE_OPTIONS"] = (
3487+
f"{existing} {require_arg}".strip() if existing else require_arg
3488+
)
3489+
34763490
# /build link points to our build-data dir (set up during setup).
34773491
# Required for Immich 2.7+ plugin WASM paths stored in the shared DB.
34783492
build_data = DATA_DIR / "build-data"
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// pg_keepalive_shim.js
2+
//
3+
// Runtime interposition to enable TCP keepalive on Immich's Postgres
4+
// connections (issue #74).
5+
//
6+
// In a split deployment the worker holds long-lived connections across the
7+
// network to a remote Postgres. A stateful firewall or NAT between them (e.g.
8+
// the worker on one VLAN, the database on another) silently reaps a connection
9+
// once it has been idle past the firewall's timeout. node-postgres never learns
10+
// the socket is gone, so the next query's read just hangs until it fails with
11+
// `read ETIMEDOUT`, and the worker does not recover. This is intermittent by
12+
// nature — only idle connections get reaped, active ones survive — which is
13+
// what makes it confusing to diagnose.
14+
//
15+
// node-postgres supports `keepAlive` on the socket, but Immich doesn't expose
16+
// it as an env var (it's a Pool/Client option, not part of DB_URL). Rather than
17+
// patch Immich's source (which would break the "unmodified" invariant), we
18+
// preload this module via `NODE_OPTIONS=--require ...` and wrap the `pg`
19+
// module's Pool and Client constructors so every connection sets keepAlive,
20+
// keeping the socket warm so the firewall never considers it idle.
21+
//
22+
// Same interposition pattern as the pg_dump and HEIC shims. Off the hot path
23+
// (only runs at connection construction) and a no-op for same-host setups where
24+
// nothing reaps the connection. Opt-out / tune via env (see below).
25+
26+
'use strict';
27+
28+
// keepAlive is benign on a healthy LAN, so default it on. The initial delay is
29+
// how long a connection sits idle before the first keepalive probe; 10s is well
30+
// under typical firewall idle timeouts (often 60–300s) while staying cheap.
31+
const ENABLED = process.env.IMMICH_ACCEL_PG_KEEPALIVE !== '0';
32+
const DELAY_MS = parseInt(
33+
process.env.IMMICH_ACCEL_PG_KEEPALIVE_MS || '10000', 10
34+
);
35+
36+
function withKeepAlive(config) {
37+
// pg accepts the config as the first constructor arg (object or undefined;
38+
// a connection-string shorthand is also allowed). Only augment the object
39+
// form, and never override an explicit choice the caller already made.
40+
if (config == null || typeof config !== 'object') {
41+
config = { connectionString: config == null ? undefined : config };
42+
}
43+
if (config.keepAlive === undefined) {
44+
config.keepAlive = true;
45+
}
46+
if (config.keepAliveInitialDelayMillis === undefined) {
47+
config.keepAliveInitialDelayMillis = DELAY_MS;
48+
}
49+
return config;
50+
}
51+
52+
// A construct-trap Proxy preserves instanceof, the prototype chain, and static
53+
// properties (kysely and pg's own internals reference these), unlike wrapping
54+
// the class in a plain function.
55+
function wrapCtor(Original) {
56+
return new Proxy(Original, {
57+
construct(target, args, newTarget) {
58+
const next = args.slice();
59+
next[0] = withKeepAlive(next[0]);
60+
return Reflect.construct(target, next, newTarget);
61+
},
62+
});
63+
}
64+
65+
function patchPg(pg) {
66+
if (pg.Pool) pg.Pool = wrapCtor(pg.Pool);
67+
if (pg.Client) pg.Client = wrapCtor(pg.Client);
68+
process.stderr.write(
69+
`[immich-accelerator] pg keepalive enabled (initial delay ${DELAY_MS}ms)\n`
70+
);
71+
}
72+
73+
if (ENABLED) {
74+
const Module = require('module');
75+
const origLoad = Module._load;
76+
Module._load = function (request, parent, isMain) {
77+
const mod = origLoad.apply(this, arguments);
78+
if (request === 'pg' && mod && !mod.__keepAlivePatched) {
79+
try {
80+
patchPg(mod);
81+
mod.__keepAlivePatched = true;
82+
} catch (e) {
83+
process.stderr.write(
84+
'[immich-accelerator] pg keepalive shim failed: ' +
85+
String((e && e.message) || e) + '\n'
86+
);
87+
}
88+
}
89+
return mod;
90+
};
91+
}

tests/test_fresh_install.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1484,3 +1484,93 @@ def test_intercepts_sharp_routes_heic_passes_through_others(self, tmp_path):
14841484
"INPUT_TYPE:buffer",
14851485
"INPUT_TYPE:string",
14861486
], f"expected heic→buffer, other→string; got {types}\n{result.stdout}"
1487+
1488+
1489+
class TestPgKeepaliveShim:
1490+
"""The pg keepalive shim sets keepAlive on Immich's Postgres connections so
1491+
a stateful firewall between worker and a remote DB can't reap idle
1492+
connections (issue #74). Immich's source is never touched — the shim wraps
1493+
the `pg` module via NODE_OPTIONS=--require."""
1494+
1495+
SHIM_PATH = REPO_ROOT / "immich_accelerator" / "hooks" / "pg_keepalive_shim.js"
1496+
1497+
def test_shim_file_exists(self):
1498+
assert self.SHIM_PATH.exists(), f"hook shim missing: {self.SHIM_PATH}"
1499+
1500+
def test_shim_is_referenced_by_cmd_start(self):
1501+
src = (REPO_ROOT / "immich_accelerator" / "__main__.py").read_text()
1502+
assert "pg_keepalive_shim.js" in src
1503+
assert "NODE_OPTIONS" in src
1504+
1505+
@pytest.mark.slow
1506+
def test_shim_injects_keepalive_into_pg(self, tmp_path):
1507+
"""Run node with the shim preloaded against a fake `pg` module (no real
1508+
DB), construct a Pool, and confirm keepAlive is injected while
1509+
instanceof and an explicit caller value are both preserved."""
1510+
import shutil
1511+
1512+
node = shutil.which("node")
1513+
if not node:
1514+
pytest.skip("node not installed")
1515+
1516+
# Minimal stand-in for node-postgres: Pool/Client that stash their config.
1517+
pgdir = tmp_path / "node_modules" / "pg"
1518+
pgdir.mkdir(parents=True)
1519+
(pgdir / "index.js").write_text(
1520+
"class Pool { constructor(c){ this.options = c || {}; } }\n"
1521+
"class Client { constructor(c){ this.options = c || {}; } }\n"
1522+
"module.exports = { Pool, Client };\n"
1523+
)
1524+
1525+
caller = tmp_path / "caller.js"
1526+
caller.write_text(
1527+
"const { Pool } = require('pg');\n"
1528+
"const p = new Pool({ host: 'db' });\n"
1529+
"console.log('KEEPALIVE:' + p.options.keepAlive);\n"
1530+
"console.log('DELAY:' + p.options.keepAliveInitialDelayMillis);\n"
1531+
"console.log('INSTANCE:' + (p instanceof Pool));\n"
1532+
"const q = new Pool({ keepAlive: false });\n"
1533+
"console.log('EXPLICIT:' + q.options.keepAlive);\n"
1534+
)
1535+
result = subprocess.run(
1536+
[node, "--require", str(self.SHIM_PATH), str(caller)],
1537+
cwd=str(tmp_path),
1538+
capture_output=True,
1539+
text=True,
1540+
timeout=15,
1541+
)
1542+
assert result.returncode == 0, f"stdout={result.stdout}\nstderr={result.stderr}"
1543+
out = result.stdout
1544+
assert "KEEPALIVE:true" in out, out
1545+
assert "DELAY:10000" in out, out
1546+
assert "INSTANCE:true" in out, out # Proxy preserves instanceof
1547+
assert "EXPLICIT:false" in out, out # never override an explicit choice
1548+
1549+
@pytest.mark.slow
1550+
def test_shim_disabled_via_env(self, tmp_path):
1551+
import shutil
1552+
1553+
node = shutil.which("node")
1554+
if not node:
1555+
pytest.skip("node not installed")
1556+
pgdir = tmp_path / "node_modules" / "pg"
1557+
pgdir.mkdir(parents=True)
1558+
(pgdir / "index.js").write_text(
1559+
"class Pool { constructor(c){ this.options = c || {}; } }\n"
1560+
"module.exports = { Pool };\n"
1561+
)
1562+
caller = tmp_path / "caller.js"
1563+
caller.write_text(
1564+
"const { Pool } = require('pg');\n"
1565+
"console.log('KEEPALIVE:' + new Pool({}).options.keepAlive);\n"
1566+
)
1567+
result = subprocess.run(
1568+
[node, "--require", str(self.SHIM_PATH), str(caller)],
1569+
cwd=str(tmp_path),
1570+
env={"IMMICH_ACCEL_PG_KEEPALIVE": "0", "PATH": "/usr/bin:/bin"},
1571+
capture_output=True,
1572+
text=True,
1573+
timeout=15,
1574+
)
1575+
assert result.returncode == 0, result.stderr
1576+
assert "KEEPALIVE:undefined" in result.stdout, result.stdout

0 commit comments

Comments
 (0)