lorebase-store 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 (163) hide show
  1. lorebase_store/__init__.py +1 -0
  2. lorebase_store/_migrations/env.py +106 -0
  3. lorebase_store/_migrations/script.py.mako +27 -0
  4. lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
  5. lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
  6. lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
  7. lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
  8. lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
  9. lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
  10. lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
  11. lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
  12. lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
  13. lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
  14. lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
  15. lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
  16. lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
  17. lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
  18. lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
  19. lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
  20. lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
  21. lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
  22. lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
  23. lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
  24. lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
  25. lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
  26. lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
  27. lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
  28. lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
  29. lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
  30. lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
  31. lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
  32. lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
  33. lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
  34. lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
  35. lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
  36. lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
  37. lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
  38. lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
  39. lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
  40. lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
  41. lorebase_store/auth/__init__.py +52 -0
  42. lorebase_store/auth/api_keys.py +161 -0
  43. lorebase_store/auth/audit.py +382 -0
  44. lorebase_store/auth/checkpoints.py +185 -0
  45. lorebase_store/auth/grants.py +161 -0
  46. lorebase_store/auth/principals.py +158 -0
  47. lorebase_store/auth/scope.py +70 -0
  48. lorebase_store/auth/sessions.py +153 -0
  49. lorebase_store/auth/unit.py +53 -0
  50. lorebase_store/auth/usage.py +70 -0
  51. lorebase_store/blobs.py +73 -0
  52. lorebase_store/bm25.py +346 -0
  53. lorebase_store/bulk.py +147 -0
  54. lorebase_store/bundle.py +1830 -0
  55. lorebase_store/changefeed.py +261 -0
  56. lorebase_store/commit/__init__.py +42 -0
  57. lorebase_store/commit/changes.py +67 -0
  58. lorebase_store/commit/errors.py +60 -0
  59. lorebase_store/commit/migrations.py +335 -0
  60. lorebase_store/commit/pipeline.py +754 -0
  61. lorebase_store/commit/schema.py +123 -0
  62. lorebase_store/db.py +58 -0
  63. lorebase_store/definer.py +50 -0
  64. lorebase_store/digests.py +99 -0
  65. lorebase_store/embeddings.py +444 -0
  66. lorebase_store/endpoint_modes.py +59 -0
  67. lorebase_store/extraction/__init__.py +61 -0
  68. lorebase_store/extraction/items.py +187 -0
  69. lorebase_store/extraction/materialize.py +364 -0
  70. lorebase_store/extraction/proposal.py +65 -0
  71. lorebase_store/extraction/review.py +168 -0
  72. lorebase_store/gc.py +970 -0
  73. lorebase_store/indexes.py +1033 -0
  74. lorebase_store/jobs.py +651 -0
  75. lorebase_store/keyring.py +72 -0
  76. lorebase_store/layout.py +335 -0
  77. lorebase_store/materialize/__init__.py +103 -0
  78. lorebase_store/materialize/apply.py +436 -0
  79. lorebase_store/materialize/background.py +1184 -0
  80. lorebase_store/materialize/branch.py +268 -0
  81. lorebase_store/materialize/build.py +536 -0
  82. lorebase_store/materialize/derived.py +540 -0
  83. lorebase_store/materialize/lineage_backfill.py +502 -0
  84. lorebase_store/materialize/links.py +266 -0
  85. lorebase_store/materialize/overlay.py +292 -0
  86. lorebase_store/materialize/paths.py +315 -0
  87. lorebase_store/materialize/rebuild.py +326 -0
  88. lorebase_store/materialize/rows.py +153 -0
  89. lorebase_store/materialize/sensitivity.py +66 -0
  90. lorebase_store/materialize/snapshot.py +205 -0
  91. lorebase_store/materialize/stale.py +444 -0
  92. lorebase_store/materialize/verify.py +321 -0
  93. lorebase_store/merging/__init__.py +39 -0
  94. lorebase_store/merging/attempts.py +334 -0
  95. lorebase_store/merging/lineage.py +123 -0
  96. lorebase_store/merging/service.py +652 -0
  97. lorebase_store/migrations.py +133 -0
  98. lorebase_store/models/__init__.py +136 -0
  99. lorebase_store/models/base.py +64 -0
  100. lorebase_store/models/enums.py +132 -0
  101. lorebase_store/models/governance.py +268 -0
  102. lorebase_store/models/ingestion.py +288 -0
  103. lorebase_store/models/materialized.py +277 -0
  104. lorebase_store/models/proposals.py +195 -0
  105. lorebase_store/models/queues.py +87 -0
  106. lorebase_store/models/tracing.py +116 -0
  107. lorebase_store/models/versioning.py +222 -0
  108. lorebase_store/models/webhooks.py +96 -0
  109. lorebase_store/nodestore.py +384 -0
  110. lorebase_store/objectstore.py +162 -0
  111. lorebase_store/outbox_events.py +166 -0
  112. lorebase_store/profiling/__init__.py +58 -0
  113. lorebase_store/profiling/delimited.py +109 -0
  114. lorebase_store/profiling/postgres.py +211 -0
  115. lorebase_store/profiling/record_files.py +127 -0
  116. lorebase_store/profiling/records.py +92 -0
  117. lorebase_store/profiling/sql_databases.py +298 -0
  118. lorebase_store/ratelimit.py +129 -0
  119. lorebase_store/reads.py +1491 -0
  120. lorebase_store/replicas.py +434 -0
  121. lorebase_store/repositories/__init__.py +152 -0
  122. lorebase_store/repositories/changesets.py +59 -0
  123. lorebase_store/repositories/deferred_events.py +142 -0
  124. lorebase_store/repositories/documents.py +305 -0
  125. lorebase_store/repositories/domains.py +97 -0
  126. lorebase_store/repositories/events.py +205 -0
  127. lorebase_store/repositories/index_view.py +489 -0
  128. lorebase_store/repositories/invocations.py +68 -0
  129. lorebase_store/repositories/materializations.py +276 -0
  130. lorebase_store/repositories/materialized.py +1129 -0
  131. lorebase_store/repositories/merges.py +417 -0
  132. lorebase_store/repositories/proposal_items.py +199 -0
  133. lorebase_store/repositories/proposals.py +184 -0
  134. lorebase_store/repositories/provenance.py +77 -0
  135. lorebase_store/repositories/refs.py +452 -0
  136. lorebase_store/repositories/revisions.py +273 -0
  137. lorebase_store/repositories/scope.py +33 -0
  138. lorebase_store/repositories/source_runs.py +123 -0
  139. lorebase_store/repositories/suggestions.py +638 -0
  140. lorebase_store/runtime.py +87 -0
  141. lorebase_store/schema_cache.py +50 -0
  142. lorebase_store/settings.py +879 -0
  143. lorebase_store/storage.py +1478 -0
  144. lorebase_store/telemetry.py +142 -0
  145. lorebase_store/testing.py +108 -0
  146. lorebase_store/tracing.py +505 -0
  147. lorebase_store/traverse.py +578 -0
  148. lorebase_store/vault/__init__.py +50 -0
  149. lorebase_store/vault/keys.py +216 -0
  150. lorebase_store/vault/quotes.py +133 -0
  151. lorebase_store/vault/rotation.py +209 -0
  152. lorebase_store/vault/settings.py +152 -0
  153. lorebase_store/vault/store.py +987 -0
  154. lorebase_store/vault/transfer.py +558 -0
  155. lorebase_store/webhooks/__init__.py +24 -0
  156. lorebase_store/webhooks/delivery.py +637 -0
  157. lorebase_store/webhooks/keys.py +126 -0
  158. lorebase_store/webhooks/repository.py +142 -0
  159. lorebase_store/webhooks/settings.py +138 -0
  160. lorebase_store/webhooks/signing.py +50 -0
  161. lorebase_store-0.2.3.dist-info/METADATA +13 -0
  162. lorebase_store-0.2.3.dist-info/RECORD +163 -0
  163. lorebase_store-0.2.3.dist-info/WHEEL +4 -0
@@ -0,0 +1,638 @@
1
+ """Schema suggestions of a Domain (design 9.2 step 10): observations of extraction jobs
2
+ aggregated by fingerprint, and the reviewer's decisions.
3
+
4
+ `record` folds a job's observations into the stored rows: per fingerprint, each document's
5
+ share (`observations[document_id] = {"n": count, "c": summed confidence}`) is replaced, so
6
+ extracting a document again does not count it twice; examples of that document are replaced,
7
+ samples, labels and sources are united, each within its bound. A decided suggestion keeps
8
+ counting observations but stays decided. New fingerprints beyond `max_tracked` rows are
9
+ dropped and counted.
10
+
11
+ Variants of one Attribute (`renewal_notice_days`, `renewal_proposal_notice_days`) are folded
12
+ into the best supported suggestion of the Kind (`merge_attributes`), their labels kept; a Kind
13
+ that only names a field or a generic word is not recorded (`kind_is_filtered`, shared with
14
+ the extraction job) unless its evidence shows an entity of its own. `merge_variants` folds
15
+ variants that were stored before they could be merged on arrival.
16
+
17
+ Concurrency: `record` and `merge_variants` of one Domain take the same transaction-scoped
18
+ advisory lock, so they never wait for each other's rows; every `FOR UPDATE` select is ordered
19
+ by id, and either method is retried once, in a savepoint, when Postgres picks it as the victim
20
+ of a deadlock with another writer (`decide`). `record` locks the pending suggestions it
21
+ consolidates against before reading them; `merge_variants` reads light columns, computes the
22
+ merge without locks, then locks only the rows of each set it folds and checks them unchanged.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from collections import defaultdict
28
+ from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
29
+ from dataclasses import dataclass, fields
30
+ from datetime import datetime
31
+ from typing import Any, TypeVar
32
+
33
+ from sqlalchemy import Table, delete, func, literal, or_, select, text, update
34
+ from sqlalchemy.dialects.postgresql import insert as pg_insert
35
+ from sqlalchemy.exc import DBAPIError
36
+ from sqlalchemy.ext.asyncio import AsyncSession
37
+
38
+ from lorebase_core.infer.suggestions import (
39
+ PROPOSED,
40
+ AttributeSighting,
41
+ Observation,
42
+ SuggestionOptions,
43
+ SuggestionType,
44
+ kind_is_filtered,
45
+ merge_attributes,
46
+ )
47
+ from lorebase_store.models import metadata
48
+
49
+ SUGGESTIONS: Table = metadata.tables["schema_suggestions"]
50
+
51
+ PENDING = "pending"
52
+ ACCEPTED = "accepted"
53
+ REJECTED = "rejected"
54
+ STATUSES = (PENDING, ACCEPTED, REJECTED)
55
+ MAX_SOURCES = 10
56
+ MAX_MERGE_PASSES = 5
57
+ DEADLOCK = "40P01"
58
+ T = TypeVar("T")
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class SuggestionRow:
63
+ id: str
64
+ fingerprint: str
65
+ suggestion_type: str
66
+ name: str
67
+ kind_ref: str | None
68
+ target_ref: str | None
69
+ labels: list[str]
70
+ support: int
71
+ confidence: float
72
+ observations: dict[str, Any]
73
+ examples: list[dict[str, Any]]
74
+ samples: list[str]
75
+ sources: list[str]
76
+ status: str
77
+ decided_by: str | None
78
+ decided_at: datetime | None
79
+ proposal_id: str | None
80
+ branch: str | None
81
+ last_job_id: str | None
82
+ created_at: datetime | None
83
+ updated_at: datetime | None
84
+
85
+ @property
86
+ def documents(self) -> int:
87
+ return len(self.observations)
88
+
89
+
90
+ _FIELDS = tuple(f.name for f in fields(SuggestionRow))
91
+
92
+
93
+ def _row(found: Any) -> SuggestionRow:
94
+ return SuggestionRow(**{name: found[name] for name in _FIELDS})
95
+
96
+
97
+ @dataclass(frozen=True, slots=True)
98
+ class RecordOutcome:
99
+ created: int = 0
100
+ updated: int = 0
101
+ dropped: int = 0
102
+
103
+
104
+ @dataclass(frozen=True, slots=True)
105
+ class MergeOutcome:
106
+ """What `merge_variants` did: suggestions folded away and suggestions that absorbed them."""
107
+
108
+ merged: int = 0
109
+ kept: int = 0
110
+
111
+
112
+ def _sqlstate(error: DBAPIError) -> str | None:
113
+ for source in (error.orig, getattr(error.orig, "__cause__", None)):
114
+ code = getattr(source, "sqlstate", None) or getattr(source, "pgcode", None)
115
+ if code:
116
+ return str(code)
117
+ return None
118
+
119
+
120
+ def _spread(kept: Sequence[Any], fresh: Iterable[Any]) -> list[Any]:
121
+ """`kept` examples, then the `fresh` ones from documents not shown yet before the others,
122
+ so the examples span documents."""
123
+ shown = {e.get("document_id") for e in kept}
124
+ return [*kept, *sorted(fresh, key=lambda e: e.get("document_id") in shown)]
125
+
126
+
127
+ def _bounded(items: Iterable[Any], limit: int) -> list[Any]:
128
+ return list(dict.fromkeys(items))[:limit]
129
+
130
+
131
+ def fold(
132
+ current: Mapping[Any, Any] | None,
133
+ observations: Sequence[Observation],
134
+ options: SuggestionOptions,
135
+ ) -> dict[str, Any]:
136
+ """Column values of a suggestion after a job's `observations` of it (one fingerprint)."""
137
+ shares: dict[str, dict[str, float]] = dict((current or {}).get("observations") or {})
138
+ replaced = {o.document_id for o in observations}
139
+ batch: dict[str, dict[str, float]] = defaultdict(lambda: {"n": 0, "c": 0.0})
140
+ for observation in observations:
141
+ share = batch[observation.document_id]
142
+ share["n"] += 1
143
+ share["c"] = round(share["c"] + observation.confidence, 6)
144
+ shares.update({k: {"n": int(v["n"]), "c": v["c"]} for k, v in batch.items()})
145
+ support = sum(int(v.get("n", 0)) for v in shares.values())
146
+ total = sum(float(v.get("c", 0.0)) for v in shares.values())
147
+ kept = [
148
+ e for e in ((current or {}).get("examples") or []) if e.get("document_id") not in replaced
149
+ ]
150
+ seen = {(e.get("document_id"), e.get("start"), e.get("end")) for e in kept}
151
+ fresh: list[dict[str, Any]] = []
152
+ for observation in observations:
153
+ evidence = observation.evidence
154
+ if evidence is None:
155
+ continue
156
+ key = (evidence.get("document_id"), evidence.get("start"), evidence.get("end"))
157
+ if key not in seen:
158
+ seen.add(key)
159
+ fresh.append(dict(evidence))
160
+ examples = _spread(kept, fresh)[: options.max_examples]
161
+ samples = _bounded(
162
+ [
163
+ *((current or {}).get("samples") or []),
164
+ *(o.value for o in observations if o.value),
165
+ ],
166
+ options.max_samples,
167
+ )
168
+ labels = _bounded(
169
+ [*((current or {}).get("labels") or []), *(o.label for o in observations)],
170
+ options.max_labels,
171
+ )
172
+ sources = _bounded(
173
+ [*((current or {}).get("sources") or []), *(o.source for o in observations)], MAX_SOURCES
174
+ )
175
+ return {
176
+ "observations": shares,
177
+ "support": support,
178
+ "confidence": round(total / support, 4) if support else 0.0,
179
+ "examples": examples,
180
+ "samples": samples,
181
+ "labels": labels,
182
+ "sources": sources,
183
+ }
184
+
185
+
186
+ def combine(
187
+ kept: Mapping[Any, Any], others: Sequence[Mapping[Any, Any]], options: SuggestionOptions
188
+ ) -> dict[str, Any]:
189
+ """Column values of the suggestion `kept` after absorbing the suggestions `others` (variants
190
+ of it): each document's share is summed, examples, samples, labels (the absorbed names
191
+ included) and sources are united, each within its bound."""
192
+ shares: dict[str, dict[str, float]] = {
193
+ d: dict(v) for d, v in (kept.get("observations") or {}).items()
194
+ }
195
+ for other in others:
196
+ for document, share in (other.get("observations") or {}).items():
197
+ into = shares.setdefault(document, {"n": 0, "c": 0.0})
198
+ into["n"] = int(into.get("n", 0)) + int(share.get("n", 0))
199
+ into["c"] = round(float(into.get("c", 0.0)) + float(share.get("c", 0.0)), 6)
200
+ support = sum(int(v.get("n", 0)) for v in shares.values())
201
+ total = sum(float(v.get("c", 0.0)) for v in shares.values())
202
+ every = [kept, *others]
203
+ first = list(kept.get("examples") or [])
204
+ examples: dict[tuple[Any, Any, Any], dict[str, Any]] = {}
205
+ for example in first:
206
+ examples.setdefault(
207
+ (example.get("document_id"), example.get("start"), example.get("end")), example
208
+ )
209
+ more: dict[tuple[Any, Any, Any], dict[str, Any]] = {}
210
+ for row in others:
211
+ for example in row.get("examples") or []:
212
+ key = (example.get("document_id"), example.get("start"), example.get("end"))
213
+ if key not in examples:
214
+ more.setdefault(key, example)
215
+ return {
216
+ "target_ref": next((r["target_ref"] for r in every if r.get("target_ref")), None),
217
+ "observations": shares,
218
+ "support": support,
219
+ "confidence": round(total / support, 4) if support else 0.0,
220
+ "examples": _spread(list(examples.values()), more.values())[: options.max_examples],
221
+ "samples": _bounded(
222
+ (v for r in every for v in r.get("samples") or []), options.max_samples
223
+ ),
224
+ "labels": _bounded(
225
+ [
226
+ *(kept.get("labels") or []),
227
+ *(x for r in others for x in (r["name"], *(r.get("labels") or []))),
228
+ ],
229
+ options.max_labels,
230
+ ),
231
+ "sources": _bounded((v for r in every for v in r.get("sources") or []), MAX_SOURCES),
232
+ }
233
+
234
+
235
+ def consolidate(
236
+ observations: Sequence[Observation],
237
+ stored: Sequence[Mapping[Any, Any]],
238
+ options: SuggestionOptions,
239
+ ) -> tuple[dict[str, list[Observation]], dict[str, str]]:
240
+ """Observations grouped by the fingerprint of the suggestion they count for, and the name
241
+ of each group. Attribute variants join the best supported suggestion of their Kind,
242
+ among the pending ``stored`` ones and the job's; noise Kinds are left out, unless the
243
+ evidence (the job's and the ``stored`` Kind and Attribute suggestions) shows an
244
+ independent entity."""
245
+ sightings: dict[str, AttributeSighting] = {}
246
+ kinds: dict[str, Mapping[Any, Any]] = {}
247
+ for row in stored:
248
+ if row.get("suggestion_type", SuggestionType.ATTRIBUTE.value) == SuggestionType.KIND.value:
249
+ kinds[row["fingerprint"]] = row
250
+ continue
251
+ sightings[row["fingerprint"]] = AttributeSighting(
252
+ row["fingerprint"],
253
+ row["name"],
254
+ row["kind_ref"],
255
+ tuple(row["samples"] or ()),
256
+ int(row["support"]),
257
+ )
258
+ batch: dict[str, list[Observation]] = defaultdict(list)
259
+ for observation in observations:
260
+ batch[observation.fingerprint].append(observation)
261
+ for key, group in batch.items():
262
+ first = group[0]
263
+ if first.type is not SuggestionType.ATTRIBUTE:
264
+ continue
265
+ known = sightings.get(key)
266
+ samples = tuple(dict.fromkeys(o.value for o in group if o.value))
267
+ sightings[key] = AttributeSighting(
268
+ key,
269
+ first.name,
270
+ first.kind,
271
+ tuple(dict.fromkeys([*(known.samples if known else ()), *samples])),
272
+ (known.support if known else 0) + len(group),
273
+ )
274
+ merged = merge_attributes(list(sightings.values()), options)
275
+ names = {s.key: s.name for s in sightings.values()}
276
+ groups: dict[str, list[Observation]] = defaultdict(list)
277
+ for key, group in batch.items():
278
+ groups[merged.get(key, key)].extend(group)
279
+ fields_known = [s.name for s in sightings.values()]
280
+ for key in [k for k, g in groups.items() if g[0].type is SuggestionType.KIND]:
281
+ name = groups[key][0].name
282
+ owned = f"{PROPOSED}{name}"
283
+ instances = {o.value for o in groups[key] if o.value}
284
+ instances.update((kinds[key].get("samples") or []) if key in kinds else [])
285
+ fields = {s.key for s in sightings.values() if s.kind == owned}
286
+ if kind_is_filtered(
287
+ name, fields_known, options, instances=len(instances), fields=len(fields)
288
+ ):
289
+ del groups[key]
290
+ return groups, {k: names.get(k, groups[k][0].name) for k in groups}
291
+
292
+
293
+ class SchemaSuggestionRepository:
294
+ """Schema suggestions of one Domain, in the caller's transaction (after `scope_to_domain`)."""
295
+
296
+ def __init__(self, session: AsyncSession, domain_id: str) -> None:
297
+ self._session = session
298
+ self._domain = domain_id
299
+
300
+ def _scoped(self) -> Any:
301
+ return SUGGESTIONS.c.domain_id == self._domain
302
+
303
+ async def _serialize(self) -> None:
304
+ """Take the Domain's suggestion-writer lock, held to the end of the transaction."""
305
+ await self._session.execute(
306
+ text("SELECT pg_advisory_xact_lock(hashtextextended(:k, 0))"),
307
+ {"k": f"schema_suggestions\x1f{self._domain}"},
308
+ )
309
+
310
+ async def _retrying(self, work: Callable[[], Awaitable[T]]) -> T:
311
+ """`work` in a savepoint, run once more if Postgres chose it as a deadlock victim."""
312
+ for attempt in (0, 1):
313
+ try:
314
+ async with self._session.begin_nested():
315
+ return await work()
316
+ except DBAPIError as error:
317
+ if attempt or _sqlstate(error) != DEADLOCK:
318
+ raise
319
+ raise AssertionError("unreachable") # pragma: no cover
320
+
321
+ async def record(
322
+ self,
323
+ observations: Sequence[Observation],
324
+ *,
325
+ job_id: str,
326
+ options: SuggestionOptions,
327
+ now: datetime,
328
+ new_id: Callable[[], str],
329
+ ) -> RecordOutcome:
330
+ """Fold a job's observations into the stored suggestions (see module doc)."""
331
+
332
+ async def work() -> RecordOutcome:
333
+ await self._serialize()
334
+ return await self._record(
335
+ observations, job_id=job_id, options=options, now=now, new_id=new_id
336
+ )
337
+
338
+ return await self._retrying(work)
339
+
340
+ async def _record(
341
+ self,
342
+ observations: Sequence[Observation],
343
+ *,
344
+ job_id: str,
345
+ options: SuggestionOptions,
346
+ now: datetime,
347
+ new_id: Callable[[], str],
348
+ ) -> RecordOutcome:
349
+ table = SUGGESTIONS
350
+ refs = {o.kind for o in observations if o.type is SuggestionType.ATTRIBUTE and o.kind}
351
+ kind_keys = {o.fingerprint for o in observations if o.type is SuggestionType.KIND}
352
+ refs.update(f"{PROPOSED}{o.name}" for o in observations if o.type is SuggestionType.KIND)
353
+ pending: list[Any] = []
354
+ if refs:
355
+ pending = list(
356
+ (
357
+ await self._session.execute(
358
+ select(table)
359
+ .where(
360
+ self._scoped(),
361
+ table.c.suggestion_type == SuggestionType.ATTRIBUTE.value,
362
+ table.c.status == PENDING,
363
+ table.c.kind_ref.in_(sorted(refs)),
364
+ )
365
+ .order_by(table.c.id)
366
+ .with_for_update()
367
+ )
368
+ ).mappings()
369
+ )
370
+ if kind_keys:
371
+ pending += list(
372
+ (
373
+ await self._session.execute(
374
+ select(table)
375
+ .where(self._scoped(), table.c.fingerprint.in_(sorted(kind_keys)))
376
+ .order_by(table.c.id)
377
+ .with_for_update()
378
+ )
379
+ ).mappings()
380
+ )
381
+ groups, names = consolidate(observations, pending, options)
382
+ if not groups:
383
+ return RecordOutcome()
384
+ stored = {
385
+ row["fingerprint"]: row
386
+ for row in (
387
+ await self._session.execute(
388
+ select(table)
389
+ .where(self._scoped(), table.c.fingerprint.in_(list(groups)))
390
+ .order_by(table.c.id)
391
+ .with_for_update()
392
+ )
393
+ ).mappings()
394
+ }
395
+ room = options.max_tracked - await self.count()
396
+ created = updated = dropped = 0
397
+ for key in sorted(groups):
398
+ group = groups[key]
399
+ current = stored.get(key)
400
+ values = fold(current, group, options)
401
+ if current is not None:
402
+ await self._session.execute(
403
+ update(table)
404
+ .where(self._scoped(), table.c.id == current["id"])
405
+ .values(**values, last_job_id=job_id, updated_at=now)
406
+ )
407
+ updated += 1
408
+ continue
409
+ if room <= 0:
410
+ dropped += len(group)
411
+ continue
412
+ first = group[0]
413
+ inserted = (
414
+ await self._session.execute(
415
+ pg_insert(table)
416
+ .values(
417
+ domain_id=self._domain,
418
+ id=new_id(),
419
+ fingerprint=key,
420
+ suggestion_type=first.type.value,
421
+ name=names[key],
422
+ kind_ref=first.kind,
423
+ target_ref=first.target,
424
+ last_job_id=job_id,
425
+ created_at=now,
426
+ updated_at=now,
427
+ **values,
428
+ )
429
+ .on_conflict_do_nothing(index_elements=["domain_id", "fingerprint"])
430
+ .returning(table.c.id)
431
+ )
432
+ ).scalar_one_or_none()
433
+ if inserted is None: # another job inserted it meanwhile: fold into its row
434
+ row = (
435
+ (
436
+ await self._session.execute(
437
+ select(table)
438
+ .where(self._scoped(), table.c.fingerprint == key)
439
+ .with_for_update()
440
+ )
441
+ )
442
+ .mappings()
443
+ .one()
444
+ )
445
+ await self._session.execute(
446
+ update(table)
447
+ .where(self._scoped(), table.c.id == row["id"])
448
+ .values(**fold(row, group, options), last_job_id=job_id, updated_at=now)
449
+ )
450
+ updated += 1
451
+ continue
452
+ room -= 1
453
+ created += 1
454
+ return RecordOutcome(created, updated, dropped)
455
+
456
+ async def merge_variants(
457
+ self,
458
+ *,
459
+ options: SuggestionOptions,
460
+ now: datetime,
461
+ max_passes: int = MAX_MERGE_PASSES,
462
+ ) -> MergeOutcome:
463
+ """Fold the pending Attribute suggestions that are variants of one another
464
+ (`merge_attributes`) into the best supported one of each set, as `record` does for new
465
+ observations: support, documents, examples, samples and labels are united and the
466
+ absorbed rows deleted. Decided suggestions are left alone. Folding changes the samples
467
+ and support the similarity reads, so it repeats until nothing folds more, at most
468
+ `max_passes` times."""
469
+ merged = kept = 0
470
+ for _ in range(max(max_passes, 1)):
471
+ outcome = await self._retrying(lambda: self._merge_pass(options, now))
472
+ merged += outcome.merged
473
+ kept += outcome.kept
474
+ if not outcome.merged:
475
+ break
476
+ return MergeOutcome(merged=merged, kept=kept)
477
+
478
+ async def _merge_pass(self, options: SuggestionOptions, now: datetime) -> MergeOutcome:
479
+ table = SUGGESTIONS
480
+ await self._serialize()
481
+ light = [
482
+ table.c.id,
483
+ table.c.fingerprint,
484
+ table.c.name,
485
+ table.c.kind_ref,
486
+ table.c.samples,
487
+ table.c.support,
488
+ table.c.updated_at,
489
+ ]
490
+ seen = {
491
+ row["fingerprint"]: row
492
+ for row in (
493
+ await self._session.execute(
494
+ select(*light)
495
+ .where(
496
+ self._scoped(),
497
+ table.c.suggestion_type == SuggestionType.ATTRIBUTE.value,
498
+ table.c.status == PENDING,
499
+ )
500
+ .order_by(table.c.id)
501
+ )
502
+ ).mappings()
503
+ }
504
+ sightings = [
505
+ AttributeSighting(
506
+ key, r["name"], r["kind_ref"], tuple(r["samples"] or ()), int(r["support"])
507
+ )
508
+ for key, r in seen.items()
509
+ ]
510
+ folded: dict[str, list[str]] = defaultdict(list)
511
+ for key, target in merge_attributes(sightings, options).items():
512
+ if key != target:
513
+ folded[target].append(key)
514
+ merged = kept = 0
515
+ for target, absorbed in sorted(folded.items()):
516
+ members = [seen[k] for k in sorted([target, *absorbed], key=lambda k: seen[k]["id"])]
517
+ # Lock only these rows, in id order, and fold them if no writer touched them since.
518
+ locked = {
519
+ row["id"]: row
520
+ for row in (
521
+ await self._session.execute(
522
+ select(table)
523
+ .where(self._scoped(), table.c.id.in_([m["id"] for m in members]))
524
+ .order_by(table.c.id)
525
+ .with_for_update()
526
+ )
527
+ ).mappings()
528
+ }
529
+ if len(locked) != len(members) or any(
530
+ locked[m["id"]]["status"] != PENDING
531
+ or locked[m["id"]]["support"] != m["support"]
532
+ or locked[m["id"]]["updated_at"] != m["updated_at"]
533
+ for m in members
534
+ ):
535
+ continue
536
+ others = [locked[seen[k]["id"]] for k in sorted(absorbed)]
537
+ await self._session.execute(
538
+ update(table)
539
+ .where(self._scoped(), table.c.id == seen[target]["id"])
540
+ .values(**combine(locked[seen[target]["id"]], others, options), updated_at=now)
541
+ )
542
+ await self._session.execute(
543
+ delete(table).where(self._scoped(), table.c.id.in_([o["id"] for o in others]))
544
+ )
545
+ merged += len(absorbed)
546
+ kept += 1
547
+ return MergeOutcome(merged=merged, kept=kept)
548
+
549
+ async def count(self, status: str | None = None) -> int:
550
+ query = select(func.count()).select_from(SUGGESTIONS).where(self._scoped())
551
+ if status is not None:
552
+ query = query.where(SUGGESTIONS.c.status == status)
553
+ return int((await self._session.execute(query)).scalar_one())
554
+
555
+ async def page(
556
+ self,
557
+ *,
558
+ statuses: Sequence[str] = (),
559
+ types: Sequence[str] = (),
560
+ min_support: int = 1,
561
+ kind_min_attributes: int = 0,
562
+ limit: int,
563
+ after: tuple[int, str] | None = None,
564
+ ) -> list[SuggestionRow]:
565
+ """Suggestions by support (descending), then id, after the keyset `(support, id)`;
566
+ pending ones need `min_support`, and pending Kinds `kind_min_attributes` Attribute
567
+ suggestions of their own (owner ref `+<Kind>`) or as many observations."""
568
+ table = SUGGESTIONS
569
+ query = select(table).where(
570
+ self._scoped(), or_(table.c.status != PENDING, table.c.support >= min_support)
571
+ )
572
+ if kind_min_attributes > 0:
573
+ owned = SUGGESTIONS.alias("owned")
574
+ attributes = (
575
+ select(func.count())
576
+ .select_from(owned)
577
+ .where(
578
+ owned.c.domain_id == table.c.domain_id,
579
+ owned.c.suggestion_type == SuggestionType.ATTRIBUTE.value,
580
+ owned.c.kind_ref == func.concat(literal(PROPOSED), table.c.name),
581
+ )
582
+ .scalar_subquery()
583
+ )
584
+ query = query.where(
585
+ or_(
586
+ table.c.status != PENDING,
587
+ table.c.suggestion_type != SuggestionType.KIND.value,
588
+ table.c.support >= kind_min_attributes,
589
+ attributes >= kind_min_attributes,
590
+ )
591
+ )
592
+ if statuses:
593
+ query = query.where(table.c.status.in_(list(statuses)))
594
+ if types:
595
+ query = query.where(table.c.suggestion_type.in_(list(types)))
596
+ if after is not None:
597
+ support, last = after
598
+ query = query.where(
599
+ or_(table.c.support < support, (table.c.support == support) & (table.c.id > last))
600
+ )
601
+ query = query.order_by(table.c.support.desc(), table.c.id).limit(limit)
602
+ return [_row(found) for found in (await self._session.execute(query)).mappings()]
603
+
604
+ async def get_many(
605
+ self, ids: Sequence[str], *, for_update: bool = False
606
+ ) -> dict[str, SuggestionRow]:
607
+ if not ids:
608
+ return {}
609
+ query = select(SUGGESTIONS).where(self._scoped(), SUGGESTIONS.c.id.in_(list(ids)))
610
+ if for_update:
611
+ query = query.order_by(SUGGESTIONS.c.id).with_for_update()
612
+ rows = (await self._session.execute(query)).mappings()
613
+ return {row["id"]: _row(row) for row in rows}
614
+
615
+ async def decide(
616
+ self,
617
+ ids: Sequence[str],
618
+ *,
619
+ status: str,
620
+ decided_by: str | None,
621
+ decided_at: datetime | None,
622
+ proposal_id: str | None = None,
623
+ branch: str | None = None,
624
+ ) -> None:
625
+ if not ids:
626
+ return
627
+ await self._session.execute(
628
+ update(SUGGESTIONS)
629
+ .where(self._scoped(), SUGGESTIONS.c.id.in_(list(ids)))
630
+ .values(
631
+ status=status,
632
+ decided_by=decided_by,
633
+ decided_at=decided_at,
634
+ proposal_id=proposal_id,
635
+ branch=branch,
636
+ updated_at=func.now() if decided_at is None else decided_at,
637
+ )
638
+ )