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.
Files changed (41) hide show
  1. lorebase_scribe/__init__.py +3 -0
  2. lorebase_scribe/__main__.py +156 -0
  3. lorebase_scribe/checkpoints.py +324 -0
  4. lorebase_scribe/cron.py +117 -0
  5. lorebase_scribe/embeddings.py +570 -0
  6. lorebase_scribe/extraction/__init__.py +17 -0
  7. lorebase_scribe/extraction/assemble.py +339 -0
  8. lorebase_scribe/extraction/candidates.py +136 -0
  9. lorebase_scribe/extraction/discovery.py +567 -0
  10. lorebase_scribe/extraction/llm_discovery.py +271 -0
  11. lorebase_scribe/extraction/matching.py +285 -0
  12. lorebase_scribe/extraction/ontology.py +432 -0
  13. lorebase_scribe/extraction/parsing.py +114 -0
  14. lorebase_scribe/extraction/pipeline.py +1091 -0
  15. lorebase_scribe/extraction/relations.py +194 -0
  16. lorebase_scribe/extraction/sandbox.py +269 -0
  17. lorebase_scribe/extraction/settings.py +293 -0
  18. lorebase_scribe/extraction/text.py +179 -0
  19. lorebase_scribe/extraction/tier2.py +573 -0
  20. lorebase_scribe/extraction/values.py +282 -0
  21. lorebase_scribe/jobs.py +418 -0
  22. lorebase_scribe/scheduler.py +318 -0
  23. lorebase_scribe/service.py +146 -0
  24. lorebase_scribe/settings.py +521 -0
  25. lorebase_scribe/sources/__init__.py +60 -0
  26. lorebase_scribe/sources/access.py +241 -0
  27. lorebase_scribe/sources/connectors.py +320 -0
  28. lorebase_scribe/sources/delta.py +175 -0
  29. lorebase_scribe/sources/http_json.py +283 -0
  30. lorebase_scribe/sources/mapping.py +429 -0
  31. lorebase_scribe/sources/profile.py +36 -0
  32. lorebase_scribe/sources/records.py +298 -0
  33. lorebase_scribe/sources/sql.py +332 -0
  34. lorebase_scribe/sources/sync.py +730 -0
  35. lorebase_scribe/telemetry.py +113 -0
  36. lorebase_scribe/webhooks.py +99 -0
  37. lorebase_scribe/worker.py +254 -0
  38. lorebase_scribe-0.2.3.dist-info/METADATA +23 -0
  39. lorebase_scribe-0.2.3.dist-info/RECORD +41 -0
  40. lorebase_scribe-0.2.3.dist-info/WHEEL +4 -0
  41. 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()