notify_worker.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. """Small lifecycle shared by the two opt-in Notify background workers.
  2. Provider edits invalidate a cached enable flag. An unused integration therefore
  3. has no periodic database work. Cleanup captures ownership without waiting for the
  4. worker's HTTP/lock, then drains in the background; live row references retain a
  5. resource ID returned by an already-running create after its provider is deleted.
  6. """
  7. import asyncio
  8. import json
  9. import logging
  10. from sqlalchemy import delete, select
  11. from backend.app.models.notification import NotificationProvider
  12. logger = logging.getLogger(__name__)
  13. class NotifyWorkerLifecycle:
  14. ownership_model = None
  15. feature_field = ""
  16. worker_interval = 60
  17. worker_name = "notify-worker"
  18. stopped_states = ("ended", "deleted")
  19. def _init_worker(self):
  20. self._provider_enabled: bool | None = None
  21. self._providers_dirty = True
  22. self._cleanup_once = False
  23. self._cleanup_pending = False
  24. self._initial_discovery = True
  25. self._change_epoch = 0
  26. self._retry_delay = 1
  27. self._wake = asyncio.Event()
  28. self._cleanup_jobs: dict[tuple[int, str], tuple[dict, list]] = {}
  29. self._retired: set[tuple[int, str]] = set()
  30. self._working_rows: list = []
  31. def _eligible(self, provider):
  32. config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
  33. return bool(
  34. provider.enabled
  35. and config.get(self.feature_field) is True
  36. and not str(config.get("device_id", "")).strip().upper().startswith(("GRP", "WB", "MC"))
  37. )
  38. def _on_activated(self):
  39. pass
  40. def providers_changed(self):
  41. """Fast notification after provider CRUD; performs no DB or HTTP work."""
  42. self._providers_dirty = True
  43. self._cleanup_once = True
  44. self._change_epoch += 1
  45. self._wake.set()
  46. def _retired_row(self, row):
  47. return (row.provider_id, row.credential_key) in self._retired
  48. async def schedule_cleanup(self, provider_id, old_config):
  49. """Capture cleanup before DELETE commits, or after a credential PATCH.
  50. Only a short database read is awaited. Never cancel an in-flight create:
  51. its response may be our only opportunity to learn the remote handle.
  52. """
  53. credential = self._cleanup_key(old_config)
  54. key = (provider_id, credential)
  55. self._retired.add(key)
  56. try:
  57. async with self._session() as db:
  58. persisted = (
  59. await db.scalars(
  60. select(self.ownership_model).where(
  61. self.ownership_model.provider_id == provider_id,
  62. self.ownership_model.credential_key == credential,
  63. )
  64. )
  65. ).all()
  66. previous = self._cleanup_jobs.get(key, ({}, []))[1]
  67. # Prefer the mutable worker object for each row. A create already
  68. # awaiting HTTP will assign its returned ID on this same object.
  69. captured = {row.id: row for row in [*persisted, *previous]}
  70. for row in self._working_rows:
  71. if row.provider_id == provider_id and row.credential_key == credential:
  72. captured[row.id] = row
  73. self._cleanup_jobs[key] = (dict(old_config), list(captured.values()))
  74. except BaseException:
  75. if key not in self._cleanup_jobs:
  76. self._retired.discard(key)
  77. raise
  78. self.providers_changed()
  79. self.start()
  80. async def _purge_orphans(self):
  81. """Drop ownership rows whose provider no longer exists.
  82. SQLite does not enforce the provider cascade, and queued cleanup lives
  83. only in memory. A restart before it drains leaves rows behind, which a
  84. new provider reusing the same id would inherit. Their credentials are
  85. gone, so the remote resources can no longer be removed from here.
  86. """
  87. orphaned = self.ownership_model.provider_id.not_in(select(NotificationProvider.id))
  88. async with self._session() as db:
  89. if await db.scalar(select(self.ownership_model.id).where(orphaned).limit(1)) is None:
  90. return
  91. result = await db.execute(delete(self.ownership_model).where(orphaned))
  92. await db.commit()
  93. logger.info("%s removed %d rows left by deleted providers", self.worker_name, result.rowcount)
  94. def start(self):
  95. if self._task is None or self._task.done():
  96. self._task = asyncio.create_task(self._run(), name=self.worker_name)
  97. async def close(self):
  98. if self._task:
  99. self._task.cancel()
  100. try:
  101. await self._task
  102. except asyncio.CancelledError:
  103. pass
  104. self._task = None
  105. if self._http:
  106. await self._http.aclose()
  107. self._http = None
  108. self._client = None
  109. async def _run(self):
  110. while True:
  111. self._wake.clear()
  112. change_epoch = self._change_epoch
  113. try:
  114. if self._providers_dirty:
  115. self._providers_dirty = False
  116. if self._initial_discovery:
  117. await self._purge_orphans()
  118. async with self._session() as db:
  119. providers = (
  120. await db.scalars(
  121. select(NotificationProvider).where(NotificationProvider.provider_type == "notify")
  122. )
  123. ).all()
  124. previously_enabled = self._provider_enabled
  125. self._provider_enabled = any(self._eligible(provider) for provider in providers)
  126. if self._provider_enabled and previously_enabled is False:
  127. self._on_activated()
  128. if self._initial_discovery and not self._provider_enabled and providers:
  129. async with self._session() as db:
  130. pending = await db.scalar(
  131. select(self.ownership_model.id)
  132. .where(
  133. self.ownership_model.provider_id.in_([provider.id for provider in providers]),
  134. self.ownership_model.state.not_in(self.stopped_states),
  135. )
  136. .limit(1)
  137. )
  138. self._cleanup_pending = pending is not None
  139. self._initial_discovery = False
  140. while self._cleanup_jobs:
  141. key = next(iter(self._cleanup_jobs))
  142. old_config, rows = self._cleanup_jobs.pop(key)
  143. try:
  144. await self.cleanup_provider(key[0], old_config, captured_rows=rows)
  145. except Exception:
  146. pending = self._cleanup_jobs.get(key, (old_config, []))[1]
  147. self._cleanup_jobs[key] = (old_config, [*rows, *pending])
  148. raise
  149. finally:
  150. if key not in self._cleanup_jobs:
  151. self._retired.discard(key)
  152. if self._provider_enabled or self._cleanup_once or self._cleanup_pending:
  153. await self.tick()
  154. if change_epoch == self._change_epoch:
  155. self._cleanup_once = False
  156. self._retry_delay = 1
  157. except asyncio.CancelledError:
  158. raise
  159. except Exception:
  160. logger.exception("%s reconciliation failed", self.worker_name)
  161. self._providers_dirty = True
  162. await asyncio.sleep(self._retry_delay)
  163. self._retry_delay = min(self._retry_delay * 2, 30)
  164. continue
  165. if self._providers_dirty or self._cleanup_jobs:
  166. continue
  167. if self._provider_enabled or self._cleanup_pending:
  168. try:
  169. await asyncio.wait_for(self._wake.wait(), timeout=self.worker_interval)
  170. except TimeoutError:
  171. pass
  172. else:
  173. await self._wake.wait()