From 83326ecf53e437afab31239595ed1e470e6b92e8 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 16 Aug 2026 19:24:51 +0200 Subject: [PATCH 1/3] fix(downloads): clamp notification title to column limit so download_gc can't wedge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A downloaded FB/IG reel's "title" is the whole caption (up to ~1900 chars). It flowed untrimmed into Notification.title (String(255)); at the batched download_gc commit that raised psycopg StringDataRightTruncation in pass 1, aborting the whole run so expiry deletion, LRU eviction and orphan reap never executed (13/110 prod assets already exceed the limit). Clamp the title to 255 (with an ellipsis) centrally in create_notification — defending all six callers — since the full title already reaches the client via `data` and the column is only an English fallback. Add a regression test. --- backend/app/notifications.py | 9 +++++ backend/tests/test_notifications.py | 55 +++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 backend/tests/test_notifications.py diff --git a/backend/app/notifications.py b/backend/app/notifications.py index 1e7b6cec..10f0ad6b 100644 --- a/backend/app/notifications.py +++ b/backend/app/notifications.py @@ -14,6 +14,13 @@ from app.models import Notification # rows are never pruned (the user hasn't seen them yet). READ_SOFT_CAP = 200 +# Hard limit of the Notification.title column (String(255)). Titles are only an English +# fallback — the client renders the translated message from `data` — so clamping an +# over-long one (e.g. a FB/IG reel whose "title" is the whole caption, hundreds of chars) +# is harmless to the UI and prevents a StringDataRightTruncation that would abort the +# caller's whole transaction (the batched download_gc run in particular). +TITLE_MAX_LEN = 255 + def create_notification( db: Session, @@ -27,6 +34,8 @@ def create_notification( ) -> Notification: """Insert one notification for a user. Callers that batch many inserts in a single transaction can pass commit=False and commit once themselves.""" + if title is not None and len(title) > TITLE_MAX_LEN: + title = title[: TITLE_MAX_LEN - 1] + "…" # clamp to the column limit, add an ellipsis notif = Notification( user_id=user_id, type=type, title=title, body=body, data=data ) diff --git a/backend/tests/test_notifications.py b/backend/tests/test_notifications.py new file mode 100644 index 00000000..6780eb2d --- /dev/null +++ b/backend/tests/test_notifications.py @@ -0,0 +1,55 @@ +"""create_notification title-clamp regression. + +A downloaded FB/IG reel's "title" is the whole caption (hundreds to ~1900 chars). Before the +clamp that string went straight into Notification.title (String(255)) and, at the batched +download_gc commit, raised psycopg StringDataRightTruncation — aborting the entire GC run so +no expiry deletion / LRU eviction / orphan reap ever ran. The clamp trims the title to the +column limit centrally, defending all callers regardless of DB backend.""" +from app.models import Notification +from app.notifications import TITLE_MAX_LEN, create_notification + + +class _CaptureDB: + """Captures the added row without a real DB, so the clamp is verified on every lane + (the truncation only reproduces on Postgres; the clamp logic is backend-agnostic).""" + + def __init__(self): + self.added: list[Notification] = [] + + def add(self, obj): + self.added.append(obj) + + def commit(self): + pass + + def refresh(self, _obj): + pass + + +def test_title_clamped_to_column_limit(): + long_title = "x" * 2000 # e.g. a reel caption + notif = create_notification( + _CaptureDB(), user_id=1, type="download_expiring", title=long_title + ) + assert len(notif.title) == TITLE_MAX_LEN + assert notif.title.endswith("…") + # The untruncated title still reaches the client via `data`, which is never clamped. + notif2 = create_notification( + _CaptureDB(), + user_id=1, + type="download_expiring", + title=long_title, + data={"kind": "download_expiring", "title": long_title}, + ) + assert notif2.data["title"] == long_title + + +def test_short_title_is_untouched(): + notif = create_notification(_CaptureDB(), user_id=1, type="t", title="short") + assert notif.title == "short" + + +def test_title_at_exactly_the_limit_is_untouched(): + exact = "y" * TITLE_MAX_LEN + notif = create_notification(_CaptureDB(), user_id=1, type="t", title=exact) + assert notif.title == exact From 80ea1918e367591133e7022a389c45a4aff784ea Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 16 Aug 2026 19:40:17 +0200 Subject: [PATCH 2/3] fix(downloads): derive title clamp from the column + real-Postgres regression Address review findings on the title-clamp: TITLE_MAX_LEN now reads the length straight from Notification.title so the clamp can't drift from the column a migration would change (single source of truth). Add a db-lane test that actually commits a >255-char title into Postgres (end-to-end proof the truncation is gone, not just the clamp logic) and one that asserts the constant tracks the column width. --- backend/app/notifications.py | 13 +++++++------ backend/tests/test_notifications.py | 22 +++++++++++++++++++++- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/backend/app/notifications.py b/backend/app/notifications.py index 10f0ad6b..27abded7 100644 --- a/backend/app/notifications.py +++ b/backend/app/notifications.py @@ -14,12 +14,13 @@ from app.models import Notification # rows are never pruned (the user hasn't seen them yet). READ_SOFT_CAP = 200 -# Hard limit of the Notification.title column (String(255)). Titles are only an English -# fallback — the client renders the translated message from `data` — so clamping an -# over-long one (e.g. a FB/IG reel whose "title" is the whole caption, hundreds of chars) -# is harmless to the UI and prevents a StringDataRightTruncation that would abort the -# caller's whole transaction (the batched download_gc run in particular). -TITLE_MAX_LEN = 255 +# Hard limit of the Notification.title column, read straight from the model so the clamp +# can never drift from the actual column width (a later migration is the single source of +# truth). Titles are only an English fallback — the client renders the translated message +# from `data` — so clamping an over-long one (e.g. a FB/IG reel whose "title" is the whole +# caption, hundreds of chars) is harmless to the UI and prevents a StringDataRightTruncation +# that would abort the caller's whole transaction (the batched download_gc run in particular). +TITLE_MAX_LEN = Notification.__table__.c.title.type.length def create_notification( diff --git a/backend/tests/test_notifications.py b/backend/tests/test_notifications.py index 6780eb2d..25018d32 100644 --- a/backend/tests/test_notifications.py +++ b/backend/tests/test_notifications.py @@ -5,7 +5,7 @@ clamp that string went straight into Notification.title (String(255)) and, at th download_gc commit, raised psycopg StringDataRightTruncation — aborting the entire GC run so no expiry deletion / LRU eviction / orphan reap ever ran. The clamp trims the title to the column limit centrally, defending all callers regardless of DB backend.""" -from app.models import Notification +from app.models import Notification, User from app.notifications import TITLE_MAX_LEN, create_notification @@ -53,3 +53,23 @@ def test_title_at_exactly_the_limit_is_untouched(): exact = "y" * TITLE_MAX_LEN notif = create_notification(_CaptureDB(), user_id=1, type="t", title=exact) assert notif.title == exact + + +def test_limit_tracks_the_actual_column_width(): + # The clamp is derived from the model, so it can't silently drift from the column a + # migration would change (which is exactly what re-introduces the truncation bug). + assert TITLE_MAX_LEN == Notification.__table__.c.title.type.length + + +def test_over_long_title_actually_commits_on_postgres(db): + # The stub tests above prove the clamp *logic*; this proves the real fix end-to-end — + # a caption-length title now COMMITS into varchar(TITLE_MAX_LEN) instead of raising + # StringDataRightTruncation. Uses the opt-in Postgres lane (skipped with no DB). + user = User(email="reel@example.com") + db.add(user) + db.flush() + notif = create_notification( + db, user_id=user.id, type="download_expiring", title="z" * 2000 + ) + db.refresh(notif) + assert len(notif.title) == TITLE_MAX_LEN # persisted, not truncated by the DB From 588a1a82a11e41bddf3313550562da8a2f323931 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 16 Aug 2026 20:08:23 +0200 Subject: [PATCH 3/3] release: bump VERSION to 0.65.2 + release notes (download-cleanup title-truncation fix) --- VERSION | 2 +- frontend/src/lib/releaseNotes.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index ad4ae449..a9ca1dcc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.65.1 \ No newline at end of file +0.65.2 \ No newline at end of file diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index f7428d84..7ab5056d 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,18 @@ export interface ReleaseEntry { } export const RELEASE_NOTES: ReleaseEntry[] = [ + { + version: "0.65.2", + date: "2026-08-16", + summary: + "Fixes the automatic cleanup of expired downloads, which had quietly stopped running.", + fixes: [ + "The scheduled cleanup of downloaded videos works again. A download with a very long title — for example a Facebook or Instagram reel, whose entire caption is used as the title — could jam the cleanup job, so expired downloads stopped being removed and their disk space wasn't reclaimed. Over-long titles are now trimmed for the notification only (the full title is kept everywhere else), so the cleanup runs to completion on schedule.", + ], + chores: [ + "Added a regression test that a very long download title can no longer break the cleanup job, and enabled a Postgres query-statistics extension on the server so a transient slow-loading report can be diagnosed if it recurs.", + ], + }, { version: "0.65.1", date: "2026-08-16",