generated from coulomb/repo-seed
Add admin sync hot reload path
This commit is contained in:
@@ -15,6 +15,8 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
@@ -30,6 +32,20 @@ TEMPORAL_HOST = os.environ.get("TEMPORAL_HOST", "localhost:7233")
|
||||
TEMPORAL_NAMESPACE = os.environ.get("TEMPORAL_NAMESPACE", "default")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScheduleSyncResult:
|
||||
upserted: int = 0
|
||||
paused: int = 0
|
||||
deleted_orphans: int = 0
|
||||
|
||||
def to_dict(self) -> dict[str, int]:
|
||||
return {
|
||||
"upserted": self.upserted,
|
||||
"paused": self.paused,
|
||||
"deleted_orphans": self.deleted_orphans,
|
||||
}
|
||||
|
||||
|
||||
def _row_to_domain(row: ActivityDefinitionRow) -> ActivityDefinition:
|
||||
"""Convert an ORM row to a domain ActivityDefinition for schedule_manager."""
|
||||
return ActivityDefinition.model_validate(
|
||||
@@ -46,12 +62,82 @@ def _row_to_domain(row: ActivityDefinitionRow) -> ActivityDefinition:
|
||||
)
|
||||
|
||||
|
||||
async def sync(client: Client, db_url: str) -> None:
|
||||
def _valid_schedule_activity_id(defn: ActivityDefinition) -> str:
|
||||
if isinstance(defn.trigger_config, ScheduledTriggerConfig):
|
||||
return f"{defn.id}-once"
|
||||
return str(defn.id)
|
||||
|
||||
|
||||
async def _load_schedule_rows(
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> Sequence[ActivityDefinitionRow]:
|
||||
async with session_factory() as session:
|
||||
return (
|
||||
await session.scalars(
|
||||
select(ActivityDefinitionRow).where(
|
||||
ActivityDefinitionRow.trigger_type.in_(["cron", "scheduled"])
|
||||
)
|
||||
)
|
||||
).all()
|
||||
|
||||
|
||||
async def sync_schedule_rows(
|
||||
client: Client,
|
||||
rows: Sequence[ActivityDefinitionRow],
|
||||
) -> ScheduleSyncResult:
|
||||
"""Reconcile Temporal Schedules against already-loaded definition rows."""
|
||||
valid_schedule_activity_ids: set[str] = set()
|
||||
result = ScheduleSyncResult()
|
||||
|
||||
for row in rows:
|
||||
defn = _row_to_domain(row)
|
||||
if not isinstance(
|
||||
defn.trigger_config,
|
||||
(CronTriggerConfig, ScheduledTriggerConfig),
|
||||
):
|
||||
continue
|
||||
|
||||
valid_schedule_activity_ids.add(_valid_schedule_activity_id(defn))
|
||||
|
||||
await upsert_schedule(client, defn)
|
||||
if defn.enabled:
|
||||
result.upserted += 1
|
||||
logger.info("upserted schedule for activity %s (%s)", defn.id, defn.name)
|
||||
else:
|
||||
result.paused += 1
|
||||
logger.info("upserted paused schedule for disabled activity %s", defn.id)
|
||||
|
||||
# Tombstone cleanup: remove Temporal Schedules with no matching DB row.
|
||||
existing_schedules = await list_schedules(client)
|
||||
for entry in existing_schedules:
|
||||
if entry["activity_id"] not in valid_schedule_activity_ids:
|
||||
await delete_schedule(client, entry["activity_id"])
|
||||
result.deleted_orphans += 1
|
||||
logger.info("deleted orphaned schedule %s", entry["schedule_id"])
|
||||
|
||||
logger.info(
|
||||
"sync_schedules complete — upserted=%d paused=%d deleted_orphans=%d",
|
||||
result.upserted,
|
||||
result.paused,
|
||||
result.deleted_orphans,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def sync_with_session_factory(
|
||||
client: Client,
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> ScheduleSyncResult:
|
||||
"""Reconcile Temporal Schedules using an existing DB session factory."""
|
||||
return await sync_schedule_rows(client, await _load_schedule_rows(session_factory))
|
||||
|
||||
|
||||
async def sync(client: Client, db_url: str) -> ScheduleSyncResult:
|
||||
"""Reconcile Temporal Schedules against the ActivityDefinition table.
|
||||
|
||||
Steps:
|
||||
1. Load all enabled cron ActivityDefinitions from Postgres.
|
||||
2. Upsert a Temporal Schedule for each one.
|
||||
1. Load all cron/scheduled ActivityDefinitions from Postgres.
|
||||
2. Upsert a Temporal Schedule for each one, paused when disabled.
|
||||
3. Delete Temporal Schedules whose activity_id has no matching DB row
|
||||
(tombstone cleanup for deleted or trigger-type-changed definitions).
|
||||
"""
|
||||
@@ -59,55 +145,10 @@ async def sync(client: Client, db_url: str) -> None:
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
try:
|
||||
async with session_factory() as session:
|
||||
rows = (
|
||||
await session.scalars(
|
||||
select(ActivityDefinitionRow).where(
|
||||
ActivityDefinitionRow.trigger_type.in_(["cron", "scheduled"])
|
||||
)
|
||||
)
|
||||
).all()
|
||||
return await sync_with_session_factory(client, session_factory)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
db_activity_ids: set[str] = set()
|
||||
upserted = 0
|
||||
skipped = 0
|
||||
|
||||
for row in rows:
|
||||
defn = _row_to_domain(row)
|
||||
if not isinstance(defn.trigger_config, (CronTriggerConfig, ScheduledTriggerConfig)):
|
||||
continue
|
||||
|
||||
db_activity_ids.add(str(defn.id))
|
||||
|
||||
if defn.enabled:
|
||||
await upsert_schedule(client, defn)
|
||||
upserted += 1
|
||||
logger.info("upserted schedule for activity %s (%s)", defn.id, defn.name)
|
||||
else:
|
||||
# Disabled definitions: schedule may exist (paused) — leave it;
|
||||
# upsert_schedule already handles the paused state.
|
||||
await upsert_schedule(client, defn)
|
||||
skipped += 1
|
||||
logger.info("upserted paused schedule for disabled activity %s", defn.id)
|
||||
|
||||
# Tombstone cleanup: remove Temporal Schedules with no matching DB row.
|
||||
existing_schedules = await list_schedules(client)
|
||||
deleted = 0
|
||||
for entry in existing_schedules:
|
||||
if entry["activity_id"] not in db_activity_ids:
|
||||
await delete_schedule(client, entry["activity_id"])
|
||||
deleted += 1
|
||||
logger.info("deleted orphaned schedule %s", entry["schedule_id"])
|
||||
|
||||
logger.info(
|
||||
"sync_schedules complete — upserted=%d skipped_disabled=%d deleted_orphans=%d",
|
||||
upserted,
|
||||
skipped,
|
||||
deleted,
|
||||
)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
@@ -116,7 +157,13 @@ async def main() -> None:
|
||||
raise RuntimeError("ACTCORE_DB_URL is required")
|
||||
|
||||
client = await Client.connect(TEMPORAL_HOST, namespace=TEMPORAL_NAMESPACE)
|
||||
await sync(client, db_url)
|
||||
result = await sync(client, db_url)
|
||||
print(
|
||||
"Synced schedules: "
|
||||
f"upserted={result.upserted} "
|
||||
f"paused={result.paused} "
|
||||
f"deleted_orphans={result.deleted_orphans}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user