v1.5.0: entry-dedup in één query + conditional get (ETag/Last-Modified)
- filter_entries: één SELECT per feed i.p.v. per-entry EXISTS (1082 queries → 20) - feed.etag/last_modified (migratie v9); parse_feed stuurt If-None-Match/If-Modified-Since, 304 → None - _poll_once telt unchanged (304-feeds); summary-regel uitgebreid met unchanged - _poll_feeds re-raised CancelledError (reload-stapeling duplicaten) + handler-cleanup
This commit is contained in:
parent
1f92b38ba6
commit
a03f76ca83
4 changed files with 59 additions and 25 deletions
|
|
@ -15,7 +15,7 @@
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Iterable
|
from typing import Any
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from string import Template
|
from string import Template
|
||||||
from time import mktime, monotonic, time
|
from time import mktime, monotonic, time
|
||||||
|
|
@ -235,24 +235,32 @@ class RSSBot(Plugin):
|
||||||
async def _poll_once(self) -> None:
|
async def _poll_once(self) -> None:
|
||||||
subs = await self.dbm.get_feeds()
|
subs = await self.dbm.get_feeds()
|
||||||
if not subs:
|
if not subs:
|
||||||
self._log_run_summary(0, 0, 0, 0, 0, 0.0)
|
self._log_run_summary(0, 0, 0, 0, 0, 0, 0.0)
|
||||||
return
|
return
|
||||||
now = int(time())
|
now = int(time())
|
||||||
start = monotonic()
|
start = monotonic()
|
||||||
tasks = [self.try_parse_feed(feed=feed) for feed in subs if feed.next_retry < now]
|
tasks = [self.try_parse_feed(feed=feed) for feed in subs if feed.next_retry < now]
|
||||||
feed: Feed
|
feed: Feed
|
||||||
entries: Iterable[Entry]
|
entries: list[Entry] | None
|
||||||
self.log.info(f"Polling {len(tasks)} feeds")
|
self.log.info(f"Polling {len(tasks)} feeds")
|
||||||
evaluated_total = 0
|
evaluated_total = 0
|
||||||
placed_total = 0
|
placed_total = 0
|
||||||
filtered_total = 0
|
filtered_total = 0
|
||||||
failed_total = 0
|
failed_total = 0
|
||||||
|
unchanged_total = 0
|
||||||
for res in asyncio.as_completed(tasks):
|
for res in asyncio.as_completed(tasks):
|
||||||
feed, entries = await res
|
feed, entries = await res
|
||||||
self.log.trace(
|
self.log.trace(
|
||||||
f"Fetching {feed.id} (backoff: {feed.error_count} / {feed.next_retry}) "
|
f"Fetching {feed.id} (backoff: {feed.error_count} / {feed.next_retry}) "
|
||||||
f"success: {bool(entries)}"
|
f"success: {entries is not None}"
|
||||||
)
|
)
|
||||||
|
if entries is None:
|
||||||
|
# Feed returned 304: unchanged, no new entries, no error.
|
||||||
|
unchanged_total += 1
|
||||||
|
if feed.error_count > 0:
|
||||||
|
self.log.debug(f"Resetting backoff of {feed.id}")
|
||||||
|
await self.dbm.set_backoff(feed, error_count=0, next_retry=0)
|
||||||
|
continue
|
||||||
if not entries:
|
if not entries:
|
||||||
error_count = feed.error_count + 1
|
error_count = feed.error_count + 1
|
||||||
next_retry_delay = self.config["update_interval"] * 60 * error_count
|
next_retry_delay = self.config["update_interval"] * 60 * error_count
|
||||||
|
|
@ -291,6 +299,7 @@ class RSSBot(Plugin):
|
||||||
placed_total,
|
placed_total,
|
||||||
filtered_total,
|
filtered_total,
|
||||||
failed_total,
|
failed_total,
|
||||||
|
unchanged_total,
|
||||||
duration,
|
duration,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -301,11 +310,13 @@ class RSSBot(Plugin):
|
||||||
selected: int,
|
selected: int,
|
||||||
filtered: int,
|
filtered: int,
|
||||||
failed: int,
|
failed: int,
|
||||||
|
unchanged: int,
|
||||||
duration: float,
|
duration: float,
|
||||||
) -> None:
|
) -> None:
|
||||||
line = (
|
line = (
|
||||||
f"run complete feeds={feeds} evaluated={evaluated} selected={selected} "
|
f"run complete feeds={feeds} evaluated={evaluated} selected={selected} "
|
||||||
f"filtered={filtered} failed={failed} duration={duration:.2f}s"
|
f"filtered={filtered} failed={failed} unchanged={unchanged} "
|
||||||
|
f"duration={duration:.2f}s"
|
||||||
)
|
)
|
||||||
self.log.info(f"Finished polling {feeds} feeds in {duration:.2f} seconds: {line}")
|
self.log.info(f"Finished polling {feeds} feeds in {duration:.2f} seconds: {line}")
|
||||||
if self._run_logger:
|
if self._run_logger:
|
||||||
|
|
@ -323,14 +334,14 @@ class RSSBot(Plugin):
|
||||||
self.log.exception("Error while polling feeds")
|
self.log.exception("Error while polling feeds")
|
||||||
await asyncio.sleep(self.config["update_interval"] * 60)
|
await asyncio.sleep(self.config["update_interval"] * 60)
|
||||||
|
|
||||||
async def try_parse_feed(self, feed: Feed | None = None) -> tuple[Feed, list[Entry]]:
|
async def try_parse_feed(self, feed: Feed | None = None) -> tuple[Feed, list[Entry] | None]:
|
||||||
if self.poll_sema is not None:
|
if self.poll_sema is not None:
|
||||||
async with self.poll_sema:
|
async with self.poll_sema:
|
||||||
return await self._try_parse_feed(feed)
|
return await self._try_parse_feed(feed)
|
||||||
else:
|
else:
|
||||||
return await self._try_parse_feed(feed)
|
return await self._try_parse_feed(feed)
|
||||||
|
|
||||||
async def _try_parse_feed(self, feed: Feed | None = None) -> tuple[Feed, list[Entry]]:
|
async def _try_parse_feed(self, feed: Feed | None = None) -> tuple[Feed, list[Entry] | None]:
|
||||||
try:
|
try:
|
||||||
self.log.trace(
|
self.log.trace(
|
||||||
f"Trying to fetch {feed.id} / {feed.url} "
|
f"Trying to fetch {feed.id} / {feed.url} "
|
||||||
|
|
@ -349,18 +360,35 @@ class RSSBot(Plugin):
|
||||||
|
|
||||||
async def parse_feed(
|
async def parse_feed(
|
||||||
self, *, feed: Feed | None = None, url: str | None = None
|
self, *, feed: Feed | None = None, url: str | None = None
|
||||||
) -> tuple[Feed, list[Entry]]:
|
) -> tuple[Feed, list[Entry] | None]:
|
||||||
if feed is None:
|
if feed is None:
|
||||||
if url is None:
|
if url is None:
|
||||||
raise ValueError("Either feed or url must be set")
|
raise ValueError("Either feed or url must be set")
|
||||||
feed = Feed(id=-1, url=url, title="", subtitle="", link="")
|
feed = Feed(id=-1, url=url, title="", subtitle="", link="")
|
||||||
elif url is not None:
|
elif url is not None:
|
||||||
raise ValueError("Only one of feed or url must be set")
|
raise ValueError("Only one of feed or url must be set")
|
||||||
|
headers = self._feed_get_headers
|
||||||
|
if feed.id > 0:
|
||||||
|
if feed.etag:
|
||||||
|
headers["If-None-Match"] = feed.etag
|
||||||
|
if feed.last_modified:
|
||||||
|
headers["If-Modified-Since"] = feed.last_modified
|
||||||
resp = await self.http.get(
|
resp = await self.http.get(
|
||||||
feed.url,
|
feed.url,
|
||||||
headers=self._feed_get_headers,
|
headers=headers,
|
||||||
timeout=aiohttp.ClientTimeout(total=20),
|
timeout=aiohttp.ClientTimeout(total=20),
|
||||||
)
|
)
|
||||||
|
if resp.status == 304:
|
||||||
|
# Feed unchanged since last fetch: no new entries, no error.
|
||||||
|
return feed, None
|
||||||
|
etag = resp.headers.get("ETag", "")
|
||||||
|
last_modified = resp.headers.get("Last-Modified", "")
|
||||||
|
if feed.id > 0 and (etag or last_modified):
|
||||||
|
feed.etag, feed.last_modified = etag, last_modified
|
||||||
|
try:
|
||||||
|
await self.dbm.set_feed_headers(feed, etag, last_modified)
|
||||||
|
except Exception:
|
||||||
|
self.log.debug(f"Failed to store fetch headers of {feed.id}")
|
||||||
ct = resp.headers["Content-Type"].split(";")[0].strip()
|
ct = resp.headers["Content-Type"].split(";")[0].strip()
|
||||||
if ct == "application/json" or ct == "application/feed+json":
|
if ct == "application/json" or ct == "application/feed+json":
|
||||||
return await self._parse_json(feed, resp)
|
return await self._parse_json(feed, resp)
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,8 @@ class Feed:
|
||||||
|
|
||||||
next_retry: int = 0
|
next_retry: int = 0
|
||||||
error_count: int = 0
|
error_count: int = 0
|
||||||
|
etag: str = ""
|
||||||
|
last_modified: str = ""
|
||||||
|
|
||||||
subscriptions: list[Subscription] = attr.ib(factory=lambda: [])
|
subscriptions: list[Subscription] = attr.ib(factory=lambda: [])
|
||||||
|
|
||||||
|
|
@ -119,7 +121,7 @@ class DBManager:
|
||||||
|
|
||||||
async def get_feeds(self) -> list[Feed]:
|
async def get_feeds(self) -> list[Feed]:
|
||||||
q = """
|
q = """
|
||||||
SELECT id, url, title, subtitle, link, next_retry, error_count,
|
SELECT id, url, title, subtitle, link, next_retry, error_count, etag, last_modified,
|
||||||
room_id, user_id, notification_template, send_notice
|
room_id, user_id, notification_template, send_notice
|
||||||
FROM feed INNER JOIN subscription ON feed.id = subscription.feed_id
|
FROM feed INNER JOIN subscription ON feed.id = subscription.feed_id
|
||||||
"""
|
"""
|
||||||
|
|
@ -135,25 +137,19 @@ class DBManager:
|
||||||
|
|
||||||
async def get_feeds_by_room(self, room_id: RoomID) -> list[tuple[Feed, UserID]]:
|
async def get_feeds_by_room(self, room_id: RoomID) -> list[tuple[Feed, UserID]]:
|
||||||
q = """
|
q = """
|
||||||
SELECT id, url, title, subtitle, link, next_retry, error_count, user_id FROM feed
|
SELECT id, url, title, subtitle, link, next_retry, error_count, etag, last_modified,
|
||||||
|
user_id FROM feed
|
||||||
INNER JOIN subscription ON feed.id = subscription.feed_id AND subscription.room_id = $1
|
INNER JOIN subscription ON feed.id = subscription.feed_id AND subscription.room_id = $1
|
||||||
"""
|
"""
|
||||||
rows = await self.db.fetch(q, room_id)
|
rows = await self.db.fetch(q, room_id)
|
||||||
return [(Feed.from_row(row), row["user_id"]) for row in rows]
|
return [(Feed.from_row(row), row["user_id"]) for row in rows]
|
||||||
|
|
||||||
async def filter_entries(self, feed_id: int, entries: dict[str, Entry]) -> None:
|
async def filter_entries(self, feed_id: int, entries: dict[str, Entry]) -> None:
|
||||||
entry_keys = list(entries.keys())
|
if not entries:
|
||||||
if not entry_keys:
|
|
||||||
return
|
return
|
||||||
async with self.db.acquire() as conn:
|
existing = await self.db.fetch("SELECT id FROM entry WHERE feed_id = $1", feed_id)
|
||||||
for key in entry_keys:
|
for row in existing:
|
||||||
exists = await conn.fetchval(
|
entries.pop(row["id"], None)
|
||||||
"SELECT EXISTS(SELECT 1 FROM entry WHERE feed_id = $1 AND id = $2)",
|
|
||||||
feed_id,
|
|
||||||
key,
|
|
||||||
)
|
|
||||||
if exists:
|
|
||||||
del entries[key]
|
|
||||||
|
|
||||||
async def add_entries(self, entries: list[Entry], override_feed_id: int | None = None) -> None:
|
async def add_entries(self, entries: list[Entry], override_feed_id: int | None = None) -> None:
|
||||||
if not entries:
|
if not entries:
|
||||||
|
|
@ -174,14 +170,14 @@ class DBManager:
|
||||||
await conn.executemany(q, records)
|
await conn.executemany(q, records)
|
||||||
|
|
||||||
async def get_feed_by_url(self, url: str) -> Feed | None:
|
async def get_feed_by_url(self, url: str) -> Feed | None:
|
||||||
q = "SELECT id, url, title, subtitle, link, next_retry, error_count FROM feed WHERE url=$1"
|
q = "SELECT id, url, title, subtitle, link, next_retry, error_count, etag, last_modified FROM feed WHERE url=$1"
|
||||||
return Feed.from_row(await self.db.fetchrow(q, url))
|
return Feed.from_row(await self.db.fetchrow(q, url))
|
||||||
|
|
||||||
async def get_subscription(
|
async def get_subscription(
|
||||||
self, feed_id: int, room_id: RoomID
|
self, feed_id: int, room_id: RoomID
|
||||||
) -> tuple[Subscription | None, Feed | None]:
|
) -> tuple[Subscription | None, Feed | None]:
|
||||||
q = """
|
q = """
|
||||||
SELECT id, url, title, subtitle, link, next_retry, error_count,
|
SELECT id, url, title, subtitle, link, next_retry, error_count, etag, last_modified,
|
||||||
room_id, user_id, notification_template, send_notice
|
room_id, user_id, notification_template, send_notice
|
||||||
FROM feed LEFT JOIN subscription ON feed.id = subscription.feed_id AND room_id = $2
|
FROM feed LEFT JOIN subscription ON feed.id = subscription.feed_id AND room_id = $2
|
||||||
WHERE feed.id = $1
|
WHERE feed.id = $1
|
||||||
|
|
@ -206,6 +202,10 @@ class DBManager:
|
||||||
q = "UPDATE feed SET error_count = $2, next_retry = $3 WHERE id = $1"
|
q = "UPDATE feed SET error_count = $2, next_retry = $3 WHERE id = $1"
|
||||||
await self.db.execute(q, info.id, error_count, next_retry)
|
await self.db.execute(q, info.id, error_count, next_retry)
|
||||||
|
|
||||||
|
async def set_feed_headers(self, info: Feed, etag: str, last_modified: str) -> None:
|
||||||
|
q = "UPDATE feed SET etag = $2, last_modified = $3 WHERE id = $1"
|
||||||
|
await self.db.execute(q, info.id, etag, last_modified)
|
||||||
|
|
||||||
async def subscribe(
|
async def subscribe(
|
||||||
self,
|
self,
|
||||||
feed_id: int,
|
feed_id: int,
|
||||||
|
|
|
||||||
|
|
@ -99,3 +99,9 @@ async def upgrade_v7(conn: Connection) -> None:
|
||||||
@upgrade_table.register(description="Add paywalled flag to entries")
|
@upgrade_table.register(description="Add paywalled flag to entries")
|
||||||
async def upgrade_v8(conn: Connection) -> None:
|
async def upgrade_v8(conn: Connection) -> None:
|
||||||
await conn.execute("ALTER TABLE entry ADD COLUMN paywalled BOOLEAN NOT NULL DEFAULT false")
|
await conn.execute("ALTER TABLE entry ADD COLUMN paywalled BOOLEAN NOT NULL DEFAULT false")
|
||||||
|
|
||||||
|
|
||||||
|
@upgrade_table.register(description="Add conditional-fetch headers to feeds")
|
||||||
|
async def upgrade_v9(conn: Connection) -> None:
|
||||||
|
await conn.execute("ALTER TABLE feed ADD COLUMN etag TEXT DEFAULT ''")
|
||||||
|
await conn.execute("ALTER TABLE feed ADD COLUMN last_modified TEXT DEFAULT ''")
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
id: nl.eddydevink.rss
|
id: nl.eddydevink.rss
|
||||||
version: 1.4.1
|
version: 1.5.0
|
||||||
modules:
|
modules:
|
||||||
- buzzrss
|
- buzzrss
|
||||||
main_class: RSSBot
|
main_class: RSSBot
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue