Skip to content

Commit 0a55d6d

Browse files
teh-hippoCopilot
andcommitted
fix: add resilience to manifest DB writes
Add retry with backoff for transient 'database is locked' errors in upsert(), preventing a single lock failure from cascading into total manifest write failure for the rest of the run. Also add error handling to flush(), close(), and update_path() which were previously unprotected against SQLite errors. Fix missing _pending_count increment in update_path(). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a1c5dc2 commit 0a55d6d

File tree

1 file changed

+98
-61
lines changed

1 file changed

+98
-61
lines changed

src/icloudpd/manifest.py

Lines changed: 98 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import logging
1414
import os
1515
import sqlite3
16+
import time
1617
from dataclasses import dataclass
1718
from datetime import datetime, timezone
1819

@@ -133,6 +134,9 @@ class ManifestRow:
133134
class ManifestDB:
134135
"""SQLite-backed asset manifest for tracking downloaded files."""
135136

137+
_MAX_WRITE_RETRIES = 3
138+
_RETRY_BASE_DELAY = 0.1
139+
136140
def __init__(self, download_dir: str) -> None:
137141
self._db_path = os.path.join(download_dir, ".icloudpd.db")
138142
self._conn: sqlite3.Connection | None = None
@@ -229,7 +233,13 @@ def close(self) -> None:
229233
"""Close the manifest DB, committing any pending writes."""
230234
if self._conn:
231235
if self._dirty:
232-
self._conn.commit()
236+
try:
237+
self._conn.commit()
238+
except sqlite3.OperationalError as e:
239+
if "locked" in str(e):
240+
logger.warning("Manifest commit on close failed: %s", e)
241+
else:
242+
raise
233243
self._dirty = False
234244
self._pending_count = 0
235245
self._conn.close()
@@ -238,9 +248,15 @@ def close(self) -> None:
238248
def flush(self) -> None:
239249
"""Commit pending writes without closing."""
240250
if self._conn and self._dirty:
241-
self._conn.commit()
242-
self._dirty = False
243-
self._pending_count = 0
251+
try:
252+
self._conn.commit()
253+
self._dirty = False
254+
self._pending_count = 0
255+
except sqlite3.OperationalError as e:
256+
if "locked" in str(e):
257+
logger.warning("Manifest flush failed: %s", e)
258+
else:
259+
raise
244260

245261
def __enter__(self) -> "ManifestDB":
246262
self.open()
@@ -308,69 +324,90 @@ def upsert(
308324
raw_fields: str | None = None,
309325
) -> None:
310326
"""Insert or update a manifest entry. Auto-flushes every 500 writes."""
327+
now = datetime.now(tz=timezone.utc).isoformat()
328+
params = (
329+
asset_id, zone_id, local_path, version_size, version_checksum,
330+
change_tag, now, now, item_type, filename,
331+
asset_date, added_date, is_favorite, is_hidden, is_deleted,
332+
original_width, original_height, duration, orientation,
333+
title, description, keywords, gps_latitude, gps_longitude, gps_altitude,
334+
gps_speed, gps_timestamp, timezone_offset, asset_subtype, hdr_type,
335+
burst_flags, burst_flags_ext, burst_id, original_orientation, raw_fields,
336+
)
337+
sql = (
338+
f"INSERT INTO manifest ({_ALL_COLUMNS}) "
339+
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "
340+
"ON CONFLICT(asset_id, zone_id, local_path) DO UPDATE SET "
341+
"version_size=excluded.version_size, "
342+
"version_checksum=excluded.version_checksum, "
343+
"change_tag=excluded.change_tag, "
344+
"last_updated_at=excluded.last_updated_at, "
345+
"item_type=excluded.item_type, "
346+
"filename=excluded.filename, "
347+
"asset_date=excluded.asset_date, "
348+
"added_date=excluded.added_date, "
349+
"is_favorite=excluded.is_favorite, "
350+
"is_hidden=excluded.is_hidden, "
351+
"is_deleted=excluded.is_deleted, "
352+
"original_width=excluded.original_width, "
353+
"original_height=excluded.original_height, "
354+
"duration=excluded.duration, "
355+
"orientation=excluded.orientation, "
356+
"title=excluded.title, "
357+
"description=excluded.description, "
358+
"keywords=excluded.keywords, "
359+
"gps_latitude=excluded.gps_latitude, "
360+
"gps_longitude=excluded.gps_longitude, "
361+
"gps_altitude=excluded.gps_altitude, "
362+
"gps_speed=excluded.gps_speed, "
363+
"gps_timestamp=excluded.gps_timestamp, "
364+
"timezone_offset=excluded.timezone_offset, "
365+
"asset_subtype=excluded.asset_subtype, "
366+
"hdr_type=excluded.hdr_type, "
367+
"burst_flags=excluded.burst_flags, "
368+
"burst_flags_ext=excluded.burst_flags_ext, "
369+
"burst_id=excluded.burst_id, "
370+
"original_orientation=excluded.original_orientation, "
371+
"raw_fields=excluded.raw_fields"
372+
)
373+
for attempt in range(self._MAX_WRITE_RETRIES):
374+
try:
375+
self._db.execute(sql, params)
376+
self._dirty = True
377+
self._pending_count += 1
378+
if self._pending_count >= self._flush_interval:
379+
self.flush()
380+
return
381+
except sqlite3.OperationalError as e:
382+
if "locked" in str(e) and attempt < self._MAX_WRITE_RETRIES - 1:
383+
logger.debug(
384+
"Manifest write retry %d for %s: %s",
385+
attempt + 1, local_path, e,
386+
)
387+
time.sleep(self._RETRY_BASE_DELAY * (attempt + 1))
388+
else:
389+
logger.warning("Manifest write failed for %s: %s", local_path, e)
390+
return
391+
except sqlite3.Error as e:
392+
logger.warning("Manifest write failed for %s: %s", local_path, e)
393+
return
394+
395+
def update_path(self, asset_id: str, zone_id: str, old_path: str, new_path: str) -> None:
396+
"""Update local_path for an existing manifest entry."""
311397
try:
312-
now = datetime.now(tz=timezone.utc).isoformat()
313398
self._db.execute(
314-
f"INSERT INTO manifest ({_ALL_COLUMNS}) "
315-
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "
316-
"ON CONFLICT(asset_id, zone_id, local_path) DO UPDATE SET "
317-
"version_size=excluded.version_size, "
318-
"version_checksum=excluded.version_checksum, "
319-
"change_tag=excluded.change_tag, "
320-
"last_updated_at=excluded.last_updated_at, "
321-
"item_type=excluded.item_type, "
322-
"filename=excluded.filename, "
323-
"asset_date=excluded.asset_date, "
324-
"added_date=excluded.added_date, "
325-
"is_favorite=excluded.is_favorite, "
326-
"is_hidden=excluded.is_hidden, "
327-
"is_deleted=excluded.is_deleted, "
328-
"original_width=excluded.original_width, "
329-
"original_height=excluded.original_height, "
330-
"duration=excluded.duration, "
331-
"orientation=excluded.orientation, "
332-
"title=excluded.title, "
333-
"description=excluded.description, "
334-
"keywords=excluded.keywords, "
335-
"gps_latitude=excluded.gps_latitude, "
336-
"gps_longitude=excluded.gps_longitude, "
337-
"gps_altitude=excluded.gps_altitude, "
338-
"gps_speed=excluded.gps_speed, "
339-
"gps_timestamp=excluded.gps_timestamp, "
340-
"timezone_offset=excluded.timezone_offset, "
341-
"asset_subtype=excluded.asset_subtype, "
342-
"hdr_type=excluded.hdr_type, "
343-
"burst_flags=excluded.burst_flags, "
344-
"burst_flags_ext=excluded.burst_flags_ext, "
345-
"burst_id=excluded.burst_id, "
346-
"original_orientation=excluded.original_orientation, "
347-
"raw_fields=excluded.raw_fields",
348-
(
349-
asset_id, zone_id, local_path, version_size, version_checksum,
350-
change_tag, now, now, item_type, filename,
351-
asset_date, added_date, is_favorite, is_hidden, is_deleted,
352-
original_width, original_height, duration, orientation,
353-
title, description, keywords, gps_latitude, gps_longitude, gps_altitude,
354-
gps_speed, gps_timestamp, timezone_offset, asset_subtype, hdr_type,
355-
burst_flags, burst_flags_ext, burst_id, original_orientation, raw_fields,
356-
),
399+
"UPDATE manifest SET local_path = ?, last_updated_at = ? "
400+
"WHERE asset_id = ? AND zone_id = ? AND local_path = ?",
401+
(new_path, datetime.now(tz=timezone.utc).isoformat(),
402+
asset_id, zone_id, old_path),
357403
)
358404
self._dirty = True
359405
self._pending_count += 1
360-
if self._pending_count >= self._flush_interval:
361-
self.flush()
362406
except sqlite3.Error as e:
363-
logger.warning("Manifest write failed for %s: %s", local_path, e)
364-
365-
def update_path(self, asset_id: str, zone_id: str, old_path: str, new_path: str) -> None:
366-
"""Update local_path for an existing manifest entry."""
367-
self._db.execute(
368-
"UPDATE manifest SET local_path = ?, last_updated_at = ? "
369-
"WHERE asset_id = ? AND zone_id = ? AND local_path = ?",
370-
(new_path, datetime.now(tz=timezone.utc).isoformat(),
371-
asset_id, zone_id, old_path),
372-
)
373-
self._dirty = True
407+
logger.warning(
408+
"Manifest path update failed for %s -> %s: %s",
409+
old_path, new_path, e,
410+
)
374411

375412
def count_by_path(self, local_path: str) -> int:
376413
"""Count how many manifest entries reference a given local path."""

0 commit comments

Comments
 (0)