Moved stream subscription lifecycle handling into SQLite.
This commit is contained in:
+17
-12
@@ -110,13 +110,15 @@ scheduled polling, and tests wired through explicit dependencies.
|
||||
handlers pass it user-supplied stream targets, and it turns those targets into
|
||||
normalized domains before creating or removing stored data.
|
||||
|
||||
Subscribing a room creates a subscription for the normalized domain and creates
|
||||
the shared stream record when needed. Domains with no current subscribers are
|
||||
validated through `OwncastClient`; domains that already have subscribers reuse
|
||||
the existing stream record instead of revalidating.
|
||||
Subscribing a room validates domains with no current subscribers through
|
||||
`OwncastClient`, then inserts a subscription for the normalized domain. SQLite
|
||||
creates the shared stream record from that subscription insert when needed.
|
||||
Domains that already have subscribers reuse the existing stream record instead
|
||||
of revalidating.
|
||||
|
||||
Unsubscribing removes one room's subscription to a domain. It does not delete
|
||||
the shared stream record or make remote Owncast requests.
|
||||
Unsubscribing removes one room's subscription to a domain. When the last
|
||||
subscription for a domain is removed, SQLite deletes the shared stream record.
|
||||
Unsubscribe does not make remote Owncast requests.
|
||||
|
||||
Listing methods return the subscriptions for one Matrix room with shared stream
|
||||
state attached, so commands can display each instance's name, title, link,
|
||||
@@ -287,15 +289,18 @@ Maubot owns the database connection and runs the schema upgrades registered by
|
||||
package's stream and subscription persistence operations.
|
||||
|
||||
The persistence model stores one stream record per normalized domain and one
|
||||
subscription row per room/domain pair. `SubscriptionManager` normalizes user
|
||||
input before repository calls. `StreamRepository` writes display and state
|
||||
fields, while failure counters use dedicated methods.
|
||||
subscription row per room/domain pair. SQLite constraints enforce non-empty
|
||||
identifiers, unique room/domain subscriptions, and subscription ownership by a
|
||||
stream record. `SubscriptionManager` normalizes user input before repository
|
||||
calls. `StreamRepository` writes display and state fields, while failure
|
||||
counters use dedicated methods.
|
||||
`SubscriptionRepository` raises domain-specific errors for duplicate adds and
|
||||
missing removes.
|
||||
|
||||
Room subscription listings join `subscriptions` to `streams`, which means
|
||||
orphaned subscription entries without a matching stream record are skipped in
|
||||
room display queries.
|
||||
Subscription inserts create missing stream rows through a SQLite trigger, and
|
||||
deleting the last subscription for a domain deletes its stream row. Deleting a
|
||||
stream cascades to its subscriptions through SQLite, so cleanup only has to
|
||||
remove the stream record after user notifications are sent.
|
||||
|
||||
## Metrics
|
||||
|
||||
|
||||
@@ -102,7 +102,6 @@ class OwncastSentry(Plugin):
|
||||
# Initialize subscription manager
|
||||
self.subscription_manager = SubscriptionManager(
|
||||
self.owncast_client,
|
||||
self.stream_repo,
|
||||
self.subscription_repo,
|
||||
self.log,
|
||||
)
|
||||
|
||||
+149
-45
@@ -192,13 +192,134 @@ async def upgrade_v4(conn: Connection) -> None:
|
||||
await conn.execute("ALTER TABLE streams_new RENAME TO streams")
|
||||
|
||||
|
||||
@upgrade_table.register( # type: ignore[arg-type, call-arg, untyped-decorator]
|
||||
description="Formalize stream and subscription constraints"
|
||||
)
|
||||
async def upgrade_v5(conn: Connection) -> None:
|
||||
"""Upgrade database schema to version 5 format.
|
||||
|
||||
Rebuilds the current tables with explicit SQLite constraints, keeps only
|
||||
stream rows that have valid subscriptions, and creates placeholder stream
|
||||
rows for legacy subscriptions before adding the subscription-to-stream
|
||||
foreign key.
|
||||
|
||||
Subscription inserts create their stream parent row in a trigger before the
|
||||
foreign key is checked. Stream deletions cascade to dependent subscriptions.
|
||||
Subscription deletions also run a trigger that removes the stream row once
|
||||
no subscriptions remain.
|
||||
|
||||
:param conn: A connection to run the v5 database migration on.
|
||||
"""
|
||||
await conn.execute(
|
||||
"""CREATE TABLE "streams_new" (
|
||||
"domain" TEXT NOT NULL CHECK(length(trim("domain")) > 0),
|
||||
"name" TEXT,
|
||||
"title" TEXT,
|
||||
"online" INTEGER NOT NULL DEFAULT 0 CHECK("online" IN (0, 1)),
|
||||
"status_since" TEXT,
|
||||
"failure_counter" INTEGER NOT NULL DEFAULT 0
|
||||
CHECK("failure_counter" >= 0),
|
||||
PRIMARY KEY("domain")
|
||||
)"""
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"""INSERT INTO streams_new (
|
||||
domain, name, title, online, status_since, failure_counter
|
||||
)
|
||||
SELECT domain,
|
||||
name,
|
||||
title,
|
||||
CASE WHEN online THEN 1 ELSE 0 END,
|
||||
status_since,
|
||||
CASE
|
||||
WHEN failure_counter IS NULL OR failure_counter < 0 THEN 0
|
||||
ELSE failure_counter
|
||||
END
|
||||
FROM streams
|
||||
WHERE domain IS NOT NULL
|
||||
AND length(trim(domain)) > 0
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM subscriptions
|
||||
WHERE subscriptions.stream_domain = streams.domain
|
||||
AND subscriptions.room_id IS NOT NULL
|
||||
AND length(trim(subscriptions.room_id)) > 0
|
||||
)"""
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"""INSERT OR IGNORE INTO streams_new (domain)
|
||||
SELECT DISTINCT stream_domain
|
||||
FROM subscriptions
|
||||
WHERE stream_domain IS NOT NULL
|
||||
AND length(trim(stream_domain)) > 0
|
||||
AND room_id IS NOT NULL
|
||||
AND length(trim(room_id)) > 0"""
|
||||
)
|
||||
|
||||
await conn.execute("DROP TABLE streams")
|
||||
await conn.execute("ALTER TABLE streams_new RENAME TO streams")
|
||||
|
||||
await conn.execute(
|
||||
"""CREATE TABLE "subscriptions_new" (
|
||||
"stream_domain" TEXT NOT NULL
|
||||
CHECK(length(trim("stream_domain")) > 0),
|
||||
"room_id" TEXT NOT NULL CHECK(length(trim("room_id")) > 0),
|
||||
PRIMARY KEY("room_id", "stream_domain"),
|
||||
FOREIGN KEY("stream_domain")
|
||||
REFERENCES "streams"("domain")
|
||||
ON DELETE CASCADE
|
||||
)"""
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"""INSERT INTO subscriptions_new (stream_domain, room_id)
|
||||
SELECT stream_domain, room_id
|
||||
FROM subscriptions
|
||||
WHERE stream_domain IS NOT NULL
|
||||
AND length(trim(stream_domain)) > 0
|
||||
AND room_id IS NOT NULL
|
||||
AND length(trim(room_id)) > 0"""
|
||||
)
|
||||
|
||||
await conn.execute("DROP TABLE subscriptions")
|
||||
await conn.execute("ALTER TABLE subscriptions_new RENAME TO subscriptions")
|
||||
await conn.execute(
|
||||
"""CREATE INDEX "subscriptions_stream_domain_idx"
|
||||
ON "subscriptions"("stream_domain")"""
|
||||
)
|
||||
await conn.execute(
|
||||
"""CREATE TRIGGER "create_stream_for_subscription"
|
||||
BEFORE INSERT ON "subscriptions"
|
||||
BEGIN
|
||||
INSERT INTO "streams" ("domain")
|
||||
VALUES (NEW."stream_domain")
|
||||
ON CONFLICT("domain") DO NOTHING;
|
||||
END"""
|
||||
)
|
||||
await conn.execute(
|
||||
"""CREATE TRIGGER "delete_unsubscribed_stream"
|
||||
AFTER DELETE ON "subscriptions"
|
||||
WHEN NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "subscriptions"
|
||||
WHERE "stream_domain" = OLD."stream_domain"
|
||||
)
|
||||
BEGIN
|
||||
DELETE FROM "streams"
|
||||
WHERE "domain" = OLD."stream_domain";
|
||||
END"""
|
||||
)
|
||||
|
||||
|
||||
def get_upgrade_table() -> UpgradeTable:
|
||||
"""Return the repository upgrade table with registered migrations."""
|
||||
return upgrade_table
|
||||
|
||||
|
||||
class StreamRepository:
|
||||
"""Repository for managing stream data in the database."""
|
||||
"""Repository for stream parent rows and persisted stream state."""
|
||||
|
||||
def __init__(self, database: Database) -> None:
|
||||
"""Initialize the stream repository.
|
||||
@@ -210,6 +331,10 @@ class StreamRepository:
|
||||
async def create(self, domain: str) -> bool:
|
||||
"""Create a new stream entry in the database.
|
||||
|
||||
Subscription inserts also create stream rows through a SQLite trigger;
|
||||
this explicit create method is for workflows that need a stream row
|
||||
before any subscription is inserted.
|
||||
|
||||
:param domain: The stream domain.
|
||||
:return: True if created, False if the stream already existed.
|
||||
"""
|
||||
@@ -231,15 +356,6 @@ class StreamRepository:
|
||||
row = await conn.fetchrow(query, domain)
|
||||
return StreamState.from_db_row(row) if row else None
|
||||
|
||||
async def exists(self, domain: str) -> bool:
|
||||
"""Check if a stream exists in the database.
|
||||
|
||||
:param domain: The stream domain.
|
||||
:return: True if exists, False otherwise.
|
||||
"""
|
||||
result = await self.get_by_domain(domain)
|
||||
return result is not None
|
||||
|
||||
async def update(
|
||||
self,
|
||||
domain: str,
|
||||
@@ -302,6 +418,8 @@ class StreamRepository:
|
||||
async def delete(self, domain: str) -> None:
|
||||
"""Delete a stream record from the database.
|
||||
|
||||
SQLite cascades the delete to all subscriptions for the stream.
|
||||
|
||||
:param domain: The stream domain.
|
||||
"""
|
||||
query = "DELETE FROM streams WHERE domain=$1"
|
||||
@@ -332,7 +450,7 @@ class StreamRepository:
|
||||
|
||||
|
||||
class SubscriptionRepository:
|
||||
"""Repository for managing stream subscriptions in the database."""
|
||||
"""Repository for subscriptions and their database-owned stream rows."""
|
||||
|
||||
def __init__(self, database: Database) -> None:
|
||||
"""Initialize the subscription repository.
|
||||
@@ -344,6 +462,10 @@ class SubscriptionRepository:
|
||||
async def add(self, domain: str, room_id: str) -> None:
|
||||
"""Add a subscription for a room to a stream.
|
||||
|
||||
SQLite creates the stream parent row in a trigger before enforcing the
|
||||
subscription foreign key. Callers must validate first subscriptions
|
||||
before inserting untrusted domains.
|
||||
|
||||
:param domain: The stream domain.
|
||||
:param room_id: The Matrix room ID.
|
||||
:raises AlreadySubscribedError: If subscription already exists.
|
||||
@@ -359,6 +481,9 @@ class SubscriptionRepository:
|
||||
async def remove(self, domain: str, room_id: str) -> None:
|
||||
"""Remove a subscription for a room from a stream.
|
||||
|
||||
SQLite removes the stream row through a trigger when this deletes the
|
||||
last subscription for the domain.
|
||||
|
||||
:param domain: The stream domain.
|
||||
:param room_id: The Matrix room ID.
|
||||
:raises NotSubscribedError: If no subscription exists.
|
||||
@@ -369,17 +494,6 @@ class SubscriptionRepository:
|
||||
if int(result.rowcount) == 0:
|
||||
raise NotSubscribedError(domain)
|
||||
|
||||
async def delete_all_for_domain(self, domain: str) -> int:
|
||||
"""Delete all subscriptions for a given stream domain.
|
||||
|
||||
:param domain: The stream domain.
|
||||
:return: Number of subscriptions deleted.
|
||||
"""
|
||||
query = "DELETE FROM subscriptions WHERE stream_domain=$1"
|
||||
async with self.db.acquire() as conn:
|
||||
result = await conn.execute(query, domain)
|
||||
return int(result.rowcount)
|
||||
|
||||
async def get_subscribed_rooms(self, domain: str) -> list[str]:
|
||||
"""Get all room IDs subscribed to a stream.
|
||||
|
||||
@@ -391,16 +505,12 @@ class SubscriptionRepository:
|
||||
results = await conn.fetch(query, domain)
|
||||
return [row["room_id"] for row in results]
|
||||
|
||||
async def get_subscribed_streams_for_room(self, room_id: str) -> list[str]:
|
||||
"""Get all stream domains that a room is subscribed to.
|
||||
|
||||
:param room_id: The Matrix room ID.
|
||||
:return: List of stream domains.
|
||||
"""
|
||||
query = "SELECT stream_domain FROM subscriptions WHERE room_id=$1"
|
||||
async def has_domain_subscriptions(self, domain: str) -> bool:
|
||||
"""Check whether a stream domain has any subscriptions."""
|
||||
query = "SELECT 1 FROM subscriptions WHERE stream_domain=$1 LIMIT 1"
|
||||
async with self.db.acquire() as conn:
|
||||
results = await conn.fetch(query, room_id)
|
||||
return [row["stream_domain"] for row in results]
|
||||
result = await conn.fetchrow(query, domain)
|
||||
return result is not None
|
||||
|
||||
async def has_room_subscriptions(self, room_id: str) -> bool:
|
||||
"""Check whether a room has any subscriptions."""
|
||||
@@ -419,7 +529,7 @@ class SubscriptionRepository:
|
||||
FROM subscriptions
|
||||
JOIN streams ON streams.domain = subscriptions.stream_domain
|
||||
WHERE subscriptions.room_id=$1
|
||||
ORDER BY streams.domain"""
|
||||
ORDER BY subscriptions.stream_domain"""
|
||||
async with self.db.acquire() as conn:
|
||||
results = await conn.fetch(query, room_id)
|
||||
return [
|
||||
@@ -438,7 +548,7 @@ class SubscriptionRepository:
|
||||
WHERE subscriptions.room_id=$1
|
||||
AND streams.online=true
|
||||
AND streams.failure_counter <= $2
|
||||
ORDER BY streams.domain"""
|
||||
ORDER BY subscriptions.stream_domain"""
|
||||
async with self.db.acquire() as conn:
|
||||
results = await conn.fetch(query, room_id, UNKNOWN_STATUS_THRESHOLD)
|
||||
return [
|
||||
@@ -459,17 +569,6 @@ class SubscriptionRepository:
|
||||
results = await conn.fetch(query)
|
||||
return [row["stream_domain"] for row in results]
|
||||
|
||||
async def count_by_domain(self, domain: str) -> int:
|
||||
"""Count the number of subscriptions for a given stream domain.
|
||||
|
||||
:param domain: The stream domain.
|
||||
:return: Number of subscriptions.
|
||||
"""
|
||||
query = "SELECT COUNT(*) FROM subscriptions WHERE stream_domain=$1"
|
||||
async with self.db.acquire() as conn:
|
||||
result = await conn.fetchrow(query, domain)
|
||||
return int(result[0])
|
||||
|
||||
async def count_by_domains(self, domains: list[str]) -> dict[str, int]:
|
||||
"""Count subscriptions for each requested stream domain.
|
||||
|
||||
@@ -480,11 +579,16 @@ class SubscriptionRepository:
|
||||
return {}
|
||||
|
||||
counts = dict.fromkeys(domains, 0)
|
||||
placeholders = ", ".join(f"${index}" for index in range(1, len(counts) + 1))
|
||||
query = """SELECT stream_domain, COUNT(*) AS subscription_count
|
||||
FROM subscriptions
|
||||
WHERE stream_domain IN ({placeholders})
|
||||
GROUP BY stream_domain"""
|
||||
async with self.db.acquire() as conn:
|
||||
results = await conn.fetch(query)
|
||||
results = await conn.fetch(
|
||||
query.format(placeholders=placeholders),
|
||||
*counts,
|
||||
)
|
||||
|
||||
for row in results:
|
||||
domain = row["stream_domain"]
|
||||
|
||||
@@ -427,17 +427,13 @@ class StreamMonitor:
|
||||
# Send deletion notification
|
||||
await self.notification_service.send_cleanup_deletion(domain)
|
||||
|
||||
# Delete all subscriptions for this domain
|
||||
deleted_count = await self.subscription_repo.delete_all_for_domain(domain)
|
||||
|
||||
# Delete the stream record
|
||||
await self.stream_repo.delete(domain)
|
||||
self.offline_timer_cache.pop(domain, None)
|
||||
self.notification_service.clear_notification_state(domain)
|
||||
|
||||
self.log.info(
|
||||
"[%s] Cleanup complete. Deleted %s subscriptions and stream record.",
|
||||
"[%s] Cleanup complete. Deleted stream record and subscriptions.",
|
||||
domain,
|
||||
deleted_count,
|
||||
)
|
||||
self.metrics.remove_stream(domain)
|
||||
|
||||
@@ -27,7 +27,7 @@ if TYPE_CHECKING:
|
||||
import logging
|
||||
|
||||
from .owncast_client import OwncastClient
|
||||
from .repository import StreamRepository, SubscriptionRepository
|
||||
from .repository import SubscriptionRepository
|
||||
|
||||
|
||||
_DOMAIN_CLEANUP_RE = re.compile(r"[^a-z0-9.-]")
|
||||
@@ -55,19 +55,20 @@ class SubscriptionManager:
|
||||
def __init__(
|
||||
self,
|
||||
owncast_client: OwncastClient,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
logger: logging.Logger,
|
||||
) -> None:
|
||||
"""Initialize the subscription manager."""
|
||||
self.owncast_client = owncast_client
|
||||
self.stream_repo = stream_repo
|
||||
self.subscription_repo = subscription_repo
|
||||
self.log = logger
|
||||
|
||||
async def subscribe(self, room_id: str, url: str) -> str:
|
||||
"""Subscribe a room to stream notifications and return the stream domain.
|
||||
|
||||
First subscriptions validate the remote Owncast instance before the
|
||||
subscription insert creates the shared stream row through SQLite.
|
||||
|
||||
:param room_id: Matrix room ID to subscribe.
|
||||
:param url: User-supplied Owncast URL, domain, or Fediverse-style address.
|
||||
:return: Normalized stream domain.
|
||||
@@ -76,23 +77,27 @@ class SubscriptionManager:
|
||||
"""
|
||||
stream_domain = _domainify(url)
|
||||
|
||||
subscription_count = await self.subscription_repo.count_by_domain(stream_domain)
|
||||
if subscription_count == 0:
|
||||
is_new_domain = not await self.subscription_repo.has_domain_subscriptions(
|
||||
stream_domain
|
||||
)
|
||||
if is_new_domain:
|
||||
is_valid = await self.owncast_client.validate_instance(stream_domain)
|
||||
if not is_valid:
|
||||
raise InvalidOwncastInstanceError(stream_domain)
|
||||
|
||||
await self.subscription_repo.add(stream_domain, room_id)
|
||||
|
||||
if await self.stream_repo.create(stream_domain):
|
||||
if is_new_domain:
|
||||
self.log.info("[%s] Discovered new stream!", stream_domain)
|
||||
|
||||
self.log.info("[%s] Subscription added for room %s.", stream_domain, room_id)
|
||||
return stream_domain
|
||||
|
||||
async def unsubscribe(self, room_id: str, url: str) -> str:
|
||||
"""Remove a room subscription and return the stream domain.
|
||||
|
||||
When this removes the last subscription for a stream, SQLite deletes the
|
||||
stream row through the subscription cleanup trigger.
|
||||
|
||||
:param room_id: Matrix room ID to unsubscribe.
|
||||
:param url: User-supplied Owncast URL, domain, or Fediverse-style address.
|
||||
:return: Normalized stream domain.
|
||||
|
||||
Reference in New Issue
Block a user