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
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user