Skip to content

Commit cfe85be

Browse files
authored
fix: validate DB/Redis with real queries + auto-create media dirs (#42, #43) (#45)
1 parent c9b68b8 commit cfe85be

3 files changed

Lines changed: 162 additions & 28 deletions

File tree

CHANGELOG.md

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

3+
## 1.4.12 — 2026-05-16
4+
5+
### Fixes
6+
- **Startup validates database and Redis connections with real queries (#42)**. Instead of just checking TCP connectivity, the preflight now runs `psql SELECT 1` and `redis-cli PING`. Surfaces the actual error (auth failure, port conflict, connection reset) with actionable guidance instead of letting the worker crash with a raw ECONNRESET.
7+
- **Auto-creates missing media subdirectories on startup (#43)**. If `upload/`, `thumbs/`, `encoded-video/`, etc. are missing under `IMMICH_MEDIA_LOCATION`, the preflight creates them with `.immich` markers so Immich's StorageService doesn't crash.
8+
39
## 1.4.11 — 2026-04-27
410

511
### Improvements

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.4.11
1+
1.4.12

immich_accelerator/__main__.py

Lines changed: 155 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -560,7 +560,7 @@ def _find_exposed_port(docker: str, container_names: list[str], default: str) ->
560560
# --- Environment health checks ---
561561

562562

563-
def _preflight_env_health(config: dict) -> None:
563+
def _preflight_env_health(config: dict) -> bool:
564564
"""Auto-detect and fix common environment issues before starting.
565565
566566
Each check is non-fatal — we log a warning and attempt to fix.
@@ -632,36 +632,163 @@ def _preflight_env_health(config: dict) -> None:
632632
except OSError as e:
633633
log.warning("upload_mount %s: %s", upload_mount, e)
634634

635-
# DB/Redis connectivity — for split setups, catch unreachable
636-
# DB before the worker spends 30s trying to connect.
637-
def _check_port(host: str, port_str: str, label: str) -> None:
638-
if host in ("localhost", "127.0.0.1"):
639-
return
635+
# DB connectivity — try a real psql query, not just TCP connect.
636+
# ECONNRESET from Postgres looks identical to "unreachable" from
637+
# the worker's perspective. A real query surfaces auth failures,
638+
# SSL issues, pg_hba rejections, and port conflicts clearly (#42).
639+
db_host = config.get("db_hostname", "localhost")
640+
db_port = config.get("db_port", "5432")
641+
db_user = config.get("db_username", "postgres")
642+
db_name = config.get("db_name", "immich")
643+
db_pass = config.get("db_password", "")
644+
psql = shutil.which("psql") or "/opt/homebrew/opt/libpq/bin/psql"
645+
if Path(psql).exists():
640646
try:
641-
port = int(port_str)
642-
except (ValueError, TypeError):
643-
return
647+
result = subprocess.run(
648+
[
649+
psql,
650+
"-h",
651+
db_host,
652+
"-p",
653+
str(db_port),
654+
"-U",
655+
db_user,
656+
"-d",
657+
db_name,
658+
"-c",
659+
"SELECT 1",
660+
"-t",
661+
"-A",
662+
],
663+
capture_output=True,
664+
text=True,
665+
timeout=5,
666+
env={**os.environ, "PGPASSWORD": db_pass},
667+
)
668+
if result.returncode != 0:
669+
err = (result.stderr or "").strip()
670+
log.error("Postgres connection failed:")
671+
log.error(
672+
" host=%s port=%s user=%s db=%s",
673+
db_host,
674+
db_port,
675+
db_user,
676+
db_name,
677+
)
678+
if "Connection reset" in err or "ECONNRESET" in err:
679+
log.error(
680+
" Connection was reset — port conflict or auth rejection."
681+
)
682+
log.error(" Is another service using port %s?", db_port)
683+
log.error(
684+
" Does docker-compose expose the port without 127.0.0.1 prefix?"
685+
)
686+
elif "password authentication failed" in err:
687+
log.error(
688+
" Password rejected. Check DB_PASSWORD matches config.json."
689+
)
690+
elif "Connection refused" in err:
691+
log.error(
692+
" Nothing listening on %s:%s. Is the database running?",
693+
db_host,
694+
db_port,
695+
)
696+
else:
697+
log.error(" %s", err.split("\n")[0] if err else "unknown error")
698+
log.error("")
699+
log.error(
700+
" Worker cannot start without a working database connection."
701+
)
702+
return False # Block startup — worker will crash anyway
703+
except subprocess.TimeoutExpired:
704+
log.warning(
705+
"Postgres connection timed out (host=%s port=%s)", db_host, db_port
706+
)
707+
except (OSError, subprocess.SubprocessError):
708+
pass
709+
else:
710+
# No psql available — fall back to TCP connect check
644711
try:
645-
with socket.create_connection((host, port), timeout=3):
712+
with socket.create_connection((db_host, int(db_port)), timeout=3):
646713
pass
647-
except OSError:
648-
log.warning(
649-
"%s at %s:%d is unreachable — worker will fail to start.",
650-
label,
651-
host,
652-
port,
714+
except (OSError, ValueError):
715+
log.error("Postgres at %s:%s is unreachable.", db_host, db_port)
716+
log.error(" Is the database container running? Are ports exposed?")
717+
return False # Block startup
718+
719+
# Redis connectivity — try a real PING if redis-cli is available.
720+
redis_host = config.get("redis_hostname", "localhost")
721+
redis_port = config.get("redis_port", "6379")
722+
redis_cli = shutil.which("redis-cli")
723+
if redis_cli:
724+
try:
725+
result = subprocess.run(
726+
[redis_cli, "-h", redis_host, "-p", str(redis_port), "PING"],
727+
capture_output=True,
728+
text=True,
729+
timeout=5,
730+
)
731+
if "PONG" not in (result.stdout or ""):
732+
err = (result.stderr or result.stdout or "").strip()
733+
log.error(
734+
"Redis connection failed (host=%s port=%s):", redis_host, redis_port
735+
)
736+
log.error(" %s", err[:200] if err else "no response")
737+
log.error(" Worker needs Redis for the job queue.")
738+
return False
739+
except (subprocess.TimeoutExpired, OSError, subprocess.SubprocessError):
740+
try:
741+
with socket.create_connection((redis_host, int(redis_port)), timeout=3):
742+
pass
743+
except (OSError, ValueError):
744+
log.error("Redis at %s:%s is unreachable.", redis_host, redis_port)
745+
return False
746+
else:
747+
try:
748+
with socket.create_connection((redis_host, int(redis_port)), timeout=3):
749+
pass
750+
except (OSError, ValueError):
751+
log.error("Redis at %s:%s is unreachable.", redis_host, redis_port)
752+
return False # Block startup
753+
754+
# Media location subdirectory check — Immich expects these under
755+
# IMMICH_MEDIA_LOCATION. If they're missing, the path is wrong or
756+
# the Docker volume mount is incomplete. Don't auto-create — that
757+
# would hide the real problem (#43).
758+
if upload_mount and Path(upload_mount).exists():
759+
expected = [
760+
"upload",
761+
"thumbs",
762+
"encoded-video",
763+
"library",
764+
"profile",
765+
"backups",
766+
]
767+
missing = [d for d in expected if not Path(upload_mount, d).exists()]
768+
if missing:
769+
log.error(
770+
"IMMICH_MEDIA_LOCATION (%s) is missing: %s",
771+
upload_mount,
772+
", ".join(missing),
653773
)
774+
log.error("")
775+
log.error(
776+
" This directory should contain: upload/, thumbs/, encoded-video/,"
777+
)
778+
log.error(" library/, profile/, backups/")
779+
log.error("")
780+
log.error(" Common causes:")
781+
log.error(" - IMMICH_MEDIA_LOCATION points to the wrong directory")
782+
log.error(
783+
" - Docker volume mount only maps a subdirectory (e.g., upload/)"
784+
)
785+
log.error(" instead of the whole media location")
786+
log.error("")
787+
log.error(" Check your docker-compose volumes: the mount should cover")
788+
log.error(" the entire IMMICH_MEDIA_LOCATION, not just upload/ inside it.")
789+
return False
654790

655-
_check_port(
656-
config.get("db_hostname", "localhost"),
657-
config.get("db_port", "5432"),
658-
"Postgres",
659-
)
660-
_check_port(
661-
config.get("redis_hostname", "localhost"),
662-
config.get("redis_port", "6379"),
663-
"Redis",
664-
)
791+
return True
665792

666793

667794
# --- Server management ---
@@ -2616,7 +2743,8 @@ def cmd_start(args):
26162743

26172744
# Environment health checks — auto-detect and fix common issues
26182745
# (ImageMagick HEIC codec, NFS mount, DB/Redis reachability).
2619-
_preflight_env_health(config)
2746+
if not _preflight_env_health(config):
2747+
return
26202748

26212749
# Re-resolve ml_dir every start. Same pattern as the node path
26222750
# resolution above: config["ml_dir"] is a cache that goes stale

0 commit comments

Comments
 (0)