lorebase-scribe 0.2.3__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.
- lorebase_scribe/__init__.py +3 -0
- lorebase_scribe/__main__.py +156 -0
- lorebase_scribe/checkpoints.py +324 -0
- lorebase_scribe/cron.py +117 -0
- lorebase_scribe/embeddings.py +570 -0
- lorebase_scribe/extraction/__init__.py +17 -0
- lorebase_scribe/extraction/assemble.py +339 -0
- lorebase_scribe/extraction/candidates.py +136 -0
- lorebase_scribe/extraction/discovery.py +567 -0
- lorebase_scribe/extraction/llm_discovery.py +271 -0
- lorebase_scribe/extraction/matching.py +285 -0
- lorebase_scribe/extraction/ontology.py +432 -0
- lorebase_scribe/extraction/parsing.py +114 -0
- lorebase_scribe/extraction/pipeline.py +1091 -0
- lorebase_scribe/extraction/relations.py +194 -0
- lorebase_scribe/extraction/sandbox.py +269 -0
- lorebase_scribe/extraction/settings.py +293 -0
- lorebase_scribe/extraction/text.py +179 -0
- lorebase_scribe/extraction/tier2.py +573 -0
- lorebase_scribe/extraction/values.py +282 -0
- lorebase_scribe/jobs.py +418 -0
- lorebase_scribe/scheduler.py +318 -0
- lorebase_scribe/service.py +146 -0
- lorebase_scribe/settings.py +521 -0
- lorebase_scribe/sources/__init__.py +60 -0
- lorebase_scribe/sources/access.py +241 -0
- lorebase_scribe/sources/connectors.py +320 -0
- lorebase_scribe/sources/delta.py +175 -0
- lorebase_scribe/sources/http_json.py +283 -0
- lorebase_scribe/sources/mapping.py +429 -0
- lorebase_scribe/sources/profile.py +36 -0
- lorebase_scribe/sources/records.py +298 -0
- lorebase_scribe/sources/sql.py +332 -0
- lorebase_scribe/sources/sync.py +730 -0
- lorebase_scribe/telemetry.py +113 -0
- lorebase_scribe/webhooks.py +99 -0
- lorebase_scribe/worker.py +254 -0
- lorebase_scribe-0.2.3.dist-info/METADATA +23 -0
- lorebase_scribe-0.2.3.dist-info/RECORD +41 -0
- lorebase_scribe-0.2.3.dist-info/WHEEL +4 -0
- lorebase_scribe-0.2.3.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Periodic jobs with leader election (design 9).
|
|
2
|
+
|
|
3
|
+
Every Scribe process may run a scheduler; the one holding a session-level Postgres advisory
|
|
4
|
+
lock (key derived from `scribe.scheduler.lock_name`, held on a dedicated connection) is the
|
|
5
|
+
leader, so replicas never queue the same periodic job twice. A leader whose connection dies
|
|
6
|
+
loses the lock with it and another process takes over at its next tick.
|
|
7
|
+
|
|
8
|
+
Each tick of the leader:
|
|
9
|
+
|
|
10
|
+
- reclaims running jobs without heartbeat for `store.jobs.stale_after_seconds` (requeued, or
|
|
11
|
+
failed once their attempts are used up);
|
|
12
|
+
- queues a `webhook_delivery` pass every `webhook_interval_s` (an identical queued job is
|
|
13
|
+
reused, so a backlog never piles up);
|
|
14
|
+
- queues an `audit_verify` pass every `audit_verify_interval_s` (reused like the webhook pass);
|
|
15
|
+
- queues an `audit_checkpoint` job every `scribe.audit_checkpoints.interval_s` when
|
|
16
|
+
checkpoints are enabled (reused like the webhook pass);
|
|
17
|
+
- queues a `gc` job per Domain at each firing of `gc_schedule`, counted by `lorebase.gc.scheduled`
|
|
18
|
+
with its `dry_run` mode;
|
|
19
|
+
- queues an `embeddings` pass per Domain every `embeddings_interval_s` (an identical queued
|
|
20
|
+
pass is reused);
|
|
21
|
+
- queues a `source_sync` job for each Source whose `schedule` fired, read from the schema of
|
|
22
|
+
the Domain's default branch;
|
|
23
|
+
- counts the claimable, retrying and running jobs for the `lorebase.jobs.queue_depth` gauge
|
|
24
|
+
(reported by the leader only).
|
|
25
|
+
|
|
26
|
+
A cron job is due when its latest firing time is later than the `run_after` of the newest job
|
|
27
|
+
it queued before (jobs are queued with `run_after` set to the firing time) or, with no such
|
|
28
|
+
job, later than the moment this process became leader. Missed firings are not replayed: one
|
|
29
|
+
job covers them.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import asyncio
|
|
35
|
+
import contextlib
|
|
36
|
+
import hashlib
|
|
37
|
+
import logging
|
|
38
|
+
from collections.abc import Callable
|
|
39
|
+
from datetime import UTC, datetime
|
|
40
|
+
|
|
41
|
+
from sqlalchemy import text
|
|
42
|
+
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, async_sessionmaker
|
|
43
|
+
|
|
44
|
+
from lorebase_core.model import ElementStatus
|
|
45
|
+
from lorebase_scribe.cron import CronError, parse_cron
|
|
46
|
+
from lorebase_scribe.jobs import AUDIT_CHECKPOINT_JOB, AUDIT_VERIFY_JOB, WEBHOOK_DELIVERY_JOB
|
|
47
|
+
from lorebase_scribe.settings import SchedulerSettings
|
|
48
|
+
from lorebase_scribe.telemetry import GC_SCHEDULED, QUEUE_DEPTH, SCHEDULED, tracer
|
|
49
|
+
from lorebase_store.auth.scope import rls_scope
|
|
50
|
+
from lorebase_store.commit.schema import load_compiled
|
|
51
|
+
from lorebase_store.jobs import (
|
|
52
|
+
EMBEDDINGS_JOB,
|
|
53
|
+
GC_JOB,
|
|
54
|
+
SOURCE_SYNC_JOB,
|
|
55
|
+
embeddings_payload,
|
|
56
|
+
enqueue_job,
|
|
57
|
+
queue_depth,
|
|
58
|
+
requeue_stale,
|
|
59
|
+
source_sync_payload,
|
|
60
|
+
)
|
|
61
|
+
from lorebase_store.repositories import DomainRepository, MaterializedRepository, scope_to_domain
|
|
62
|
+
from lorebase_store.settings import JobSettings, WriteSettings
|
|
63
|
+
|
|
64
|
+
log = logging.getLogger("lorebase.scribe.scheduler")
|
|
65
|
+
|
|
66
|
+
_LAST_FIRE = text(
|
|
67
|
+
"""
|
|
68
|
+
SELECT max(run_after) FROM jobs
|
|
69
|
+
WHERE type = :type AND domain_id IS NOT DISTINCT FROM CAST(:domain AS text)
|
|
70
|
+
AND (CAST(:source AS text) IS NULL OR payload ->> 'source' = CAST(:source AS text))
|
|
71
|
+
AND coalesce(payload ->> 'dry_run', 'false') = :dry_run
|
|
72
|
+
"""
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def lock_key(name: str) -> int:
|
|
77
|
+
"""Signed 64-bit advisory lock key of a name."""
|
|
78
|
+
return int.from_bytes(hashlib.blake2b(name.encode(), digest_size=8).digest(), signed=True)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def utc_now() -> datetime:
|
|
82
|
+
return datetime.now(UTC)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Scheduler:
|
|
86
|
+
"""Leader election and periodic job queueing."""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
engine: AsyncEngine,
|
|
91
|
+
sessions: async_sessionmaker[AsyncSession],
|
|
92
|
+
*,
|
|
93
|
+
settings: SchedulerSettings,
|
|
94
|
+
jobs: JobSettings,
|
|
95
|
+
writes: WriteSettings,
|
|
96
|
+
clock: Callable[[], datetime] = utc_now,
|
|
97
|
+
checkpoint_interval_s: float = 0.0,
|
|
98
|
+
) -> None:
|
|
99
|
+
self._engine = engine
|
|
100
|
+
self._checkpoint_interval_s = checkpoint_interval_s
|
|
101
|
+
self._sessions = sessions
|
|
102
|
+
self._settings = settings
|
|
103
|
+
self._jobs = jobs
|
|
104
|
+
self._writes = writes
|
|
105
|
+
self._clock = clock
|
|
106
|
+
self._key = lock_key(settings.lock_name)
|
|
107
|
+
self._connection: AsyncConnection | None = None
|
|
108
|
+
self._leader_since: datetime | None = None
|
|
109
|
+
self._last: dict[str, datetime] = {}
|
|
110
|
+
self._gc_queued = 0
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def is_leader(self) -> bool:
|
|
114
|
+
return self._connection is not None
|
|
115
|
+
|
|
116
|
+
async def acquire(self) -> bool:
|
|
117
|
+
"""Become (or stay) the leader; false when another process holds the lock."""
|
|
118
|
+
if self._connection is not None:
|
|
119
|
+
try:
|
|
120
|
+
await self._connection.execute(text("SELECT 1"))
|
|
121
|
+
return True
|
|
122
|
+
except Exception:
|
|
123
|
+
log.warning("scheduler lock connection lost")
|
|
124
|
+
await self._drop()
|
|
125
|
+
connection = await self._engine.connect()
|
|
126
|
+
try:
|
|
127
|
+
auto = await connection.execution_options(isolation_level="AUTOCOMMIT")
|
|
128
|
+
acquired = (
|
|
129
|
+
await auto.execute(text("SELECT pg_try_advisory_lock(:key)"), {"key": self._key})
|
|
130
|
+
).scalar_one()
|
|
131
|
+
except Exception:
|
|
132
|
+
await connection.close()
|
|
133
|
+
raise
|
|
134
|
+
if not acquired:
|
|
135
|
+
await connection.close()
|
|
136
|
+
return False
|
|
137
|
+
self._connection = auto
|
|
138
|
+
self._leader_since = self._clock()
|
|
139
|
+
self._last.clear()
|
|
140
|
+
log.info("scheduler leadership acquired", extra={"lock": self._settings.lock_name})
|
|
141
|
+
return True
|
|
142
|
+
|
|
143
|
+
async def _drop(self) -> None:
|
|
144
|
+
connection, self._connection = self._connection, None
|
|
145
|
+
self._leader_since = None
|
|
146
|
+
QUEUE_DEPTH.clear()
|
|
147
|
+
if connection is not None:
|
|
148
|
+
with contextlib.suppress(Exception):
|
|
149
|
+
await connection.close()
|
|
150
|
+
|
|
151
|
+
async def release(self) -> None:
|
|
152
|
+
"""Give up leadership."""
|
|
153
|
+
if self._connection is not None:
|
|
154
|
+
with contextlib.suppress(Exception):
|
|
155
|
+
await self._connection.execute(
|
|
156
|
+
text("SELECT pg_advisory_unlock(:key)"), {"key": self._key}
|
|
157
|
+
)
|
|
158
|
+
log.info("scheduler leadership released")
|
|
159
|
+
await self._drop()
|
|
160
|
+
|
|
161
|
+
async def tick(self) -> list[int]:
|
|
162
|
+
"""One pass: queue what is due when leader. Returns the ids of the queued jobs."""
|
|
163
|
+
if not self._settings.enabled or not await self.acquire():
|
|
164
|
+
return []
|
|
165
|
+
now = self._clock()
|
|
166
|
+
queued: list[int] = []
|
|
167
|
+
with tracer.start_as_current_span("scribe.scheduler.tick"):
|
|
168
|
+
async with self._sessions() as session, session.begin():
|
|
169
|
+
stale = await requeue_stale(session, settings=self._jobs, now=now)
|
|
170
|
+
if stale:
|
|
171
|
+
log.warning("stale jobs reclaimed", extra={"jobs": stale})
|
|
172
|
+
queued.extend(await self._intervals(session, now))
|
|
173
|
+
queued.extend(await self._domains(session, now))
|
|
174
|
+
QUEUE_DEPTH.update(await queue_depth(session, now=now))
|
|
175
|
+
if queued:
|
|
176
|
+
SCHEDULED.add(len(queued))
|
|
177
|
+
if self._settings.gc_schedule:
|
|
178
|
+
dry_run = "true" if self._settings.gc_dry_run else "false"
|
|
179
|
+
GC_SCHEDULED.add(self._gc_queued, {"dry_run": dry_run})
|
|
180
|
+
return queued
|
|
181
|
+
|
|
182
|
+
async def _intervals(self, session: AsyncSession, now: datetime) -> list[int]:
|
|
183
|
+
queued = []
|
|
184
|
+
intervals = (
|
|
185
|
+
(WEBHOOK_DELIVERY_JOB, self._settings.webhook_interval_s),
|
|
186
|
+
(AUDIT_VERIFY_JOB, self._settings.audit_verify_interval_s),
|
|
187
|
+
(AUDIT_CHECKPOINT_JOB, self._checkpoint_interval_s),
|
|
188
|
+
)
|
|
189
|
+
for job_type, interval in intervals:
|
|
190
|
+
last = self._last.get(job_type)
|
|
191
|
+
if interval <= 0 or (last is not None and (now - last).total_seconds() < interval):
|
|
192
|
+
continue
|
|
193
|
+
queued.append(await enqueue_job(session, job_type, unique=True))
|
|
194
|
+
self._last[job_type] = now
|
|
195
|
+
return queued
|
|
196
|
+
|
|
197
|
+
async def _due(
|
|
198
|
+
self,
|
|
199
|
+
session: AsyncSession,
|
|
200
|
+
job_type: str,
|
|
201
|
+
domain: str,
|
|
202
|
+
fire: datetime,
|
|
203
|
+
*,
|
|
204
|
+
source: str | None = None,
|
|
205
|
+
dry_run: bool = False,
|
|
206
|
+
) -> bool:
|
|
207
|
+
"""Whether a firing is later than the newest job of the same kind (a dry run triggered
|
|
208
|
+
by hand does not stand in for a real one)."""
|
|
209
|
+
parameters = {
|
|
210
|
+
"type": job_type,
|
|
211
|
+
"domain": domain,
|
|
212
|
+
"source": source,
|
|
213
|
+
"dry_run": "true" if dry_run else "false",
|
|
214
|
+
}
|
|
215
|
+
async with rls_scope(session, bypass=True):
|
|
216
|
+
last = (await session.execute(_LAST_FIRE, parameters)).scalar_one_or_none()
|
|
217
|
+
anchor = last if last is not None else self._leader_since
|
|
218
|
+
return anchor is None or fire > anchor
|
|
219
|
+
|
|
220
|
+
def _interval_due(self, name: str, interval: float, now: datetime) -> bool:
|
|
221
|
+
"""Whether an interval job is due; marks it run when it is."""
|
|
222
|
+
last = self._last.get(name)
|
|
223
|
+
if interval <= 0 or (last is not None and (now - last).total_seconds() < interval):
|
|
224
|
+
return False
|
|
225
|
+
self._last[name] = now
|
|
226
|
+
return True
|
|
227
|
+
|
|
228
|
+
async def _domains(self, session: AsyncSession, now: datetime) -> list[int]:
|
|
229
|
+
gc = parse_cron(self._settings.gc_schedule) if self._settings.gc_schedule else None
|
|
230
|
+
embed = self._interval_due(EMBEDDINGS_JOB, self._settings.embeddings_interval_s, now)
|
|
231
|
+
self._gc_queued = 0
|
|
232
|
+
queued: list[int] = []
|
|
233
|
+
after: str | None = None
|
|
234
|
+
while True:
|
|
235
|
+
domains = await DomainRepository(session).list(
|
|
236
|
+
limit=self._settings.domain_batch, after=after
|
|
237
|
+
)
|
|
238
|
+
if not domains:
|
|
239
|
+
return queued
|
|
240
|
+
for domain in domains:
|
|
241
|
+
if gc is not None:
|
|
242
|
+
fire = gc.previous(now)
|
|
243
|
+
dry_run = self._settings.gc_dry_run
|
|
244
|
+
if await self._due(session, GC_JOB, domain.id, fire, dry_run=dry_run):
|
|
245
|
+
queued.append(
|
|
246
|
+
await enqueue_job(
|
|
247
|
+
session,
|
|
248
|
+
GC_JOB,
|
|
249
|
+
domain_id=domain.id,
|
|
250
|
+
payload={"dry_run": dry_run},
|
|
251
|
+
run_after=fire,
|
|
252
|
+
unique=True,
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
self._gc_queued += 1
|
|
256
|
+
if embed:
|
|
257
|
+
queued.append(
|
|
258
|
+
await enqueue_job(
|
|
259
|
+
session,
|
|
260
|
+
EMBEDDINGS_JOB,
|
|
261
|
+
domain_id=domain.id,
|
|
262
|
+
payload=embeddings_payload(),
|
|
263
|
+
unique=True,
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
if self._settings.source_schedules:
|
|
267
|
+
queued.extend(
|
|
268
|
+
await self._sources(session, domain.id, domain.default_branch, now)
|
|
269
|
+
)
|
|
270
|
+
after = domains[-1].id
|
|
271
|
+
|
|
272
|
+
async def _sources(
|
|
273
|
+
self, session: AsyncSession, domain_id: str, branch: str, now: datetime
|
|
274
|
+
) -> list[int]:
|
|
275
|
+
await scope_to_domain(session, domain_id)
|
|
276
|
+
compiled = await MaterializedRepository(
|
|
277
|
+
session, domain_id, branch, writes=self._writes
|
|
278
|
+
).schema()
|
|
279
|
+
if compiled is None:
|
|
280
|
+
return []
|
|
281
|
+
queued = []
|
|
282
|
+
for source in load_compiled(compiled.compiled).sources.values():
|
|
283
|
+
if source.schedule is None or source.status is ElementStatus.DEPRECATED:
|
|
284
|
+
continue
|
|
285
|
+
try:
|
|
286
|
+
fire = parse_cron(source.schedule).previous(now)
|
|
287
|
+
except CronError as error:
|
|
288
|
+
log.warning(
|
|
289
|
+
"invalid Source schedule",
|
|
290
|
+
extra={"domain": domain_id, "source": source.api_name, "error": str(error)},
|
|
291
|
+
)
|
|
292
|
+
continue
|
|
293
|
+
if await self._due(session, SOURCE_SYNC_JOB, domain_id, fire, source=source.id):
|
|
294
|
+
queued.append(
|
|
295
|
+
await enqueue_job(
|
|
296
|
+
session,
|
|
297
|
+
SOURCE_SYNC_JOB,
|
|
298
|
+
domain_id=domain_id,
|
|
299
|
+
payload=source_sync_payload(source.id),
|
|
300
|
+
run_after=fire,
|
|
301
|
+
unique=True,
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
return queued
|
|
305
|
+
|
|
306
|
+
async def run(self, stop: asyncio.Event) -> None:
|
|
307
|
+
"""Tick every `tick_s` until `stop` is set, then release the lock."""
|
|
308
|
+
try:
|
|
309
|
+
while not stop.is_set():
|
|
310
|
+
try:
|
|
311
|
+
await self.tick()
|
|
312
|
+
except Exception:
|
|
313
|
+
log.exception("scheduler tick failed")
|
|
314
|
+
await self._drop()
|
|
315
|
+
with contextlib.suppress(TimeoutError):
|
|
316
|
+
await asyncio.wait_for(stop.wait(), timeout=self._settings.tick_s)
|
|
317
|
+
finally:
|
|
318
|
+
await self.release()
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Assembly of a Scribe process: engine, sessions, HTTP client, worker and scheduler."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import AsyncIterator
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker
|
|
13
|
+
|
|
14
|
+
from lorebase_inference import inference_settings
|
|
15
|
+
from lorebase_inference.chat import use_mode_memory
|
|
16
|
+
from lorebase_platform.settings import Settings
|
|
17
|
+
from lorebase_scribe.embeddings import EmbeddingModels, Templates
|
|
18
|
+
from lorebase_scribe.jobs import PERMANENT_JOB_ERRORS, JobContext, build_handlers
|
|
19
|
+
from lorebase_scribe.scheduler import Scheduler
|
|
20
|
+
from lorebase_scribe.settings import ScribeSettings, scribe_settings
|
|
21
|
+
from lorebase_scribe.sources.connectors import ConnectorFactory, default_connector
|
|
22
|
+
from lorebase_scribe.worker import Worker
|
|
23
|
+
from lorebase_store.db import create_engine
|
|
24
|
+
from lorebase_store.embeddings import SchemaCache
|
|
25
|
+
from lorebase_store.endpoint_modes import StoreModeMemory
|
|
26
|
+
from lorebase_store.jobs import LINEAGE_BACKFILL_JOB, MATERIALIZE_JOB, default_worker_id
|
|
27
|
+
from lorebase_store.materialize import queue_lineage_sweep, run_path_fanout_sweep
|
|
28
|
+
from lorebase_store.runtime import StoreRuntime
|
|
29
|
+
from lorebase_store.settings import database_settings
|
|
30
|
+
from lorebase_store.webhooks import webhook_settings
|
|
31
|
+
|
|
32
|
+
log = logging.getLogger("lorebase.scribe")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Scribe:
|
|
37
|
+
"""A running Scribe: use `open_scribe` to build one."""
|
|
38
|
+
|
|
39
|
+
engine: AsyncEngine
|
|
40
|
+
context: JobContext
|
|
41
|
+
worker: Worker
|
|
42
|
+
scheduler: Scheduler
|
|
43
|
+
settings: ScribeSettings
|
|
44
|
+
|
|
45
|
+
async def serve(self, stop: asyncio.Event) -> None:
|
|
46
|
+
"""Work and schedule until `stop` is set. A worker running materialization jobs also
|
|
47
|
+
rewrites, in the background, materialized path Derivations computed with another
|
|
48
|
+
`store.traverse.path_fanout` (`run_path_fanout_sweep`); one running lineage backfills
|
|
49
|
+
queues the sweep of rows without lineage revisions once (`materialize.lineage_backfill`)."""
|
|
50
|
+
if LINEAGE_BACKFILL_JOB in self.worker.types:
|
|
51
|
+
swept = await queue_lineage_sweep(self.context.sessions)
|
|
52
|
+
if swept is not None:
|
|
53
|
+
log.info("lineage revisions backfill queued", extra={"job": swept})
|
|
54
|
+
tasks = [asyncio.create_task(self.worker.run(stop), name="scribe-worker")]
|
|
55
|
+
if MATERIALIZE_JOB in self.worker.types:
|
|
56
|
+
tasks.append(
|
|
57
|
+
asyncio.create_task(
|
|
58
|
+
run_path_fanout_sweep(
|
|
59
|
+
self.context.sessions, runtime=self.context.runtime, stop=stop
|
|
60
|
+
),
|
|
61
|
+
name="scribe-path-fanout",
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
if self.settings.scheduler.enabled:
|
|
65
|
+
tasks.append(asyncio.create_task(self.scheduler.run(stop), name="scribe-scheduler"))
|
|
66
|
+
await asyncio.gather(*tasks)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@asynccontextmanager
|
|
70
|
+
async def open_scribe(
|
|
71
|
+
settings: Settings,
|
|
72
|
+
*,
|
|
73
|
+
url: str | None = None,
|
|
74
|
+
worker_id: str | None = None,
|
|
75
|
+
connectors: ConnectorFactory = default_connector,
|
|
76
|
+
) -> AsyncIterator[Scribe]:
|
|
77
|
+
"""Build a Scribe from loaded settings (`url` overrides `database.dsn`); disposes of the
|
|
78
|
+
engines and the HTTP client on exit. Per-Attribute index DDL runs as
|
|
79
|
+
`database.maintenance_dsn` when it is set."""
|
|
80
|
+
scribe = scribe_settings(settings)
|
|
81
|
+
runtime = StoreRuntime.from_loaded(settings)
|
|
82
|
+
database = database_settings(settings)
|
|
83
|
+
engine = create_engine(
|
|
84
|
+
database, url=url, statement_timeout_ms=scribe.worker.statement_timeout_ms
|
|
85
|
+
)
|
|
86
|
+
maintenance: AsyncEngine | None = None
|
|
87
|
+
if database.maintenance_dsn is not None:
|
|
88
|
+
maintenance = create_engine(
|
|
89
|
+
database,
|
|
90
|
+
url=database.maintenance_dsn.get_secret_value(),
|
|
91
|
+
pooled=False,
|
|
92
|
+
statement_timeout_ms=0,
|
|
93
|
+
)
|
|
94
|
+
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
|
95
|
+
worker_name = worker_id or default_worker_id()
|
|
96
|
+
inference = inference_settings(settings)
|
|
97
|
+
use_mode_memory(
|
|
98
|
+
StoreModeMemory(sessions),
|
|
99
|
+
secret=None
|
|
100
|
+
if inference.mode_memory_key is None
|
|
101
|
+
else inference.mode_memory_key.get_secret_value(),
|
|
102
|
+
max_age_s=inference.mode_memory_ttl_s,
|
|
103
|
+
)
|
|
104
|
+
embedding_models = EmbeddingModels(inference, dims=runtime.settings.embeddings.dims)
|
|
105
|
+
embedding_templates: SchemaCache[Templates] = SchemaCache(scribe.embeddings.template_cache_size)
|
|
106
|
+
try:
|
|
107
|
+
async with httpx.AsyncClient(trust_env=False) as http:
|
|
108
|
+
context = JobContext(
|
|
109
|
+
engine=engine,
|
|
110
|
+
sessions=sessions,
|
|
111
|
+
runtime=runtime,
|
|
112
|
+
settings=scribe,
|
|
113
|
+
webhooks=webhook_settings(settings),
|
|
114
|
+
http=http,
|
|
115
|
+
worker_id=worker_name,
|
|
116
|
+
connectors=connectors,
|
|
117
|
+
ddl_engine=maintenance,
|
|
118
|
+
inference=inference,
|
|
119
|
+
embedding_models=embedding_models,
|
|
120
|
+
embedding_templates=embedding_templates,
|
|
121
|
+
)
|
|
122
|
+
worker = Worker(
|
|
123
|
+
sessions,
|
|
124
|
+
build_handlers(context, scribe.worker.types),
|
|
125
|
+
settings=scribe.worker,
|
|
126
|
+
jobs=runtime.settings.jobs,
|
|
127
|
+
worker_id=worker_name,
|
|
128
|
+
permanent=PERMANENT_JOB_ERRORS,
|
|
129
|
+
)
|
|
130
|
+
scheduler = Scheduler(
|
|
131
|
+
engine,
|
|
132
|
+
sessions,
|
|
133
|
+
settings=scribe.scheduler,
|
|
134
|
+
jobs=runtime.settings.jobs,
|
|
135
|
+
writes=runtime.settings.writes,
|
|
136
|
+
checkpoint_interval_s=(
|
|
137
|
+
scribe.audit_checkpoints.interval_s if scribe.audit_checkpoints.enabled else 0
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
yield Scribe(engine, context, worker, scheduler, scribe)
|
|
141
|
+
finally:
|
|
142
|
+
use_mode_memory(None)
|
|
143
|
+
await embedding_models.aclose()
|
|
144
|
+
await engine.dispose()
|
|
145
|
+
if maintenance is not None:
|
|
146
|
+
await maintenance.dispose()
|