Compare commits

..
2 Commits
Author SHA1 Message Date
Pouzor a58725c2ee chore: bump version to 3.3.2
ha-relevant: no
2026-08-17 11:37:54 +02:00
Pouzor 466df59b0c fix(inventory): finish the 3.3.0 backfill on databases carrying timestamps
3.3.1 kept `nodes` writable when the backfill failed, but the backfill still
failed — so a canvas upgraded from 3.2.0 shows every device stripped of its ip,
services, hostname, notes and hardware. The facts are intact in the legacy
columns; nothing was ever reading them across.

The backfill reads those columns with raw SQL, so SQLite hands `last_seen` and
`last_scan` back as text. Writing text into a DateTime column raises
`TypeError: SQLite DateTime type only accepts Python datetime and date objects`,
which is neither IntegrityError nor OperationalError — so it went straight past
the per-node savepoint and aborted the whole run, exactly as before.

- Parse the legacy timestamps back into datetimes, the way `_decode_json`
  already handles the JSON columns. An unparseable stamp becomes None rather
  than an error: the status checker refreshes both within a minute.
- Catch every exception per node, not two chosen classes. Picking the exception
  types was the defect; a node that fails for any reason must cost only itself.

The same run would also have destroyed data. A canvas saved while an earlier
migration was stuck minted a blank inventory row from a UI that had no facts to
show and linked the node to it. The backfill only looked at unlinked nodes, so
it skipped that one, counted the canvas migrated, and dropped the columns
holding its only copy of the ip and services.

- Read linked nodes too, and fill only what their row is missing. A row that
  already holds a fact is never overwritten, so an edit made after the migration
  survives, and the pass stays a no-op once everything has moved across.

Verified end to end against a rebuilt 3.2.0 database stuck the way the reports
describe: one boot restores ip, services, hostname, mac, notes, check method,
hardware, properties, status and last_seen, then drops the legacy columns.

Refs #347, #348, #351

ha-relevant: no
2026-08-17 11:36:32 +02:00
6 changed files with 208 additions and 16 deletions
+7
View File
@@ -5,6 +5,13 @@ All notable changes to **Homelable** are documented here.
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/),
and this project adheres to [Semantic Versioning](https://semver.org/).
## [3.3.2] - 2026-08-17
### Fixes
- Devices upgraded from 3.2.0 keep their ip, services, hostname, notes and hardware. The 3.3.0 migration aborted on timestamps SQLite returns as text, so nothing was carried across to the Device Inventory and every device read back blank. (#347, #348)
- A canvas saved while an earlier migration was stuck no longer loses its device facts: the migration now fills the blank inventory row that save created, instead of skipping the node and dropping the columns holding its only copy. (#347)
## [3.3.1] - 2026-08-17
### Fixes
+1 -1
View File
@@ -1 +1 @@
3.3.1
3.3.2
+70 -12
View File
@@ -16,10 +16,10 @@ from __future__ import annotations
import json
import logging
from collections.abc import Mapping
from datetime import datetime
from typing import Any
from sqlalchemy import func, or_, select, text
from sqlalchemy.exc import IntegrityError, OperationalError
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import InventoryDevice, Node
@@ -548,6 +548,49 @@ def _decode_json(value: Any) -> Any:
return value
# Legacy `nodes` columns SQLite stores as DATETIME. Read through raw SQL they come
# back as text, and writing text into a DateTime column raises
# ``TypeError: SQLite DateTime type only accepts Python datetime and date objects``.
_LEGACY_DATETIME_COLUMNS = ("last_seen", "last_scan")
def _decode_dt(value: Any) -> datetime | None:
"""Raw SQL hands back DATETIME columns as text; the ORM would have parsed them.
An unparseable stamp becomes ``None`` rather than an error: `last_seen` and
`last_scan` are observations the status checker refreshes within a minute,
and losing one must not cost a node its inventory row.
"""
if value is None or isinstance(value, datetime):
return value
if not isinstance(value, str) or not value.strip():
return None
try:
return datetime.fromisoformat(value.strip())
except ValueError:
logger.debug("Ignoring an unparseable legacy timestamp: %r", value)
return None
async def _row_is_missing_facts(db: AsyncSession, device_id: str, facts: Mapping[str, Any]) -> bool:
"""Does the linked row lack a fact the node's legacy columns still hold?
True only where the node has something to give and the row has nothing in
that field, so a node whose facts already made it across is left alone and
the backfill stays a no-op on later boots.
"""
device = await db.get(InventoryDevice, device_id)
if device is None:
return True # The row is gone; the node needs a new one.
for field in (*DEVICE_SCALARS, "label", "type", "ieee_address"):
if not _blank(facts.get(field)) and _blank(getattr(device, field, None)):
return True
return any(
facts.get(field) and not getattr(device, field, None)
for field in ("services", "properties")
)
async def backfill_node_devices(db: AsyncSession) -> dict[str, int]:
"""Link every pre-3.3.0 canvas node to a Device Inventory row.
@@ -571,11 +614,16 @@ async def backfill_node_devices(db: AsyncSession) -> dict[str, int]:
placeholders = ", ".join(legacy)
furniture = ", ".join(f"'{t}'" for t in sorted(FURNITURE_TYPES))
# Linked nodes are read too, not just unlinked ones. A canvas saved while an
# earlier migration was stuck minted a *blank* inventory row from a UI that
# had no facts to show, and linked the node to it. Skipping those would count
# them as migrated and drop the columns that still hold their only copy of
# the ip, services and notes. A linked node is filled, never overwritten.
rows = (
await db.execute(
text(
f"SELECT id, label, type, design_id, {placeholders} FROM nodes "
f"WHERE device_id IS NULL AND type NOT IN ({furniture}) "
f"SELECT id, label, type, design_id, device_id, {placeholders} FROM nodes "
f"WHERE type NOT IN ({furniture}) "
"ORDER BY updated_at, created_at, id"
)
)
@@ -588,26 +636,36 @@ async def backfill_node_devices(db: AsyncSession) -> dict[str, int]:
facts: dict[str, Any] = {c: row[c] for c in legacy}
facts["services"] = _decode_json(facts.get("services")) or []
facts["properties"] = _decode_json(facts.get("properties")) or []
for field in _LEGACY_DATETIME_COLUMNS:
if field in facts:
facts[field] = _decode_dt(facts[field])
facts["label"] = row["label"]
facts["type"] = row["type"]
# One savepoint per node: a row the merge cannot write — a duplicate
# IEEE, a constraint no longer met — is dropped on its own rather than
# taking the whole backfill with it. Every node it does not link keeps
# its legacy columns, so nothing is lost and the next boot retries.
# One savepoint per node: a row the merge cannot write is dropped on its
# own rather than taking the whole backfill with it. Every node it does
# not link keeps its legacy columns, so nothing is lost and the next boot
# retries. The catch is deliberately broad — a node that cannot be read
# or written for *any* reason must not cost the others their link.
try:
async with db.begin_nested():
existing = await find_device_for(
db, ip=facts.get("ip"), mac=facts.get("mac"), ieee=facts.get("ieee_address")
)
node = await db.get(Node, row["id"])
if node is None: # pragma: no cover - the row was just read
continue
device = await link_facts(db, node, facts, overwrite_scalars=True)
if node.device_id and not await _row_is_missing_facts(db, node.device_id, facts):
continue # Already migrated, and its row holds the facts.
existing = await find_device_for(
db, ip=facts.get("ip"), mac=facts.get("mac"), ieee=facts.get("ieee_address")
)
# A node that is already linked only has its gaps filled: whatever
# is on the row was written after the migration and is newer.
device = await link_facts(
db, node, facts, overwrite_scalars=node.device_id is None
)
if device is None:
continue
await db.flush()
except (IntegrityError, OperationalError) as exc:
except Exception as exc:
skipped += 1
logger.warning(
"Inventory backfill: node %s (%s) skipped — %s", row["id"], row["label"], exc
+127
View File
@@ -385,6 +385,133 @@ class TestBackfill:
assert stats["linked"] == 0
assert (await db_session.execute(select(InventoryDevice))).scalars().all() == []
@pytest.mark.asyncio
async def test_carries_over_timestamps_stored_as_text(self, db_session):
"""The legacy columns are read with raw SQL, so SQLite hands the DATETIME
ones back as strings. Writing one straight into the row raises
``TypeError: SQLite DateTime type only accepts Python datetime and date
objects`` — which used to abort the whole backfill (issue #347)."""
await self._legacy_nodes_table(db_session)
design = await _design(db_session)
node_id = await self._legacy_node(
db_session,
design,
ip="10.0.0.5",
label="Dockerhost",
status="online",
last_seen="2026-08-15 04:10:22.123456",
last_scan="2026-08-15 04:00:00",
)
stats = await backfill_node_devices(db_session)
await db_session.commit()
assert stats == {"linked": 1, "created": 1, "merged": 0, "skipped": 0}
node = await db_session.get(Node, node_id)
assert node is not None
device = await db_session.get(InventoryDevice, node.device_id)
assert device is not None
assert device.last_seen == datetime(2026, 8, 15, 4, 10, 22, 123456)
assert device.last_scan == datetime(2026, 8, 15, 4, 0, 0)
assert device.status_live == "online"
@pytest.mark.asyncio
async def test_a_stamp_it_cannot_parse_costs_only_the_stamp(self, db_session):
await self._legacy_nodes_table(db_session)
design = await _design(db_session)
await self._legacy_node(db_session, design, ip="10.0.0.5", last_seen="not a date")
stats = await backfill_node_devices(db_session)
await db_session.commit()
assert stats["linked"] == 1
device = (await db_session.execute(select(InventoryDevice))).scalars().one()
assert device.last_seen is None
assert device.ip == "10.0.0.5"
@pytest.mark.asyncio
async def test_one_unwritable_node_does_not_cost_the_others_their_link(self, db_session, monkeypatch):
"""Whatever a single node raises — not only a constraint violation — the
rest of the canvas still gets linked."""
await self._legacy_nodes_table(db_session)
design = await _design(db_session)
await self._legacy_node(db_session, design, ip="10.0.0.5", label="good")
await self._legacy_node(db_session, design, ip="10.0.0.6", label="bad")
import app.services.inventory_sync as module
real = module.link_facts
async def link_facts(db, node, facts, **kwargs):
if facts.get("ip") == "10.0.0.6":
raise TypeError("SQLite DateTime type only accepts Python datetime")
return await real(db, node, facts, **kwargs)
monkeypatch.setattr(module, "link_facts", link_facts)
stats = await module.backfill_node_devices(db_session)
await db_session.commit()
assert stats["linked"] == 1
assert stats["skipped"] == 1
devices = (await db_session.execute(select(InventoryDevice))).scalars().all()
assert [d.ip for d in devices] == ["10.0.0.5"]
@pytest.mark.asyncio
async def test_fills_a_blank_row_a_stuck_canvas_save_created(self, db_session):
"""A save made while an earlier migration was stuck linked the node to a
row minted from a blank UI. Those facts only exist in the legacy columns,
and the drop is about to remove them — so fill the row before it does."""
await self._legacy_nodes_table(db_session)
design = await _design(db_session)
db_session.add(InventoryDevice(id="d-blank", ip="", label="Dockerhost", services=[]))
await db_session.commit()
node_id = await self._legacy_node(
db_session, design, label="Dockerhost", ip="192.168.0.42", hostname="clara.lan",
notes="in the cupboard", services=[{"port": 443, "protocol": "tcp", "service_name": "https"}],
)
node = await db_session.get(Node, node_id)
node.device_id = "d-blank"
await db_session.commit()
stats = await backfill_node_devices(db_session)
await db_session.commit()
assert stats["linked"] == 1
device = await db_session.get(InventoryDevice, "d-blank")
assert device is not None
assert device.ip == "192.168.0.42"
assert device.hostname == "clara.lan"
assert device.notes == "in the cupboard"
assert [s["service_name"] for s in device.services] == ["https"]
@pytest.mark.asyncio
async def test_leaves_a_linked_row_that_already_has_the_facts_alone(self, db_session):
"""The mirror of the above: a row a user has since edited is not reverted
to what the node's abandoned columns still say."""
await self._legacy_nodes_table(db_session)
design = await _design(db_session)
db_session.add(InventoryDevice(
id="d-1", ip="192.168.0.99", hostname="renamed.lan",
label="Dockerhost", type="docker_host",
))
await db_session.commit()
node_id = await self._legacy_node(
db_session, design, ip="192.168.0.42", hostname="clara.lan", label="Dockerhost"
)
node = await db_session.get(Node, node_id)
node.device_id = "d-1"
await db_session.commit()
stats = await backfill_node_devices(db_session)
await db_session.commit()
assert stats["linked"] == 0
device = await db_session.get(InventoryDevice, "d-1")
assert device is not None
assert device.ip == "192.168.0.99"
assert device.hostname == "renamed.lan"
@pytest.mark.asyncio
async def test_is_a_no_op_on_a_second_run(self, db_session):
await self._legacy_nodes_table(db_session)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frontend",
"version": "3.3.1",
"version": "3.3.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "3.3.1",
"version": "3.3.2",
"dependencies": {
"@base-ui/react": "^1.2.0",
"@dagrejs/dagre": "^2.0.4",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "3.3.1",
"version": "3.3.2",
"type": "module",
"scripts": {
"dev": "vite",