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/backend/app/notifications.py b/backend/app/notifications.py index 1e7b6cec..27abded7 100644 --- a/backend/app/notifications.py +++ b/backend/app/notifications.py @@ -14,6 +14,14 @@ 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, 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( db: Session, @@ -27,6 +35,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..25018d32 --- /dev/null +++ b/backend/tests/test_notifications.py @@ -0,0 +1,75 @@ +"""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, User +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 + + +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 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",