omkit 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- omkit/__init__.py +18 -0
- omkit/cleanup.py +62 -0
- omkit/config.py +60 -0
- omkit/cost.py +78 -0
- omkit/data/__init__.py +33 -0
- omkit/dbpool.py +139 -0
- omkit/encryption.py +58 -0
- omkit/eventbus.py +360 -0
- omkit/events.py +23 -0
- omkit/health.py +66 -0
- omkit/http.py +82 -0
- omkit/internal/__init__.py +7 -0
- omkit/internal/crypto.py +17 -0
- omkit/jobqueue/__init__.py +28 -0
- omkit/jobqueue/envelope.py +116 -0
- omkit/jobqueue/streaq.py +267 -0
- omkit/logging.py +77 -0
- omkit/metrics.py +41 -0
- omkit/model_lifecycle.py +192 -0
- omkit/platform/__init__.py +18 -0
- omkit/providers/__init__.py +11 -0
- omkit/providers/base.py +76 -0
- omkit/providers/registry.py +263 -0
- omkit/py.typed +0 -0
- omkit/quota.py +186 -0
- omkit/resilience.py +122 -0
- omkit/sanitize.py +122 -0
- omkit/security/__init__.py +28 -0
- omkit/security/events.py +79 -0
- omkit/sessions.py +301 -0
- omkit/settings.py +348 -0
- omkit/sync_notifier.py +110 -0
- omkit/tenant.py +271 -0
- omkit/tracing.py +80 -0
- omkit/transport/__init__.py +29 -0
- omkit/valkey.py +45 -0
- omkit-0.0.2.dist-info/METADATA +29 -0
- omkit-0.0.2.dist-info/RECORD +40 -0
- omkit-0.0.2.dist-info/WHEEL +5 -0
- omkit-0.0.2.dist-info/top_level.txt +1 -0
omkit/settings.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""packages/omur-sdk/omkit/settings.py — Settings manager with in-memory cache, Valkey pub/sub subscriber, and callbacks.
|
|
2
|
+
|
|
3
|
+
exports: class SettingsManager
|
|
4
|
+
rules: The SettingsManager must maintain thread-safe cache access and ensure all database and Redis operations are properly synchronized to prevent race conditions during concurrent updates.
|
|
5
|
+
agent: ollama/qwen3-coder:latest | ollama | 2026-05-01 | codedna-cli | initial CodeDNA annotation pass
|
|
6
|
+
message:
|
|
7
|
+
"""
|
|
8
|
+
# packages/omur-sdk/omkit/settings.py
|
|
9
|
+
"""Settings manager with in-memory cache, Valkey pub/sub subscriber, and callbacks."""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
from typing import Any, Callable
|
|
16
|
+
|
|
17
|
+
import redis.asyncio as aioredis
|
|
18
|
+
|
|
19
|
+
from omkit.encryption import decrypt_value
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _backend_from_env() -> str:
|
|
25
|
+
v = os.getenv("SETTINGS_BACKEND", "postgres")
|
|
26
|
+
if v not in {"postgres", "redis"}:
|
|
27
|
+
raise ValueError(f"unknown SETTINGS_BACKEND: {v}")
|
|
28
|
+
return v
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SettingsManager:
|
|
32
|
+
"""Drop-in settings manager for services.
|
|
33
|
+
|
|
34
|
+
Loads all settings from DB on start and then keeps the in-memory cache
|
|
35
|
+
fresh via either a Postgres polling task (default) or a Valkey pub/sub
|
|
36
|
+
subscriber (opt-in via ``SETTINGS_BACKEND=redis``).
|
|
37
|
+
|
|
38
|
+
Backward-compatible construction keeps accepting the legacy
|
|
39
|
+
``(service_name, db_session_factory, valkey_url, tenant_id, ...)`` signature.
|
|
40
|
+
New callers can pass an asyncpg ``pool`` with ``poll_interval`` to use the
|
|
41
|
+
polling backend without a SQLAlchemy session factory.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
service_name: str = "omur",
|
|
47
|
+
db_session_factory=None,
|
|
48
|
+
valkey_url: str = "",
|
|
49
|
+
tenant_id: str = "",
|
|
50
|
+
encryption_key: str = "",
|
|
51
|
+
*,
|
|
52
|
+
pool=None,
|
|
53
|
+
poll_interval: float = 5.0,
|
|
54
|
+
):
|
|
55
|
+
self._service_name = service_name
|
|
56
|
+
self._db_factory = db_session_factory
|
|
57
|
+
self._valkey_url = valkey_url
|
|
58
|
+
self._tenant_id = tenant_id
|
|
59
|
+
self._encryption_key = encryption_key
|
|
60
|
+
self._cache: dict[str, Any] = {}
|
|
61
|
+
self._listeners: dict[str, list[Callable]] = {}
|
|
62
|
+
self._subscriber_task: asyncio.Task | None = None
|
|
63
|
+
self._cache_path = os.environ.get("SETTINGS_CACHE_PATH", "/tmp/settings-cache.json")
|
|
64
|
+
self._secret_keys: set[str] = set()
|
|
65
|
+
self._pool = pool
|
|
66
|
+
self._poll_interval = poll_interval
|
|
67
|
+
self._poll_last_seen = None
|
|
68
|
+
self._poll_task: asyncio.Task | None = None
|
|
69
|
+
self._stop: asyncio.Event | None = None
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def create(
|
|
73
|
+
cls,
|
|
74
|
+
service_name: str,
|
|
75
|
+
db_session_factory,
|
|
76
|
+
settings,
|
|
77
|
+
) -> "SettingsManager":
|
|
78
|
+
"""Factory that reads valkey_url, tenant_id, encryption_key from a BaseServiceSettings instance.
|
|
79
|
+
|
|
80
|
+
Rules: The `service_name` must be a valid string that uniquely identifies the service; `db_session_factory` must be a callable that returns a valid database session; `settings` must have a `valkey_url` attribute, and optionally an `omur_settings_key` for encryption.
|
|
81
|
+
"""
|
|
82
|
+
return cls(
|
|
83
|
+
service_name=service_name,
|
|
84
|
+
db_session_factory=db_session_factory,
|
|
85
|
+
valkey_url=settings.valkey_url,
|
|
86
|
+
tenant_id="",
|
|
87
|
+
encryption_key=getattr(settings, "omur_settings_key", ""),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
91
|
+
"""Read from in-memory cache. No I/O.
|
|
92
|
+
|
|
93
|
+
Rules: The function only retrieves values from an in-memory cache; it does not handle missing keys gracefully if the default is not provided, and assumes the cache has already been populated.
|
|
94
|
+
"""
|
|
95
|
+
return self._cache.get(key, default)
|
|
96
|
+
|
|
97
|
+
async def get_secret(self, key: str) -> str | None:
|
|
98
|
+
"""Decrypt and return a secret value. Reads from DB, not cache.
|
|
99
|
+
|
|
100
|
+
Rules: The function assumes that the database table `app_settings` exists with columns `value_json`, `is_secret`, and `key`; the `decrypt_value` function must be defined and properly handle decryption of the stored values.
|
|
101
|
+
"""
|
|
102
|
+
from sqlalchemy import text
|
|
103
|
+
|
|
104
|
+
async with self._db_factory() as session:
|
|
105
|
+
result = await session.execute(
|
|
106
|
+
text("SELECT value_json, is_secret FROM app_settings WHERE key = :key"),
|
|
107
|
+
{"key": key},
|
|
108
|
+
)
|
|
109
|
+
row = result.one_or_none()
|
|
110
|
+
if not row or not row.is_secret:
|
|
111
|
+
return None
|
|
112
|
+
try:
|
|
113
|
+
return decrypt_value(str(row.value_json), self._encryption_key)
|
|
114
|
+
except Exception:
|
|
115
|
+
logger.warning("Failed to decrypt secret %s", key)
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
def on_change(self, key: str, callback: Callable):
|
|
119
|
+
"""Register a callback for when a specific setting changes.
|
|
120
|
+
|
|
121
|
+
Rules: Callbacks registered via `on_change` are expected to be synchronous and should not block or raise exceptions, as they are invoked directly during setting updates.
|
|
122
|
+
"""
|
|
123
|
+
self._listeners.setdefault(key, []).append(callback)
|
|
124
|
+
|
|
125
|
+
async def start(self):
|
|
126
|
+
"""Load all settings from DB into cache, validate, and start the
|
|
127
|
+
|
|
128
|
+
Rules: The `start` method requires that either `_pool` is set (for asyncpg) or `_valkey_url` is provided (for Redis); if neither is present, it defaults to a PostgreSQL-based polling mechanism.
|
|
129
|
+
configured live-update worker."""
|
|
130
|
+
if self._stop is None:
|
|
131
|
+
self._stop = asyncio.Event()
|
|
132
|
+
if self._pool is not None:
|
|
133
|
+
# Asyncpg pool path — use polling by default.
|
|
134
|
+
await self._load_from_pool()
|
|
135
|
+
else:
|
|
136
|
+
await self._load_from_db()
|
|
137
|
+
self._validate()
|
|
138
|
+
|
|
139
|
+
backend = _backend_from_env() if self._pool is not None else ("redis" if self._valkey_url else "postgres")
|
|
140
|
+
if backend == "redis" and self._valkey_url:
|
|
141
|
+
self._subscriber_task = asyncio.create_task(self._subscribe())
|
|
142
|
+
elif self._pool is not None:
|
|
143
|
+
self._poll_task = asyncio.create_task(self._poll_loop())
|
|
144
|
+
logger.info(
|
|
145
|
+
"[%s] SettingsManager started, backend=%s, %d settings cached",
|
|
146
|
+
self._service_name, backend, len(self._cache),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def _validate(self):
|
|
150
|
+
"""Log warnings for settings with invalid or empty values that may cause runtime errors."""
|
|
151
|
+
for key, value in self._cache.items():
|
|
152
|
+
if value is None:
|
|
153
|
+
logger.warning("[%s] Setting '%s' is null", self._service_name, key)
|
|
154
|
+
elif isinstance(value, str) and value.strip() == "" and "api_key" not in key and "token" not in key:
|
|
155
|
+
logger.warning("[%s] Setting '%s' is empty", self._service_name, key)
|
|
156
|
+
elif isinstance(value, str):
|
|
157
|
+
# Check for common misconfigurations: JSON strings that should be parsed
|
|
158
|
+
stripped = value.strip()
|
|
159
|
+
if (stripped.startswith("{") and stripped.endswith("}")) or (stripped.startswith("[") and stripped.endswith("]")):
|
|
160
|
+
try:
|
|
161
|
+
json.loads(stripped)
|
|
162
|
+
except json.JSONDecodeError:
|
|
163
|
+
logger.warning("[%s] Setting '%s' looks like invalid JSON", self._service_name, key)
|
|
164
|
+
|
|
165
|
+
async def stop(self):
|
|
166
|
+
"""Stop any background live-update workers.
|
|
167
|
+
|
|
168
|
+
Rules: Calling `stop` without a prior call to `start` will result in no-op behavior, but it's important to ensure that `start` was called and that background tasks were initialized before attempting to stop them.
|
|
169
|
+
"""
|
|
170
|
+
if self._stop is None:
|
|
171
|
+
# start() was never called; nothing to stop.
|
|
172
|
+
return
|
|
173
|
+
self._stop.set()
|
|
174
|
+
if self._subscriber_task:
|
|
175
|
+
self._subscriber_task.cancel()
|
|
176
|
+
try:
|
|
177
|
+
await self._subscriber_task
|
|
178
|
+
except asyncio.CancelledError:
|
|
179
|
+
pass
|
|
180
|
+
if self._poll_task:
|
|
181
|
+
self._poll_task.cancel()
|
|
182
|
+
try:
|
|
183
|
+
await self._poll_task
|
|
184
|
+
except asyncio.CancelledError:
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
async def _load_from_pool(self):
|
|
188
|
+
"""Load all non-secret settings via the asyncpg pool."""
|
|
189
|
+
try:
|
|
190
|
+
async with self._pool.acquire() as conn:
|
|
191
|
+
rows = await conn.fetch(
|
|
192
|
+
"SELECT key, value_json, is_secret, updated_at FROM app_settings"
|
|
193
|
+
)
|
|
194
|
+
latest = self._poll_last_seen
|
|
195
|
+
for r in rows:
|
|
196
|
+
if latest is None or r["updated_at"] > latest:
|
|
197
|
+
latest = r["updated_at"]
|
|
198
|
+
if r["is_secret"]:
|
|
199
|
+
self._secret_keys.add(r["key"])
|
|
200
|
+
continue
|
|
201
|
+
value = r["value_json"]
|
|
202
|
+
if isinstance(value, (bytes, bytearray)):
|
|
203
|
+
value = value.decode()
|
|
204
|
+
if isinstance(value, str):
|
|
205
|
+
try:
|
|
206
|
+
value = json.loads(value)
|
|
207
|
+
except json.JSONDecodeError:
|
|
208
|
+
pass
|
|
209
|
+
self._cache[r["key"]] = value
|
|
210
|
+
if latest is not None:
|
|
211
|
+
self._poll_last_seen = latest
|
|
212
|
+
except Exception as e:
|
|
213
|
+
logger.warning("[%s] pool load failed: %s", self._service_name, e)
|
|
214
|
+
|
|
215
|
+
async def _poll_loop(self):
|
|
216
|
+
"""Background task: re-fetch rows with updated_at > last_seen."""
|
|
217
|
+
while not self._stop.is_set():
|
|
218
|
+
try:
|
|
219
|
+
await asyncio.wait_for(self._stop.wait(), timeout=self._poll_interval)
|
|
220
|
+
except asyncio.TimeoutError:
|
|
221
|
+
pass
|
|
222
|
+
if self._stop.is_set():
|
|
223
|
+
return
|
|
224
|
+
await self._poll_once()
|
|
225
|
+
|
|
226
|
+
async def _poll_once(self):
|
|
227
|
+
try:
|
|
228
|
+
async with self._pool.acquire() as conn:
|
|
229
|
+
rows = await conn.fetch(
|
|
230
|
+
"SELECT key, value_json, is_secret, updated_at FROM app_settings "
|
|
231
|
+
"WHERE updated_at > $1 ORDER BY updated_at ASC",
|
|
232
|
+
self._poll_last_seen,
|
|
233
|
+
)
|
|
234
|
+
latest = self._poll_last_seen
|
|
235
|
+
for r in rows:
|
|
236
|
+
if latest is None or r["updated_at"] > latest:
|
|
237
|
+
latest = r["updated_at"]
|
|
238
|
+
if r["is_secret"]:
|
|
239
|
+
continue
|
|
240
|
+
value = r["value_json"]
|
|
241
|
+
if isinstance(value, (bytes, bytearray)):
|
|
242
|
+
value = value.decode()
|
|
243
|
+
if isinstance(value, str):
|
|
244
|
+
try:
|
|
245
|
+
value = json.loads(value)
|
|
246
|
+
except json.JSONDecodeError:
|
|
247
|
+
pass
|
|
248
|
+
self._cache[r["key"]] = value
|
|
249
|
+
for callback in self._listeners.get(r["key"], []):
|
|
250
|
+
try:
|
|
251
|
+
if asyncio.iscoroutinefunction(callback):
|
|
252
|
+
await callback(value)
|
|
253
|
+
else:
|
|
254
|
+
callback(value)
|
|
255
|
+
except Exception as e:
|
|
256
|
+
logger.error("Settings callback error for %s: %s", r["key"], e)
|
|
257
|
+
if latest is not None:
|
|
258
|
+
self._poll_last_seen = latest
|
|
259
|
+
except Exception as e:
|
|
260
|
+
logger.warning("[%s] poll failed: %s", self._service_name, e)
|
|
261
|
+
|
|
262
|
+
def _write_cache(self):
|
|
263
|
+
"""Write non-secret settings to local cache file atomically."""
|
|
264
|
+
try:
|
|
265
|
+
secret_keys = getattr(self, "_secret_keys", set())
|
|
266
|
+
data = {k: v for k, v in self._cache.items() if k not in secret_keys}
|
|
267
|
+
tmp_path = self._cache_path + ".tmp"
|
|
268
|
+
with open(tmp_path, "w") as f:
|
|
269
|
+
json.dump(data, f)
|
|
270
|
+
os.chmod(tmp_path, 0o600)
|
|
271
|
+
os.replace(tmp_path, self._cache_path)
|
|
272
|
+
except Exception as e:
|
|
273
|
+
logger.warning("Failed to write settings cache: %s", e)
|
|
274
|
+
|
|
275
|
+
def _read_cache(self):
|
|
276
|
+
"""Read settings from local cache file (fallback when DB unavailable)."""
|
|
277
|
+
try:
|
|
278
|
+
with open(self._cache_path) as f:
|
|
279
|
+
self._cache.update(json.load(f))
|
|
280
|
+
logger.info("[%s] Loaded %d settings from cache", self._service_name, len(self._cache))
|
|
281
|
+
except FileNotFoundError:
|
|
282
|
+
pass
|
|
283
|
+
except Exception as e:
|
|
284
|
+
logger.warning("Failed to read settings cache: %s", e)
|
|
285
|
+
|
|
286
|
+
async def _load_from_db(self):
|
|
287
|
+
"""Load all non-secret settings into cache."""
|
|
288
|
+
from sqlalchemy import text
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
async with self._db_factory() as session:
|
|
292
|
+
result = await session.execute(
|
|
293
|
+
text("SELECT key, value_json, is_secret FROM app_settings"),
|
|
294
|
+
)
|
|
295
|
+
for row in result.all():
|
|
296
|
+
if row.is_secret:
|
|
297
|
+
self._secret_keys.add(row.key)
|
|
298
|
+
else:
|
|
299
|
+
self._cache[row.key] = row.value_json
|
|
300
|
+
self._write_cache()
|
|
301
|
+
except Exception as e:
|
|
302
|
+
logger.warning("[%s] DB load failed, trying cache: %s", self._service_name, e)
|
|
303
|
+
self._read_cache()
|
|
304
|
+
|
|
305
|
+
async def _subscribe(self):
|
|
306
|
+
"""Subscribe to Valkey pub/sub channel for setting changes."""
|
|
307
|
+
channel = f"omur:settings:{self._tenant_id}"
|
|
308
|
+
try:
|
|
309
|
+
client = aioredis.from_url(self._valkey_url, decode_responses=True)
|
|
310
|
+
pubsub = client.pubsub()
|
|
311
|
+
await pubsub.subscribe(channel)
|
|
312
|
+
logger.info("[%s] Subscribed to %s", self._service_name, channel)
|
|
313
|
+
|
|
314
|
+
async for message in pubsub.listen():
|
|
315
|
+
if message["type"] == "message":
|
|
316
|
+
await self._handle_message(message["data"])
|
|
317
|
+
except asyncio.CancelledError:
|
|
318
|
+
return
|
|
319
|
+
except Exception as e:
|
|
320
|
+
logger.error("[%s] Valkey subscriber error: %s", self._service_name, e)
|
|
321
|
+
await asyncio.sleep(5)
|
|
322
|
+
self._subscriber_task = asyncio.create_task(self._subscribe())
|
|
323
|
+
|
|
324
|
+
async def _handle_message(self, data: str):
|
|
325
|
+
"""Process a pub/sub message: update cache, fire callbacks."""
|
|
326
|
+
try:
|
|
327
|
+
payload = json.loads(data)
|
|
328
|
+
except json.JSONDecodeError:
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
key = payload.get("key")
|
|
332
|
+
if not key:
|
|
333
|
+
return
|
|
334
|
+
|
|
335
|
+
if "value" in payload:
|
|
336
|
+
self._cache[key] = payload["value"]
|
|
337
|
+
elif payload.get("changed"):
|
|
338
|
+
self._cache.pop(key, None)
|
|
339
|
+
self._write_cache()
|
|
340
|
+
|
|
341
|
+
for callback in self._listeners.get(key, []):
|
|
342
|
+
try:
|
|
343
|
+
if asyncio.iscoroutinefunction(callback):
|
|
344
|
+
await callback(payload.get("value"))
|
|
345
|
+
else:
|
|
346
|
+
callback(payload.get("value"))
|
|
347
|
+
except Exception as e:
|
|
348
|
+
logger.error("Settings callback error for %s: %s", key, e)
|
omkit/sync_notifier.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""packages/omur-sdk/omkit/sync_notifier.py — Fire-and-forget notifier for solid-sync Pod synchronization.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
notifier = SyncNotifier(base_url="http://solid-sync:8000", token="...")
|
|
5
|
+
await notifier.notify("medication", "med-001", {...data...})
|
|
6
|
+
|
|
7
|
+
Notifications are best-effort: failures are logged but never block the caller.
|
|
8
|
+
|
|
9
|
+
exports: class SyncNotifier
|
|
10
|
+
rules: The SyncNotifier module must maintain a persistent HTTP client connection for all notification operations and cannot be instantiated without a valid base_url and token. All notification methods are non-blocking and use asyncio task creation, requiring the event loop to be properly initialized. The module is designed for single-threaded use and does not support concurrent access patterns.
|
|
11
|
+
agent: ollama/qwen3-coder:latest | ollama | 2026-05-01 | codedna-cli | initial CodeDNA annotation pass
|
|
12
|
+
message:
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import structlog
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
log = structlog.get_logger()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SyncNotifier:
|
|
23
|
+
"""Sends async sync notifications to solid-sync service."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, base_url: str, token: str):
|
|
26
|
+
self._base_url = base_url.rstrip("/")
|
|
27
|
+
self._token = token
|
|
28
|
+
self._client: httpx.AsyncClient | None = None
|
|
29
|
+
|
|
30
|
+
def _get_client(self) -> httpx.AsyncClient:
|
|
31
|
+
if self._client is None or self._client.is_closed:
|
|
32
|
+
self._client = httpx.AsyncClient(timeout=10.0)
|
|
33
|
+
return self._client
|
|
34
|
+
|
|
35
|
+
async def notify(self, resource_type: str, resource_id: str, data: dict) -> None:
|
|
36
|
+
"""Fire-and-forget sync notification. Never raises.
|
|
37
|
+
|
|
38
|
+
Rules: resource_type and resource_id should not be None or empty strings, as they are used to identify resources in the sync system
|
|
39
|
+
"""
|
|
40
|
+
asyncio.create_task(self._send(resource_type, resource_id, data))
|
|
41
|
+
|
|
42
|
+
async def notify_delete(self, resource_type: str, resource_id: str) -> None:
|
|
43
|
+
"""Fire-and-forget delete notification. Never raises.
|
|
44
|
+
|
|
45
|
+
Rules: resource_type and resource_id should not be None or empty strings, as they are used to identify resources in the sync system
|
|
46
|
+
"""
|
|
47
|
+
asyncio.create_task(self._send_delete(resource_type, resource_id))
|
|
48
|
+
|
|
49
|
+
async def notify_metrics(self, metric_name: str, date: str, rows: list[dict]) -> None:
|
|
50
|
+
"""Fire-and-forget metrics sync notification. Never raises.
|
|
51
|
+
|
|
52
|
+
Rules: metric_name should not be None or empty string, date should be a valid date string, and rows should be a non-empty list of dictionaries
|
|
53
|
+
"""
|
|
54
|
+
asyncio.create_task(self._send_metrics(metric_name, date, rows))
|
|
55
|
+
|
|
56
|
+
async def _send(self, resource_type: str, resource_id: str, data: dict) -> None:
|
|
57
|
+
try:
|
|
58
|
+
client = self._get_client()
|
|
59
|
+
resp = await client.post(
|
|
60
|
+
f"{self._base_url}/sync/{resource_type}/{resource_id}",
|
|
61
|
+
json=data,
|
|
62
|
+
headers={"X-Service-Token": self._token},
|
|
63
|
+
)
|
|
64
|
+
if resp.status_code == 202:
|
|
65
|
+
log.debug("sync_notifier.queued", type=resource_type, id=resource_id)
|
|
66
|
+
else:
|
|
67
|
+
log.warning("sync_notifier.unexpected_status", type=resource_type,
|
|
68
|
+
id=resource_id, status=resp.status_code)
|
|
69
|
+
except Exception as e:
|
|
70
|
+
log.warning("sync_notifier.failed", type=resource_type,
|
|
71
|
+
id=resource_id, error=str(e))
|
|
72
|
+
|
|
73
|
+
async def _send_delete(self, resource_type: str, resource_id: str) -> None:
|
|
74
|
+
try:
|
|
75
|
+
client = self._get_client()
|
|
76
|
+
resp = await client.delete(
|
|
77
|
+
f"{self._base_url}/sync/{resource_type}/{resource_id}",
|
|
78
|
+
headers={"X-Service-Token": self._token},
|
|
79
|
+
)
|
|
80
|
+
if resp.status_code == 202:
|
|
81
|
+
log.debug("sync_notifier.delete_queued", type=resource_type, id=resource_id)
|
|
82
|
+
else:
|
|
83
|
+
log.warning("sync_notifier.delete_unexpected_status", type=resource_type,
|
|
84
|
+
id=resource_id, status=resp.status_code)
|
|
85
|
+
except Exception as e:
|
|
86
|
+
log.warning("sync_notifier.delete_failed", type=resource_type,
|
|
87
|
+
id=resource_id, error=str(e))
|
|
88
|
+
|
|
89
|
+
async def _send_metrics(self, metric_name: str, date: str, rows: list[dict]) -> None:
|
|
90
|
+
try:
|
|
91
|
+
client = self._get_client()
|
|
92
|
+
resp = await client.post(
|
|
93
|
+
f"{self._base_url}/sync/metrics",
|
|
94
|
+
json={"metric_name": metric_name, "date": date, "rows": rows},
|
|
95
|
+
headers={"X-Service-Token": self._token},
|
|
96
|
+
)
|
|
97
|
+
if resp.status_code == 202:
|
|
98
|
+
log.debug("sync_notifier.metrics_queued", metric=metric_name, date=date)
|
|
99
|
+
else:
|
|
100
|
+
log.warning("sync_notifier.metrics_unexpected_status",
|
|
101
|
+
metric=metric_name, status=resp.status_code)
|
|
102
|
+
except Exception as e:
|
|
103
|
+
log.warning("sync_notifier.metrics_failed", metric=metric_name, error=str(e))
|
|
104
|
+
|
|
105
|
+
async def close(self) -> None:
|
|
106
|
+
"""
|
|
107
|
+
Rules: close should only be called when the client has been initialized, otherwise it will raise an AttributeError
|
|
108
|
+
"""
|
|
109
|
+
if self._client and not self._client.is_closed:
|
|
110
|
+
await self._client.aclose()
|