Emit DELETE for HTTP URLs removed from config

This commit is contained in:
Yiorgis Gozadinos 2026-05-27 13:39:18 +03:00
parent d7fdd61fed
commit 7466539b4f
No known key found for this signature in database
2 changed files with 28 additions and 8 deletions

View file

@ -86,9 +86,9 @@ class HTTPSource:
self, since: RevisionSnapshot | None = None self, since: RevisionSnapshot | None = None
) -> AsyncIterator[SourceEvent]: ) -> AsyncIterator[SourceEvent]:
# HTTP has no listing concept — discover() only reports on what is # HTTP has no listing concept — discover() only reports on what is
# currently configured in self.urls. Config drift (URIs that were # currently configured in self.urls. URLs that were previously in
# configured before but aren't now) is not visible here; the poller # config but aren't now emit DELETE so the poller can clean up
# layer detects that by diffing self.urls across sweeps. # alongside the in-source 410 signal.
# #
# 410 Gone is the one real source-side deletion signal: the origin # 410 Gone is the one real source-side deletion signal: the origin
# explicitly says "permanently gone". 404 and other failures are # explicitly says "permanently gone". 404 and other failures are
@ -97,6 +97,7 @@ class HTTPSource:
# via GET. # via GET.
snapshot: dict[str, str] = dict(since) if since else {} snapshot: dict[str, str] = dict(since) if since else {}
now = datetime.now(UTC) now = datetime.now(UTC)
configured = set(self.urls)
async with self._client() as http: async with self._client() as http:
for url in self.urls: for url in self.urls:
@ -145,3 +146,18 @@ class HTTPSource:
revision=revision, revision=revision,
discovered_at=now, discovered_at=now,
) )
# Anything previously ingested by this source that's no longer in
# config (URL removed from `urls`) emits DELETE so delete_orphans
# can clean up. Without this, removing a URL from config leaves the
# document and sync_state indefinitely.
for url in snapshot:
if url in configured:
continue
yield SourceEvent(
source_id=self.source_id,
uri=url,
kind=SourceEventKind.DELETE,
revision=None,
discovered_at=now,
)

View file

@ -274,9 +274,11 @@ async def test_discover_treats_network_errors_as_upsert():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_discover_does_not_emit_delete_for_unconfigured_uri(): async def test_discover_emits_delete_for_removed_url():
"""Config drift is the poller's job, not HTTPSource's. A URI that was in """A URI that was previously in config (and therefore in the snapshot)
the snapshot but is no longer configured must not appear as a DELETE.""" but is no longer configured emits DELETE so the poller can clean up
when delete_orphans=True. Mirrors what FS/S3/WebDAV already do for
items missing from a listing."""
transport = _transport( transport = _transport(
{ {
("HEAD", "https://example.com/a.md"): httpx.Response( ("HEAD", "https://example.com/a.md"): httpx.Response(
@ -290,5 +292,7 @@ async def test_discover_does_not_emit_delete_for_unconfigured_uri():
events = [ events = [
e async for e in src.discover(since={"https://example.com/gone.md": "old"}) e async for e in src.discover(since={"https://example.com/gone.md": "old"})
] ]
assert {e.uri for e in events} == {"https://example.com/a.md"} by_uri = {e.uri: e for e in events}
assert all(e.kind is not SourceEventKind.DELETE for e in events) assert by_uri["https://example.com/a.md"].kind is not SourceEventKind.DELETE
assert by_uri["https://example.com/gone.md"].kind is SourceEventKind.DELETE
assert by_uri["https://example.com/gone.md"].revision is None