feat: deferred dispatch, release-check provider, settings polish
- Defer quiet-hours dispatches into new deferred_dispatch table; drain job + periodic catch-up scan re-fire at window end with coalescing on (link, event_type, collection_id). - Add ON DELETE SET NULL migration on event_log_id and partial unique index on (link_id, collection_id, event_type) WHERE status='pending'. - Add release-check provider abstraction (Gitea/GitHub) with SSRF-safe URL validation, settings UI cassette, and scheduled polling. - Replace importlib-only version lookup with version.py helper that prefers the higher of installed metadata vs source pyproject so stale editable dev installs stop misreporting. - Aurora frontend polish: MetaStrip component, ReleaseCassette, EventDetailModal expansion, and i18n additions.
This commit is contained in:
@@ -1369,6 +1369,12 @@ _INDEXES: list[tuple[str, str, str]] = [
|
||||
("ix_command_template_slot_config_id", "command_template_slot", "config_id"),
|
||||
("ix_action_rule_action_id", "action_rule", "action_id"),
|
||||
("ix_action_execution_action_started", "action_execution", "action_id, started_at DESC"),
|
||||
# Deferred-dispatch drain: WHERE status = 'pending' AND fire_at <= ?
|
||||
# ORDER BY fire_at. The composite (status, fire_at) is the only access
|
||||
# pattern; an individual fire_at index isn't needed.
|
||||
("ix_deferred_dispatch_status_fire_at", "deferred_dispatch", "status, fire_at"),
|
||||
("ix_deferred_dispatch_link_id", "deferred_dispatch", "link_id"),
|
||||
("ix_deferred_dispatch_event_log_id", "deferred_dispatch", "event_log_id"),
|
||||
]
|
||||
|
||||
|
||||
@@ -1397,6 +1403,95 @@ async def migrate_performance_indexes(engine: AsyncEngine) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def migrate_deferred_dispatch_event_log_fk(engine: AsyncEngine) -> None:
|
||||
"""Rebuild ``deferred_dispatch`` if its event_log FK lacks ON DELETE SET NULL.
|
||||
|
||||
Early builds of this feature created the table with a default ``NO ACTION``
|
||||
FK on ``event_log_id``. The daily event_log cleanup deletes rows past the
|
||||
retention horizon — with SQLite's enforced foreign_keys PRAGMA, a pending
|
||||
DeferredDispatch row pointing at an aging-out event_log row would block
|
||||
the cleanup with an FK violation.
|
||||
|
||||
SQLite can't ALTER a constraint without rebuilding the table. The table
|
||||
has zero rows in any prod install old enough to need this fix (the
|
||||
feature shipped in the same release as this migration), so a drop +
|
||||
recreate via ``create_all`` is safe.
|
||||
"""
|
||||
async with engine.begin() as conn:
|
||||
if not await _has_table(conn, "deferred_dispatch"):
|
||||
return
|
||||
# Read the original CREATE TABLE SQL to see whether SET NULL is wired.
|
||||
row = await conn.run_sync(
|
||||
lambda sync_conn: sync_conn.execute(
|
||||
text(
|
||||
"SELECT sql FROM sqlite_master "
|
||||
"WHERE type='table' AND name='deferred_dispatch'"
|
||||
)
|
||||
).fetchone()
|
||||
)
|
||||
ddl = (row[0] or "") if row else ""
|
||||
if "ON DELETE SET NULL" in ddl.upper():
|
||||
return
|
||||
# Confirm there's nothing to migrate — refuse to drop a populated
|
||||
# table even though the schema was wrong. Better to leave a warning
|
||||
# than to lose state.
|
||||
count_row = await conn.run_sync(
|
||||
lambda sync_conn: sync_conn.execute(
|
||||
text("SELECT COUNT(*) FROM deferred_dispatch")
|
||||
).fetchone()
|
||||
)
|
||||
if count_row and count_row[0]:
|
||||
logger.warning(
|
||||
"deferred_dispatch FK is missing ON DELETE SET NULL but the "
|
||||
"table holds %d rows; not auto-dropping. Inspect manually.",
|
||||
count_row[0],
|
||||
)
|
||||
return
|
||||
await conn.execute(text("DROP TABLE deferred_dispatch"))
|
||||
logger.info(
|
||||
"Dropped deferred_dispatch (empty) so create_all rebuilds it "
|
||||
"with ON DELETE SET NULL on event_log_id",
|
||||
)
|
||||
# Recreate the table from the SQLModel metadata in this same txn.
|
||||
from sqlmodel import SQLModel
|
||||
# Ensure the model is registered on metadata before we ask create_all
|
||||
# to build it. Lazy import to avoid a circular at module load time.
|
||||
from .models import DeferredDispatch # noqa: F401
|
||||
await conn.run_sync(
|
||||
SQLModel.metadata.create_all,
|
||||
tables=[SQLModel.metadata.tables["deferred_dispatch"]],
|
||||
)
|
||||
|
||||
|
||||
async def migrate_deferred_dispatch_unique_pending(engine: AsyncEngine) -> None:
|
||||
"""Add a partial unique index preventing duplicate pending defers.
|
||||
|
||||
Without this, two webhook handlers (or a webhook racing the watcher)
|
||||
can both call ``_find_pending_asset_rows`` and find nothing, then both
|
||||
INSERT — defeating coalescing. The partial index makes the second
|
||||
INSERT raise ``IntegrityError`` and the caller's transaction abort,
|
||||
after which a retry will see the now-visible row.
|
||||
|
||||
SQLite has supported ``CREATE UNIQUE INDEX ... WHERE ...`` since 3.8.
|
||||
Once the table exists this is safe to run on every boot.
|
||||
"""
|
||||
async with engine.begin() as conn:
|
||||
if not await _has_table(conn, "deferred_dispatch"):
|
||||
return
|
||||
try:
|
||||
await conn.execute(text(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS "
|
||||
"ux_deferred_dispatch_pending "
|
||||
"ON deferred_dispatch(link_id, collection_id, event_type) "
|
||||
"WHERE status = 'pending'"
|
||||
))
|
||||
except Exception: # pragma: no cover — log and continue
|
||||
logger.warning(
|
||||
"Failed to create partial unique index on deferred_dispatch",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
async def migrate_chat_action_to_column(engine: AsyncEngine) -> None:
|
||||
"""Move ``chat_action`` from ``config`` JSON to the dedicated column.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user