SKYHOUSE.dev Journal

Maintaining the Cloud Fortress

The Corruption That Wasn't, and the RAM That Was

Why: A corrupt Postgres page in Immich's asset_exif table had been documented that morning as the top-priority fix, complete with a recommended repair. We sat down to run that repair. It turned out the page was fine, a second and much less pleasant problem was hiding behind it, and the recommended repair would have destroyed good data.

This one is worth reading for the method as much as the outcome. Two separate storage "corruptions" surfaced on the same morning, on two different drives on two different controllers. One was real. One was not. Telling them apart is what led us to the actual fault, which was in neither drive.

1. Two corruptions, one morning

The starting point was a note in the photo-migration handoff: one 8 KB page in asset_exif failed its checksum, pg_dump failed on that table, and so the nightly Immich DB backup was broken. The scope had been measured carefully — 24 of the 25 largest tables scanned clean, about 44 of 334,997 rows at risk — and two recovery paths were documented. The recommended fix was zero_damaged_pages = on followed by VACUUM FULL.

Before touching it I ran the checks the note said had never been run. Two things came back that weren't in the handoff at all:

2. The Postgres page that was never broken

The original diagnosis was reproducible — a forced heap scan errored every time. But a reproducible error is not the same as damaged media, and the test that had been run couldn't tell the difference between bad bytes on disk and good bytes misread. Postgres verifies a page's checksum after reading it into memory, so a fault anywhere along that path looks identical to a bad page.

The way to separate them is to force the read to come off the platter, which means clearing both caches:

docker stop immich_postgres                          # clears shared_buffers
sync; echo 3 | sudo tee /proc/sys/vm/drop_caches     # clears the OS page cache
docker start immich_postgres
docker exec immich_postgres psql -U postgres -d immich \
  -c "SET enable_indexonlyscan=off; SET enable_indexscan=off; SET enable_bitmapscan=off;
      SELECT count(*) FROM asset_exif;"

334,997 rows. Zero checksum failures. pg_dump -t public.asset_exif then finished in 2.6 seconds, exit 0, all 334,997 rows. The failure counter has sat frozen at 6 ever since, last incremented at 15:13.

The page on disk had always been fine. The nightly backup was never permanently broken — it would have worked the next time it ran. We zeroed nothing and restored nothing. Had we run the documented fix, zero_damaged_pages would have blanked a perfectly good 8 KB page and thrown away roughly 44 real rows to solve a problem that did not exist.

Before any of this I took a safety dump excluding the suspect table (--exclude-table-data=public.asset_exif), which succeeded and verified clean at 335,004 asset rows. It turned out to be unnecessary, which is the correct outcome for a safety net.

3. What was actually damaged on plex1

Having just learned not to trust an uncorroborated checksum error, I re-tested plex1 the same cold way. It failed the same way it had before: the zeroed inodes still read as bad type, debugfs still refused to open the volume, and ls on an affected directory still returned Bad message and logged fresh kernel errors. That damage is genuinely on disk.

Scope, measured rather than assumed: 19 corrupt inodes plus 31 block groups with bad block-bitmap checksums. I resolved all 19 to paths, and every one lives under Photos/thumbs/e00fa930-.../ — Immich thumbnails. Sixteen are empty thumbnail directories created between 07:47:01 and 07:47:30, three minutes before the morning's 07:50 reboot, while Immich was mass-generating thumbnails for 70,000 freshly imported photos. Three are inodes wiped to Links: 0.

The question that actually matters is whether any originals were hit. Two independent checks say no:

So the damage is confined to derived data that Immich can regenerate on demand.

4. Finding the common cause

At this point we had file data misread on the internal SATA SSD and file metadata corrupted on a USB disk, on the same morning, on different controllers. That pattern is worth taking seriously, so we went looking for what they share.

What we ruled out first:

That left memory, and memory is not monitored here because the RAM is non-ECC — there are no EDAC counters to consult. So I installed memtester and gave it 28 GB of the 48 GB free. It failed in under a minute, and kept failing:

offset 0x294fb2240   bit 43   byte lane 5   set
offset 0x294fb2d40   bit 44   byte lane 5   cleared
offset 0x354a3ef40   bit 43   byte lane 5   cleared
offset 0xbc479f0     bit 43   byte lane 5   set
offset 0x294fb2d40   bit 44   byte lane 5   cleared
   + "possible bad address line at offset 0x294fb2d40"

The pattern is the finding. Every single failure is a one-bit error on bit 43 or bit 44, and both of those live in byte lane 5 — across four different addresses spanning the whole 28 GB, produced by five unrelated test patterns. Random soft errors scatter across all 64 bits and all eight lanes. A fault that confines itself to two adjacent bits in one lane is a specific physical path: one DRAM chip, or a pair of adjacent data lines.

Contributing factor, almost certainly: the RAM is 2×32 GB non-ECC G.Skill DDR4 running at 3600 MT/s against an SMBIOS-rated 2600. Signal integrity degrades on particular data lines first, which is exactly the shape of what we're seeing — and it explains why memory that has been fine for years chose this week to misbehave, right after the heaviest sustained write the machine has ever done (a 195 GB import at load ~29).

One fault, both symptoms: it corrupted plex1's metadata on the way to disk, where it stuck, and the Postgres page on the way back from disk, where it didn't.

5. What we did, and what we deliberately didn't

6. The GRUB menu we could see but not steer

Making the GRUB menu visible (timeout 1 → 10, style hidden → menu) turned out to be only half the problem. On the first real attempt the menu appeared, counted down, and booted Ubuntu anyway — GRUB was not receiving any keystrokes. Neither a Logitech Unifying receiver nor Bluetooth worked, and both were plugged straight into rear motherboard ports, so hub depth was not the explanation. The wired Keychron K3 enumerates as an Apple Aluminium Keyboard (05ac:024f), which firmware is inconsistent about claiming at POST.

Rather than keep guessing at BIOS USB settings we could not reach without a working keyboard, we removed the need to press anything. GRUB's next_entry mechanism sets the default for exactly one boot and then clears itself, and this machine's generated grub.cfg already honours it unconditionally in the 00_header block — independently of GRUB_DEFAULT, which stays 0. grub-reboot only writes that variable, so no regeneration and no config edit were needed.

For the BIOS step that still lies ahead (dropping XMP to the rated speed), OsIndicationsSupported has bit 0 set, so systemctl reboot --firmware-setup boots straight into ASRock setup with no keypress. That solves getting in; navigating still needs the keyboard to be recognised.

7. Still open

Net effect: the "highest priority" corruption turned out not to exist, the repair we were about to run would have destroyed good rows, and the real fault was two bits in one byte lane of non-ECC RAM — found only because two unrelated drives failed on the same morning and that coincidence was treated as evidence rather than bad luck.

← Back to Admin Hub