feat: a tick keeps looking back 30 days, so an EDITED post is reached (4386)
CI and images / extension-version (push) Successful in 3s
CI and images / lint (push) Successful in 3s
CI and images / frontend-build (push) Successful in 19s
CI and images / backend-lint-and-test (push) Successful in 40s
CI and images / integration (push) Failing after 2m17s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / build-agent (push) Skipped

Operator, 2026-09-23, on a Floppystack post: "this post has been updated as
he implements hot fixes — any chance we have a way to scan for or see updated
posts so we can update ours to match and pull the new attachments and
pictures etc."

The download half already worked: extract_media reads the media list off the
LIVE feed response every walk, so a newly attached hotfix build is a ledger
key we have never seen. Only REACHING the post was missing — a tick stopped
after 20 contiguous already-have-it items, and a post edited three days after
publication sits well below twenty. Not a bug in the early-out; a count
cannot express "recent".

The early-out now needs BOTH conditions: the run of seen items AND a post
published before the horizon. Strictly a widening — window 0 is exactly the
old behaviour, and no window can make a tick stop EARLIER than it used to, so
a source paused for months still walks its whole unseen backlog. The horizon
is a floor on how far to look, never a ceiling.

Inside the window the post-record gate is bypassed too (write_post_record
revisit=True): the body is re-read from the feed response already in hand, so
a revisit costs zero requests, and a body that comes back empty writes
NOTHING rather than blanking one a detail-fetch had filled. Revisits are kept
out of the #862 body-drift canary's sample for the same reason — an empty
revisit is healthy, and counting it would walk the alarm toward firing on
good ticks.

The run summary names what changed ("3 post(s) updated (5 new file(s))") with
a line per post; the ask was to SEE updated posts, not only to end up with
their bytes.

download_revisit_days is a settings row, not a constant (rule 25) — how long
a creator keeps editing is a property of the creator. Default 30, 0 turns it
off. Migration 0108.

Also corrects two stale docstrings: both clients described post_meta as
feeding an Ingester.preview that no longer calls it. It had no consumer at
all until this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
2026-09-23 19:21:06 -04:00
co-authored by Claude Opus 5
parent ffbe21098c
commit 2b093958a4
16 changed files with 746 additions and 21 deletions
+38
View File
@@ -65,3 +65,41 @@ async def test_extdl_toggle_rejects_non_bool(client):
"/api/settings/import", json={"extdl_gdrive_enabled": "nope"}
)
assert resp.status_code == 400
# -- download_revisit_days: how far back a tick looks for EDITED posts --------
@pytest.mark.asyncio
async def test_revisit_window_defaults_to_thirty_days(client):
body = await (await client.get("/api/settings/import")).get_json()
assert body["download_revisit_days"] == 30
@pytest.mark.asyncio
async def test_revisit_window_is_settable(client):
resp = await client.patch(
"/api/settings/import", json={"download_revisit_days": 7}
)
assert resp.status_code == 200
assert (await resp.get_json())["download_revisit_days"] == 7
@pytest.mark.asyncio
async def test_zero_is_accepted_because_it_is_the_off_switch(client):
"""0 turns the revisit off and restores the pure count early-out. Pinned
because the obvious bounds check for a "days" field is `>= 1`, and that
would take the off switch away without anything failing."""
resp = await client.patch(
"/api/settings/import", json={"download_revisit_days": 0}
)
assert resp.status_code == 200
assert (await resp.get_json())["download_revisit_days"] == 0
@pytest.mark.asyncio
async def test_a_negative_window_is_refused(client):
resp = await client.patch(
"/api/settings/import", json={"download_revisit_days": -1}
)
assert resp.status_code == 400
+47
View File
@@ -658,6 +658,53 @@ def test_write_post_record_writes_enriched_post_only_sidecar(tmp_path):
assert rec.body_chars == len("<p>text post body</p>")
# The revisit contract (2026-09-23): a tick re-reading a post it already
# captured, so an edit made after first capture reaches us. Both halves exist to
# stop an update costing more than it is worth.
def test_a_revisit_re_reads_the_body_without_paying_for_a_detail_fetch(tmp_path):
"""A 30-day window would otherwise buy one detail GET per body-less post per
tick, forever — a per-creator cost that grows with how prolific they are, to
re-fetch a body we already stored."""
calls: list[str] = []
def _fetcher(post_id: str) -> str:
calls.append(post_id)
return "<p>detail body</p>"
dl = PatreonDownloader(
images_root=tmp_path, cookies_path=None, validate=False,
session=_FakeSession(), content_fetcher=_fetcher,
)
post = _post()
post["attributes"]["content"] = "<p>feed body</p>"
rec = dl.write_post_record(post, "artist-x", revisit=True)
assert calls == []
assert json.loads(rec.path.read_text())["content"] == "<p>feed body</p>"
def test_a_revisit_with_an_empty_body_writes_nothing_at_all(tmp_path):
"""The data-loss guard. On a FIRST capture an empty body is the truth about
the post; on a revisit it usually means the body only ever came from the
detail endpoint we just declined to call. Writing it would blank a stored
body to say something we never learned."""
dl = PatreonDownloader(
images_root=tmp_path, cookies_path=None, validate=False,
session=_FakeSession(), content_fetcher=lambda _pid: "<p>detail</p>",
)
post = _post()
post["attributes"]["content"] = ""
rec = dl.write_post_record(post, "artist-x", revisit=True)
assert rec.path is None
assert rec.body_chars == 0
# Not "wrote an empty file" — nothing was written, so a record captured on
# an earlier walk is still exactly what it was.
assert not list(tmp_path.rglob("_post.json"))
def test_write_post_record_none_without_post_id(tmp_path):
dl = PatreonDownloader(
images_root=tmp_path, cookies_path=None, validate=False,
+312 -3
View File
@@ -7,6 +7,8 @@ real CDN. The ledger is real (a sync sessionmaker bound to the test engine), so
the tier-1 skip and the idempotent mark-seen run against actual rows.
"""
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy import func, select
from sqlalchemy.orm import sessionmaker
@@ -50,9 +52,15 @@ class _FakeClient:
"""Stub PatreonClient. `pages` is a list of (page_cursor, [posts]); each post
is (post_id, [MediaItem]). `raise_on_first` lets a test trip drift."""
def __init__(self, pages, raise_exc=None, empty_body=False, gated=None):
def __init__(self, pages, raise_exc=None, empty_body=False, gated=None,
published=None):
self._pages = pages
self._raise_exc = raise_exc
# {post_id: ISO-8601 published_at} for the revisit window. Absent → the
# date reads as None, which is how EVERY test written before the window
# existed keeps its old behaviour: an unreadable date is never inside
# the horizon, so the count early-out stands alone.
self._published = dict(published or {})
# empty_body simulates a body-field schema break: every post comes back
# with no content (the #862 canary's trip condition).
self._empty_body = empty_body
@@ -89,7 +97,10 @@ class _FakeClient:
return post["_media"]
def post_meta(self, post):
return {"title": post.get("id"), "date": None}
return {
"title": post.get("id"),
"date": self._published.get(str(post.get("id") or "")),
}
@staticmethod
def post_is_gated(post):
@@ -113,6 +124,7 @@ class _FakeDownloader:
self.error = set(error or ())
self.download_calls = 0
self.post_records = 0
self.post_revisits = 0
def download_post(self, post, media_items, artist_slug, *, is_seen,
should_stop=lambda: False, recapture=False):
@@ -146,8 +158,19 @@ class _FakeDownloader:
outcomes.append(MediaOutcome(media=m, status="downloaded", path=p, error=None))
return outcomes
def write_post_record(self, post, artist_slug):
def write_post_record(self, post, artist_slug, *, revisit=False):
self.post_records += 1
if revisit:
self.post_revisits += 1
attrs_ = post.get("attributes") or {}
body_ = attrs_.get("content")
# Mirrors the real downloaders' revisit contract: a re-read whose body
# came back empty writes NOTHING rather than blanking a stored one.
if revisit and not (isinstance(body_, str) and body_.strip()):
return PostRecordOutcome(
path=None, post_type=attrs_.get("post_type"),
title=attrs_.get("title"), body_chars=0,
)
p = self.tmp_path / f"{post.get('id')}__post.json"
p.write_text("{}")
attrs = post.get("attributes") or {}
@@ -1114,3 +1137,289 @@ async def test_body_canary_silent_below_min_sample(source_id, sync_engine, tmp_p
)
assert result.success is True
assert result.error_type is None
# --- the revisit window: reaching posts that were EDITED after capture -------
#
# Operator, 2026-09-23, on a Floppystack post: *"this post has been updated as
# he implements hot fixes — any chance we have a way to scan for or see updated
# posts so we can update ours to match and pull the new attachments and
# pictures etc."*
#
# A tick stopped after N contiguous already-have-it items, so an edit to a
# three-day-old post was structurally unreachable: it sits well below twenty
# seen items. The early-out now needs BOTH that run AND a post published before
# the horizon.
def _iso(days_ago):
return (datetime.now(UTC) - timedelta(days=days_ago)).isoformat()
def _seed_seen(sync_engine, source_id, media):
factory = sessionmaker(sync_engine, expire_on_commit=False)
with factory() as s:
for m in media:
s.add(PatreonSeenMedia(
source_id=source_id, filehash=_ledger_key(m), post_id=m.post_id,
))
s.commit()
@pytest.mark.asyncio
async def test_a_run_of_seen_items_does_not_stop_a_tick_inside_the_window(
source_id, sync_engine, tmp_path,
):
"""The bug itself. Three all-seen posts, threshold 2 — the old walk turned
around at the second and never looked at the third, which is exactly where
an edited post lives."""
seen = [_media(f"p{i}", 1) for i in range(1, 4)]
_seed_seen(sync_engine, source_id, seen)
client = _FakeClient(
[(None, [(m.post_id, [m]) for m in seen])],
published={m.post_id: _iso(3) for m in seen},
)
ing = _ingester(sync_engine, tmp_path, client, _FakeDownloader(tmp_path))
result = ing.run(
source_id=source_id, campaign_id="c1", artist_slug="ingest",
url="https://patreon.com/ingest", mode="tick", seen_threshold=2,
revisit_days=30,
)
assert result.success is True
assert client.consumed_posts == 3
@pytest.mark.asyncio
async def test_the_early_out_still_fires_once_the_walk_is_below_the_horizon(
source_id, sync_engine, tmp_path,
):
"""The other half, and the one that keeps a tick cheap. Same three posts,
published outside the window → the count early-out stands exactly as it
did. Asserted beside the test above because the window is only correct if
BOTH conditions are required; either one alone is a different feature."""
seen = [_media(f"p{i}", 1) for i in range(1, 4)]
_seed_seen(sync_engine, source_id, seen)
client = _FakeClient(
[(None, [(m.post_id, [m]) for m in seen])],
published={m.post_id: _iso(90) for m in seen},
)
ing = _ingester(sync_engine, tmp_path, client, _FakeDownloader(tmp_path))
result = ing.run(
source_id=source_id, campaign_id="c1", artist_slug="ingest",
url="https://patreon.com/ingest", mode="tick", seen_threshold=2,
revisit_days=30,
)
assert result.success is True
assert client.consumed_posts == 2
@pytest.mark.asyncio
async def test_a_window_of_zero_is_the_behaviour_that_shipped_before_it(
source_id, sync_engine, tmp_path,
):
"""The off switch. Recent posts, a window of 0 → the walk stops on the
count alone, so an operator who wants the old cheap tick has one."""
seen = [_media(f"p{i}", 1) for i in range(1, 4)]
_seed_seen(sync_engine, source_id, seen)
client = _FakeClient(
[(None, [(m.post_id, [m]) for m in seen])],
published={m.post_id: _iso(1) for m in seen},
)
ing = _ingester(sync_engine, tmp_path, client, _FakeDownloader(tmp_path))
ing.run(
source_id=source_id, campaign_id="c1", artist_slug="ingest",
url="https://patreon.com/ingest", mode="tick", seen_threshold=2,
revisit_days=0,
)
assert client.consumed_posts == 2
@pytest.mark.asyncio
async def test_a_backfill_ignores_the_window_because_it_never_early_outs(
source_id, sync_engine, tmp_path,
):
"""Stated so the window cannot quietly acquire a second job. A backfill
walks to the bottom regardless, and `recapture` is the mode that re-reads
every body — a horizon there would be a third answer to a two-answer
question."""
seen = [_media(f"p{i}", 1) for i in range(1, 4)]
_seed_seen(sync_engine, source_id, seen)
client = _FakeClient(
[(None, [(m.post_id, [m]) for m in seen])],
published={m.post_id: _iso(90) for m in seen},
)
downloader = _FakeDownloader(tmp_path)
ing = _ingester(sync_engine, tmp_path, client, downloader)
ing.run(
source_id=source_id, campaign_id="c1", artist_slug="ingest",
url="https://patreon.com/ingest", mode="backfill", seen_threshold=2,
revisit_days=30,
)
assert client.consumed_posts == 3
assert downloader.post_revisits == 0
@pytest.mark.asyncio
async def test_an_edited_post_inside_the_window_downloads_its_new_attachment(
source_id, sync_engine, tmp_path,
):
"""The operator's case, end to end.
Walk one captures a post with one file. The creator then edits it to attach
a hotfix build. Walk two must download that file AND name the post as
updated — the ask was to SEE which posts changed, not only to end up with
their bytes.
The detection half needed nothing new: `extract_media` reads the media list
off the LIVE feed response every walk, so a newly attached file is simply a
ledger key we have never seen. Only reaching the post was missing.
"""
original = _media("p1", 1)
client1 = _FakeClient(
[(None, [("p1", [original])])], published={"p1": _iso(3)},
)
ing1 = _ingester(sync_engine, tmp_path, client1, _FakeDownloader(tmp_path))
ing1.run(
source_id=source_id, campaign_id="c1", artist_slug="ingest",
url="https://patreon.com/ingest", mode="tick", revisit_days=30,
)
hotfix = _media("p1", 2)
client2 = _FakeClient(
[(None, [("p1", [original, hotfix])])], published={"p1": _iso(3)},
)
downloader2 = _FakeDownloader(tmp_path)
ing2 = _ingester(sync_engine, tmp_path, client2, downloader2)
result = ing2.run(
source_id=source_id, campaign_id="c1", artist_slug="ingest",
url="https://patreon.com/ingest", mode="tick", revisit_days=30,
)
assert result.files_downloaded == 1
assert "1 post(s) updated (1 new file(s))" in result.stdout
assert "post p1 — updated: 1 new file(s)" in result.stdout
# The post record was re-read, not skipped by the capture gate.
assert downloader2.post_revisits == 1
@pytest.mark.asyncio
async def test_a_post_below_the_horizon_keeps_its_capture_gate(
source_id, sync_engine, tmp_path,
):
"""The gate still does its job everywhere the window does not reach — else
the window would have quietly become "re-read every post forever"."""
old = _media("pold", 1)
client1 = _FakeClient([(None, [("pold", [old])])], published={"pold": _iso(90)})
ing1 = _ingester(sync_engine, tmp_path, client1, _FakeDownloader(tmp_path))
ing1.run(
source_id=source_id, campaign_id="c1", artist_slug="ingest",
url="https://patreon.com/ingest", mode="tick", revisit_days=30,
)
client2 = _FakeClient([(None, [("pold", [old])])], published={"pold": _iso(90)})
downloader2 = _FakeDownloader(tmp_path)
ing2 = _ingester(sync_engine, tmp_path, client2, downloader2)
ing2.run(
source_id=source_id, campaign_id="c1", artist_slug="ingest",
url="https://patreon.com/ingest", mode="tick", revisit_days=30,
)
assert downloader2.post_records == 0
@pytest.mark.asyncio
async def test_a_revisit_that_finds_nothing_new_is_not_reported_as_an_update(
source_id, sync_engine, tmp_path,
):
"""Most revisits find nothing — that is the normal case, and a run summary
claiming "1 post(s) updated" on every tick would train the operator to stop
reading it (lesson: a signal that is always on is not a signal)."""
m = _media("p1", 1)
client1 = _FakeClient([(None, [("p1", [m])])], published={"p1": _iso(3)})
ing1 = _ingester(sync_engine, tmp_path, client1, _FakeDownloader(tmp_path))
ing1.run(
source_id=source_id, campaign_id="c1", artist_slug="ingest",
url="https://patreon.com/ingest", mode="tick", revisit_days=30,
)
client2 = _FakeClient([(None, [("p1", [m])])], published={"p1": _iso(3)})
downloader2 = _FakeDownloader(tmp_path)
ing2 = _ingester(sync_engine, tmp_path, client2, downloader2)
result = ing2.run(
source_id=source_id, campaign_id="c1", artist_slug="ingest",
url="https://patreon.com/ingest", mode="tick", revisit_days=30,
)
assert result.files_downloaded == 0
assert "updated" not in result.stdout
assert downloader2.post_revisits == 1
@pytest.mark.asyncio
async def test_revisits_do_not_feed_the_body_drift_canary(
source_id, sync_engine, tmp_path,
):
"""#862's canary fails a run that recorded a meaningful sample of posts and
got a body from NONE of them. A revisit legitimately comes back empty — the
post's body only ever arrived from the detail endpoint, which a revisit
declines to call — so counting revisits into that sample would walk the
alarm toward firing on healthy ticks. First captures only."""
posts = [(f"c{i}", []) for i in range(_CANARY_MIN_SAMPLE)]
published = {pid: _iso(3) for pid, _ in posts}
client1 = _FakeClient([(None, posts)], published=published)
ing1 = _ingester(sync_engine, tmp_path, client1, _FakeDownloader(tmp_path))
first = ing1.run(
source_id=source_id, campaign_id="c1", artist_slug="ingest",
url="https://patreon.com/ingest", mode="tick", revisit_days=30,
)
assert first.success is True
# Second walk: every post is a revisit, and every body comes back empty.
client2 = _FakeClient([(None, posts)], published=published, empty_body=True)
ing2 = _ingester(sync_engine, tmp_path, client2, _FakeDownloader(tmp_path))
second = ing2.run(
source_id=source_id, campaign_id="c1", artist_slug="ingest",
url="https://patreon.com/ingest", mode="tick", revisit_days=30,
)
assert second.success is True
assert second.error_type is not ErrorType.API_DRIFT
@pytest.mark.asyncio
async def test_a_post_whose_date_will_not_parse_falls_back_to_the_count(
source_id, sync_engine, tmp_path,
):
"""A date we cannot read must not fail the walk, and must not be guessed
into the window. It reads as "not provably recent", which leaves that post
on the behaviour it had before the window existed."""
seen = [_media(f"p{i}", 1) for i in range(1, 4)]
_seed_seen(sync_engine, source_id, seen)
client = _FakeClient(
[(None, [(m.post_id, [m]) for m in seen])],
published={m.post_id: "last Tuesday" for m in seen},
)
ing = _ingester(sync_engine, tmp_path, client, _FakeDownloader(tmp_path))
result = ing.run(
source_id=source_id, campaign_id="c1", artist_slug="ingest",
url="https://patreon.com/ingest", mode="tick", seen_threshold=2,
revisit_days=30,
)
assert result.success is True
assert client.consumed_posts == 2
+23
View File
@@ -453,6 +453,29 @@ def test_write_post_record(tmp_path):
assert rec.body_chars > 0
def test_a_revisit_with_an_empty_body_writes_nothing(tmp_path):
"""The revisit contract's second half, which applies here even though the
first (no detail-fetch) is free on SubscribeStar — there is no detail
endpoint. A chunk that parsed with no content must not overwrite a body we
already have, so a walk behaves the same on both platforms."""
dl = _downloader(tmp_path)
post = _post("222")
post["attributes"]["content"] = ""
rec = dl.write_post_record(post, "artist-x", revisit=True)
assert rec.path is None
assert rec.body_chars == 0
assert not list(tmp_path.rglob("_post.json"))
def test_a_revisit_that_still_has_a_body_writes_it(tmp_path):
dl = _downloader(tmp_path)
rec = dl.write_post_record(_post("333"), "artist-x", revisit=True)
assert rec.path is not None
assert "body text" in json.loads(rec.path.read_text())["content"]
def test_skip_seen_does_not_download(tmp_path):
session = _FakeSession()
dl = _downloader(tmp_path, session=session)