memuron 0.1.1__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.
Files changed (74) hide show
  1. memuron/__init__.py +3 -0
  2. memuron/actions/__init__.py +12 -0
  3. memuron/actions/context.py +63 -0
  4. memuron/actions/helpers.py +88 -0
  5. memuron/actions/memory.py +340 -0
  6. memuron/actions/memory_write.py +290 -0
  7. memuron/actions/nodes.py +340 -0
  8. memuron/actions/registry.py +5 -0
  9. memuron/actions/runtime.py +37 -0
  10. memuron/actions/spaces_documents.py +720 -0
  11. memuron/actions/sync.py +155 -0
  12. memuron/application/__init__.py +1 -0
  13. memuron/application/api.py +206 -0
  14. memuron/application/app.py +103 -0
  15. memuron/application/capabilities.py +82 -0
  16. memuron/application/cli.py +35 -0
  17. memuron/application/config.py +176 -0
  18. memuron/application/mcp.py +44 -0
  19. memuron/application/mcp_oauth.py +290 -0
  20. memuron/application/registry.py +52 -0
  21. memuron/context.py +532 -0
  22. memuron/documents/__init__.py +1 -0
  23. memuron/documents/link_guardian.py +192 -0
  24. memuron/documents/linking.py +292 -0
  25. memuron/documents/parser.py +1152 -0
  26. memuron/documents/storage.py +151 -0
  27. memuron/documents/url_ingest.py +375 -0
  28. memuron/domain/__init__.py +1 -0
  29. memuron/domain/decoders.py +1 -0
  30. memuron/domain/encoders.py +185 -0
  31. memuron/domain/lifecycles.py +8 -0
  32. memuron/domain/limits.py +6 -0
  33. memuron/domain/representations.py +56 -0
  34. memuron/domain/schemas.py +581 -0
  35. memuron/domain/scope_filter.py +104 -0
  36. memuron/graphfs/__init__.py +1 -0
  37. memuron/graphfs/manual.py +635 -0
  38. memuron/graphfs/projection.py +578 -0
  39. memuron/graphfs/query.py +1782 -0
  40. memuron/graphfs/read_model.py +574 -0
  41. memuron/ingest/__init__.py +1 -0
  42. memuron/ingest/guardian.py +213 -0
  43. memuron/ingest/jobs.py +424 -0
  44. memuron/ingest/prompts.py +147 -0
  45. memuron/memory/__init__.py +1 -0
  46. memuron/memory/engine.py +35 -0
  47. memuron/memory/projections.py +452 -0
  48. memuron/memory/recipes.py +3247 -0
  49. memuron/persistence/__init__.py +1 -0
  50. memuron/persistence/db_pool.py +57 -0
  51. memuron/persistence/identity_store.py +918 -0
  52. memuron/persistence/store_helpers.py +16 -0
  53. memuron/search/__init__.py +1 -0
  54. memuron/search/fulltext.py +110 -0
  55. memuron/search/hybrid.py +284 -0
  56. memuron/search/pgvector.py +252 -0
  57. memuron/security/__init__.py +1 -0
  58. memuron/security/auth.py +143 -0
  59. memuron/security/auth_provider.py +119 -0
  60. memuron/security/authorization.py +53 -0
  61. memuron/security/clerk_scopes.py +94 -0
  62. memuron/security/clerk_webhooks.py +61 -0
  63. memuron/security/jwt_tokens.py +53 -0
  64. memuron/security/passwords.py +38 -0
  65. memuron/security/tenant.py +58 -0
  66. memuron/spaces/__init__.py +1 -0
  67. memuron/spaces/model.py +35 -0
  68. memuron/spaces/service.py +155 -0
  69. memuron/sync/__init__.py +25 -0
  70. memuron/sync/folder.py +828 -0
  71. memuron-0.1.1.dist-info/METADATA +242 -0
  72. memuron-0.1.1.dist-info/RECORD +74 -0
  73. memuron-0.1.1.dist-info/WHEEL +4 -0
  74. memuron-0.1.1.dist-info/entry_points.txt +4 -0
@@ -0,0 +1,213 @@
1
+ """OpenRouter Guardian for memory ingest writes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import re
9
+ from typing import Any
10
+
11
+ import requests
12
+
13
+ from memuron.application.config import settings
14
+ from memuron.ingest.prompts import MEMORY_WRITE_GUARDIAN_PROMPT, GuardianWritePlan
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ OPENROUTER_CHAT_URL = "https://openrouter.ai/api/v1/chat/completions"
19
+
20
+
21
+ class GuardianError(RuntimeError):
22
+ """Raised when the Guardian LLM call fails after retries."""
23
+
24
+
25
+ class AgnoGuardian:
26
+ """Mandatory LLM Guardian: one atomic write plan per ingest."""
27
+
28
+ def __init__(
29
+ self,
30
+ *,
31
+ api_key: str | None = None,
32
+ model_id: str | None = None,
33
+ max_retries: int | None = None,
34
+ max_tokens: int | None = None,
35
+ timeout_seconds: int | None = None,
36
+ ) -> None:
37
+ self.api_key = api_key or settings.openrouter_api_key
38
+ self.model_id = model_id or settings.guardian_model
39
+ self.max_retries = max_retries if max_retries is not None else settings.guardian_retries
40
+ self.max_tokens = max_tokens if max_tokens is not None else settings.guardian_max_tokens
41
+ self.timeout_seconds = (
42
+ timeout_seconds
43
+ if timeout_seconds is not None
44
+ else settings.guardian_timeout_seconds
45
+ )
46
+
47
+ @staticmethod
48
+ def _parse_write_plan_content(content: Any) -> GuardianWritePlan:
49
+ if isinstance(content, GuardianWritePlan):
50
+ return content
51
+ if isinstance(content, dict):
52
+ return GuardianWritePlan.model_validate(content)
53
+ if isinstance(content, str):
54
+ text = content.strip()
55
+ if not text:
56
+ raise GuardianError("Guardian returned empty content")
57
+ if text.startswith("```"):
58
+ text = re.sub(r"^```(?:json)?\s*", "", text)
59
+ text = re.sub(r"\s*```$", "", text)
60
+ payload = json.loads(text)
61
+ if not isinstance(payload, dict):
62
+ raise GuardianError(
63
+ f"Guardian JSON payload must be an object, got {type(payload)!r}"
64
+ )
65
+ return GuardianWritePlan.model_validate(payload)
66
+ raise GuardianError(f"Unexpected Guardian content type: {type(content)!r}")
67
+
68
+ @staticmethod
69
+ def _format_candidates(candidates: list[tuple[str, float, dict[str, Any]]]) -> str:
70
+ if not candidates:
71
+ return "(no candidates)"
72
+ lines: list[str] = []
73
+ for index, (memory_id, score, memory) in enumerate(candidates, start=1):
74
+ content = str(memory.get("content", ""))[:400]
75
+ scope = memory.get("scope") or []
76
+ node_type = memory.get("node_type", "text")
77
+ lines.append(
78
+ f"\n--- Candidate {index} ---\n"
79
+ f"ID: {memory_id}\n"
80
+ f"Node type: {node_type}\n"
81
+ f"Similarity Score: {score:.3f}\n"
82
+ f"Content: {content}\n"
83
+ f"Scope: {scope}\n"
84
+ )
85
+ return "".join(lines)
86
+
87
+ def _build_prompt(
88
+ self,
89
+ new_content: str,
90
+ candidates: list[tuple[str, float, dict[str, Any]]],
91
+ *,
92
+ space_context: dict[str, str] | None = None,
93
+ ) -> str:
94
+ from memuron.ingest.prompts import SPACE_GUARDIAN_SECTION
95
+
96
+ prompt = MEMORY_WRITE_GUARDIAN_PROMPT.format(
97
+ new_content=new_content,
98
+ candidates_text=self._format_candidates(candidates),
99
+ )
100
+ if space_context:
101
+ prompt = (
102
+ prompt
103
+ + "\n"
104
+ + SPACE_GUARDIAN_SECTION.format(
105
+ active_space_name=space_context["active_space_name"],
106
+ active_space_token=space_context["active_space_token"],
107
+ active_space_prompt=space_context["active_space_prompt"],
108
+ other_spaces_summary=space_context["other_spaces_summary"],
109
+ registered_tokens=space_context["registered_tokens"],
110
+ all_spaces_detail=space_context.get("all_spaces_detail", "(none)"),
111
+ space_mode=space_context.get("space_mode", "assist"),
112
+ )
113
+ )
114
+ return prompt
115
+
116
+ def _call_guardian(self, prompt: str) -> GuardianWritePlan:
117
+ if not self.api_key:
118
+ raise GuardianError("OPENROUTER_API_KEY is required for Guardian writes")
119
+ payload = {
120
+ "model": self.model_id,
121
+ "messages": [{"role": "user", "content": prompt}],
122
+ "response_format": {"type": "json_object"},
123
+ "max_tokens": self.max_tokens,
124
+ }
125
+ try:
126
+ response = requests.post(
127
+ OPENROUTER_CHAT_URL,
128
+ headers={
129
+ "Authorization": f"Bearer {self.api_key}",
130
+ "Content-Type": "application/json",
131
+ },
132
+ json=payload,
133
+ timeout=self.timeout_seconds,
134
+ )
135
+ except requests.RequestException as exc:
136
+ raise GuardianError(f"Guardian request failed: {exc}") from exc
137
+ if response.status_code >= 400:
138
+ raise GuardianError(
139
+ f"Guardian HTTP {response.status_code}: {response.text[:500]}"
140
+ )
141
+ try:
142
+ body = response.json()
143
+ choice = body["choices"][0]
144
+ message = choice["message"]
145
+ raw_content = message.get("content")
146
+ finish_reason = choice.get("finish_reason")
147
+ except (KeyError, IndexError, TypeError, json.JSONDecodeError) as exc:
148
+ raise GuardianError(f"Guardian response parse error: {exc}") from exc
149
+ if finish_reason == "length":
150
+ raise GuardianError(
151
+ "Guardian response truncated at max_tokens="
152
+ f"{self.max_tokens}; increase MEMURON_GUARDIAN_MAX_TOKENS"
153
+ )
154
+ if not isinstance(raw_content, str) or not raw_content.strip():
155
+ raise GuardianError("Guardian returned empty content")
156
+ try:
157
+ return self._parse_write_plan_content(raw_content)
158
+ except json.JSONDecodeError as exc:
159
+ raise GuardianError(f"Guardian response JSON parse error: {exc}") from exc
160
+
161
+ async def _call_guardian_async(
162
+ self,
163
+ new_content: str,
164
+ candidates: list[tuple[str, float, dict[str, Any]]],
165
+ *,
166
+ space_context: dict[str, str] | None = None,
167
+ ) -> GuardianWritePlan:
168
+ prompt = self._build_prompt(
169
+ new_content,
170
+ candidates,
171
+ space_context=space_context,
172
+ )
173
+ return await asyncio.to_thread(self._call_guardian, prompt)
174
+
175
+ async def plan_write(
176
+ self,
177
+ new_content: str,
178
+ candidates: list[tuple[str, float, dict[str, Any]]],
179
+ *,
180
+ space_context: dict[str, str] | None = None,
181
+ ) -> GuardianWritePlan:
182
+ last_error: Exception | None = None
183
+ for attempt in range(1, self.max_retries + 1):
184
+ try:
185
+ plan = await self._call_guardian_async(
186
+ new_content,
187
+ candidates,
188
+ space_context=space_context,
189
+ )
190
+ action = plan.action
191
+ if action == "update":
192
+ if not plan.target_memory_id:
193
+ raise GuardianError("Guardian update plan missing target_memory_id")
194
+ else:
195
+ plan = plan.model_copy(update={"target_memory_id": None})
196
+ return plan
197
+ except Exception as exc:
198
+ last_error = exc
199
+ logger.warning(
200
+ "Guardian attempt %s/%s failed: %s",
201
+ attempt,
202
+ self.max_retries,
203
+ exc,
204
+ )
205
+ if attempt < self.max_retries:
206
+ await asyncio.sleep(2 ** (attempt - 1))
207
+ raise GuardianError(
208
+ f"Guardian failed after {self.max_retries} attempts: {last_error}"
209
+ ) from last_error
210
+
211
+
212
+ def create_guardian() -> AgnoGuardian:
213
+ return AgnoGuardian()
memuron/ingest/jobs.py ADDED
@@ -0,0 +1,424 @@
1
+ """Durable async ingest job queue backed by PostgreSQL."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import os
9
+ from contextlib import contextmanager
10
+ from datetime import UTC, datetime, timedelta
11
+ from typing import Any, Iterator
12
+ from uuid import uuid4
13
+
14
+ import psycopg
15
+ from psycopg.rows import dict_row
16
+
17
+ from memuron.application.config import settings
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ JOB_TABLE = "memuron_ingest_jobs"
22
+
23
+
24
+ def _utcnow() -> datetime:
25
+ return datetime.now(UTC).replace(tzinfo=None)
26
+
27
+
28
+ def _serialize_job(row: dict[str, Any]) -> dict[str, Any]:
29
+ return {
30
+ "id": str(row["id"]),
31
+ "status": row["status"],
32
+ "payload_json": row["payload_json"],
33
+ "result_json": row.get("result_json"),
34
+ "error_json": row.get("error_json"),
35
+ "attempt_count": int(row["attempt_count"]),
36
+ "max_attempts": int(row["max_attempts"]),
37
+ "retryable": bool(row["retryable"]),
38
+ "available_at": row["available_at"].isoformat() if row["available_at"] else None,
39
+ "locked_at": row["locked_at"].isoformat() if row.get("locked_at") else None,
40
+ "lease_expires_at": row["lease_expires_at"].isoformat()
41
+ if row.get("lease_expires_at")
42
+ else None,
43
+ "started_at": row["started_at"].isoformat() if row.get("started_at") else None,
44
+ "completed_at": row["completed_at"].isoformat() if row.get("completed_at") else None,
45
+ "created_at": row["created_at"].isoformat() if row["created_at"] else None,
46
+ "updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
47
+ }
48
+
49
+
50
+ class IngestJobStore:
51
+ def __init__(self, database_url: str) -> None:
52
+ if not database_url:
53
+ raise ValueError("ARTHA_DATABASE_URL is required for ingest jobs")
54
+ self.database_url = database_url
55
+ self._init_schema()
56
+
57
+ @contextmanager
58
+ def _connect(self) -> Iterator[psycopg.Connection[Any]]:
59
+ conn = psycopg.connect(self.database_url, row_factory=dict_row)
60
+ try:
61
+ yield conn
62
+ finally:
63
+ conn.close()
64
+
65
+ def _init_schema(self) -> None:
66
+ with self._connect() as conn:
67
+ conn.execute(
68
+ f"""
69
+ CREATE TABLE IF NOT EXISTS {JOB_TABLE} (
70
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
71
+ status TEXT NOT NULL DEFAULT 'queued',
72
+ payload_json JSONB NOT NULL,
73
+ result_json JSONB,
74
+ error_json JSONB,
75
+ attempt_count INT NOT NULL DEFAULT 0,
76
+ max_attempts INT NOT NULL DEFAULT 3,
77
+ retryable BOOLEAN NOT NULL DEFAULT TRUE,
78
+ available_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
79
+ locked_at TIMESTAMPTZ,
80
+ lease_expires_at TIMESTAMPTZ,
81
+ started_at TIMESTAMPTZ,
82
+ completed_at TIMESTAMPTZ,
83
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
84
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
85
+ )
86
+ """
87
+ )
88
+ conn.execute(
89
+ f"""
90
+ CREATE INDEX IF NOT EXISTS idx_memuron_ingest_jobs_claim
91
+ ON {JOB_TABLE} (status, available_at, lease_expires_at)
92
+ """
93
+ )
94
+ conn.commit()
95
+
96
+ def create_job(self, payload: dict[str, Any], *, max_attempts: int | None = None) -> dict[str, Any]:
97
+ job_id = str(uuid4())
98
+ now = _utcnow()
99
+ with self._connect() as conn:
100
+ row = conn.execute(
101
+ f"""
102
+ INSERT INTO {JOB_TABLE} (
103
+ id, status, payload_json, max_attempts, available_at, created_at, updated_at
104
+ ) VALUES (%s, 'queued', %s::jsonb, %s, %s, %s, %s)
105
+ RETURNING *
106
+ """,
107
+ (
108
+ job_id,
109
+ json.dumps(payload),
110
+ max_attempts or settings.ingest_job_max_attempts,
111
+ now,
112
+ now,
113
+ now,
114
+ ),
115
+ ).fetchone()
116
+ conn.commit()
117
+ return _serialize_job(row)
118
+
119
+ def get_job(self, job_id: str) -> dict[str, Any] | None:
120
+ with self._connect() as conn:
121
+ row = conn.execute(
122
+ f"SELECT * FROM {JOB_TABLE} WHERE id = %s",
123
+ (job_id,),
124
+ ).fetchone()
125
+ return _serialize_job(row) if row else None
126
+
127
+ def claim_next_job(self, lease_seconds: int) -> dict[str, Any] | None:
128
+ now = _utcnow()
129
+ with self._connect() as conn:
130
+ row = conn.execute(
131
+ f"""
132
+ SELECT * FROM {JOB_TABLE}
133
+ WHERE (
134
+ status = 'queued' AND available_at <= %s
135
+ ) OR (
136
+ status = 'processing'
137
+ AND lease_expires_at IS NOT NULL
138
+ AND lease_expires_at <= %s
139
+ )
140
+ ORDER BY created_at ASC
141
+ FOR UPDATE SKIP LOCKED
142
+ LIMIT 1
143
+ """,
144
+ (now, now),
145
+ ).fetchone()
146
+ if not row:
147
+ conn.rollback()
148
+ return None
149
+ lease_expires = now + timedelta(seconds=lease_seconds)
150
+ updated = conn.execute(
151
+ f"""
152
+ UPDATE {JOB_TABLE}
153
+ SET status = 'processing',
154
+ attempt_count = attempt_count + 1,
155
+ locked_at = %s,
156
+ lease_expires_at = %s,
157
+ started_at = COALESCE(started_at, %s),
158
+ updated_at = %s
159
+ WHERE id = %s
160
+ RETURNING *
161
+ """,
162
+ (now, lease_expires, now, now, row["id"]),
163
+ ).fetchone()
164
+ conn.commit()
165
+ return _serialize_job(updated) if updated else None
166
+
167
+ def complete_job(self, job_id: str, result_json: dict[str, Any]) -> dict[str, Any]:
168
+ now = _utcnow()
169
+ with self._connect() as conn:
170
+ row = conn.execute(
171
+ f"""
172
+ UPDATE {JOB_TABLE}
173
+ SET status = 'completed',
174
+ result_json = %s::jsonb,
175
+ error_json = NULL,
176
+ completed_at = %s,
177
+ locked_at = NULL,
178
+ lease_expires_at = NULL,
179
+ updated_at = %s
180
+ WHERE id = %s
181
+ RETURNING *
182
+ """,
183
+ (json.dumps(result_json), now, now, job_id),
184
+ ).fetchone()
185
+ conn.commit()
186
+ if row is None:
187
+ raise KeyError(f"Ingest job not found: {job_id}")
188
+ return _serialize_job(row)
189
+
190
+ def fail_job(
191
+ self,
192
+ job_id: str,
193
+ error_json: dict[str, Any],
194
+ *,
195
+ retryable: bool = True,
196
+ backoff_seconds: int | None = None,
197
+ ) -> dict[str, Any]:
198
+ now = _utcnow()
199
+ with self._connect() as conn:
200
+ row = conn.execute(
201
+ f"SELECT * FROM {JOB_TABLE} WHERE id = %s",
202
+ (job_id,),
203
+ ).fetchone()
204
+ if row is None:
205
+ raise KeyError(f"Ingest job not found: {job_id}")
206
+ should_retry = retryable and int(row["attempt_count"]) < int(row["max_attempts"])
207
+ if should_retry:
208
+ delay = backoff_seconds if backoff_seconds is not None else 2 ** max(
209
+ int(row["attempt_count"]) - 1, 0
210
+ )
211
+ status = "queued"
212
+ available_at = now + timedelta(seconds=delay)
213
+ completed_at = None
214
+ else:
215
+ status = "failed"
216
+ available_at = row["available_at"]
217
+ completed_at = now
218
+ updated = conn.execute(
219
+ f"""
220
+ UPDATE {JOB_TABLE}
221
+ SET status = %s,
222
+ error_json = %s::jsonb,
223
+ retryable = %s,
224
+ available_at = %s,
225
+ locked_at = NULL,
226
+ lease_expires_at = NULL,
227
+ completed_at = %s,
228
+ updated_at = %s
229
+ WHERE id = %s
230
+ RETURNING *
231
+ """,
232
+ (
233
+ status,
234
+ json.dumps(error_json),
235
+ retryable,
236
+ available_at,
237
+ completed_at,
238
+ now,
239
+ job_id,
240
+ ),
241
+ ).fetchone()
242
+ conn.commit()
243
+ return _serialize_job(updated)
244
+
245
+
246
+ def build_job_payload(
247
+ content: str,
248
+ scope: list[str] | None,
249
+ *,
250
+ metadata: dict[str, Any] | None = None,
251
+ event_metadata: dict[str, Any] | None = None,
252
+ space_context: dict[str, str] | None = None,
253
+ candidate_scope: list[str] | None = None,
254
+ ) -> dict[str, Any]:
255
+ payload: dict[str, Any] = {
256
+ "content": content,
257
+ "scope": scope or [],
258
+ "metadata": metadata or {},
259
+ "event_metadata": event_metadata or {},
260
+ "candidate_scope": candidate_scope or [],
261
+ }
262
+ if space_context:
263
+ payload["space_context"] = space_context
264
+ return payload
265
+
266
+
267
+ def _payload_space_context(payload: dict[str, Any]) -> dict[str, str] | None:
268
+ space_context = payload.get("space_context")
269
+ if not isinstance(space_context, dict):
270
+ return None
271
+ if not space_context.get("active_space_token"):
272
+ return None
273
+ return space_context
274
+
275
+
276
+ async def _run_ingest_job(job: dict[str, Any], engine: Any, guardian: Any) -> None:
277
+ from memuron.ingest.guardian import GuardianError
278
+ from memuron.memory.recipes import ingest_memory, update_memory
279
+ from memuron.spaces.service import apply_guardian_space_scope
280
+ from memuron.security.tenant import normalize_tenant_scope
281
+
282
+ store = job["_store"] # type: ignore[index]
283
+ payload = job["payload_json"] or {}
284
+ event_metadata = payload.get("event_metadata") or {}
285
+ space_context = _payload_space_context(payload)
286
+ if space_context is None:
287
+ backup = event_metadata.get("space_context")
288
+ if isinstance(backup, dict):
289
+ space_context = _payload_space_context({"space_context": backup})
290
+ tenant_id = event_metadata.get("tenant_id")
291
+ if not tenant_id:
292
+ for token in payload.get("scope") or []:
293
+ if str(token).startswith("org:"):
294
+ tenant_id = str(token)[4:]
295
+ break
296
+ try:
297
+ result = await ingest_memory(
298
+ engine,
299
+ guardian,
300
+ content=payload["content"],
301
+ scope=payload.get("scope"),
302
+ metadata=payload.get("metadata") or None,
303
+ event_metadata=event_metadata,
304
+ space_context=space_context,
305
+ candidate_scope=payload.get("candidate_scope") or None,
306
+ )
307
+ if result.get("memory_id"):
308
+ memory = result.get("memory") or {}
309
+ current_scope = list(memory.get("scope") or [])
310
+ fixed_scope = normalize_tenant_scope(
311
+ current_scope,
312
+ str(tenant_id) if tenant_id else None,
313
+ )
314
+ if space_context:
315
+ fixed_scope = apply_guardian_space_scope(
316
+ fixed_scope,
317
+ space_context=space_context,
318
+ )
319
+ if fixed_scope != current_scope:
320
+ patched = update_memory(
321
+ engine,
322
+ str(result["memory_id"]),
323
+ scope=fixed_scope,
324
+ event_metadata=event_metadata,
325
+ )
326
+ result["memory"] = patched
327
+ await asyncio.to_thread(store.complete_job, job["id"], result)
328
+ except GuardianError as exc:
329
+ await asyncio.to_thread(
330
+ store.fail_job,
331
+ job["id"],
332
+ {"code": "GUARDIAN_FAILED", "message": str(exc), "retryable": True},
333
+ retryable=True,
334
+ )
335
+ except Exception as exc:
336
+ logger.exception("Ingest job %s failed", job["id"])
337
+ await asyncio.to_thread(
338
+ store.fail_job,
339
+ job["id"],
340
+ {"code": "INGEST_JOB_FAILED", "message": str(exc), "retryable": True},
341
+ retryable=True,
342
+ )
343
+
344
+
345
+ async def _ingest_worker_loop(
346
+ worker_name: str,
347
+ stop_event: asyncio.Event,
348
+ *,
349
+ engine: Any,
350
+ guardian: Any,
351
+ job_store: IngestJobStore,
352
+ ) -> None:
353
+ while not stop_event.is_set():
354
+ try:
355
+ job = await asyncio.to_thread(
356
+ job_store.claim_next_job,
357
+ settings.ingest_job_lease_seconds,
358
+ )
359
+ if not job:
360
+ try:
361
+ await asyncio.wait_for(
362
+ stop_event.wait(),
363
+ timeout=settings.ingest_job_poll_interval_seconds,
364
+ )
365
+ except TimeoutError:
366
+ pass
367
+ continue
368
+ logger.info("Worker %s processing ingest job %s", worker_name, job["id"])
369
+ job["_store"] = job_store
370
+ await _run_ingest_job(job, engine, guardian)
371
+ except asyncio.CancelledError:
372
+ raise
373
+ except Exception:
374
+ logger.exception("Worker %s hit an unexpected ingest loop error", worker_name)
375
+ await asyncio.sleep(settings.ingest_job_poll_interval_seconds)
376
+
377
+
378
+ async def start_ingest_workers(app: Any, engine: Any, guardian: Any) -> None:
379
+ worker_count = int(
380
+ os.environ.get("MEMURON_INGEST_WORKER_COUNT", str(settings.ingest_worker_count))
381
+ )
382
+ if worker_count <= 0:
383
+ app.state.ingest_worker_tasks = []
384
+ app.state.ingest_worker_stop_event = None
385
+ return
386
+ if not settings.database_url.startswith("postgresql"):
387
+ logger.info("Skipping ingest workers because database is not PostgreSQL")
388
+ app.state.ingest_worker_tasks = []
389
+ app.state.ingest_worker_stop_event = None
390
+ return
391
+
392
+ job_store = getattr(app.state, "ingest_job_store", None)
393
+ if job_store is None:
394
+ job_store = IngestJobStore(settings.database_url)
395
+ app.state.ingest_job_store = job_store
396
+ stop_event = asyncio.Event()
397
+ tasks = [
398
+ asyncio.create_task(
399
+ _ingest_worker_loop(
400
+ f"ingest-{index + 1}",
401
+ stop_event,
402
+ engine=engine,
403
+ guardian=guardian,
404
+ job_store=job_store,
405
+ )
406
+ )
407
+ for index in range(worker_count)
408
+ ]
409
+ app.state.ingest_job_store = job_store
410
+ app.state.ingest_worker_stop_event = stop_event
411
+ app.state.ingest_worker_tasks = tasks
412
+ logger.info("Started %s ingest worker(s)", len(tasks))
413
+
414
+
415
+ async def stop_ingest_workers(app: Any) -> None:
416
+ stop_event = getattr(app.state, "ingest_worker_stop_event", None)
417
+ tasks = getattr(app.state, "ingest_worker_tasks", [])
418
+ if stop_event:
419
+ stop_event.set()
420
+ if tasks:
421
+ for task in tasks:
422
+ task.cancel()
423
+ await asyncio.gather(*tasks, return_exceptions=True)
424
+ logger.info("Stopped %s ingest worker(s)", len(tasks))