Moved stream subscription lifecycle handling into SQLite.
CI / Formatting (push) Successful in 31s
CI / Linting (push) Successful in 10s
CI / Tests (push) Successful in 35s
CI / Type Checking (push) Successful in 8s
CI / Spelling (push) Successful in 5s

This commit is contained in:
2026-05-25 19:18:20 -04:00
parent d02202295c
commit c21a8d073a
9 changed files with 921 additions and 290 deletions
+17 -12
View File
@@ -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
-1
View File
@@ -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
View File
@@ -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"]
+1 -5
View File
@@ -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)
+12 -7
View File
@@ -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.