atif-sql 0.2.0__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 (164) hide show
  1. atif_analytics/__init__.py +9 -0
  2. atif_analytics/application/__init__.py +3 -0
  3. atif_analytics/application/analyze.py +158 -0
  4. atif_analytics/application/prompts.py +750 -0
  5. atif_analytics/application/use_cases/__init__.py +3 -0
  6. atif_analytics/application/use_cases/_shared.py +264 -0
  7. atif_analytics/application/use_cases/classify.py +430 -0
  8. atif_analytics/application/use_cases/conflicts.py +448 -0
  9. atif_analytics/application/use_cases/friction.py +634 -0
  10. atif_analytics/application/use_cases/perceived.py +451 -0
  11. atif_analytics/domain/__init__.py +3 -0
  12. atif_analytics/domain/authorship.py +166 -0
  13. atif_analytics/domain/config.py +30 -0
  14. atif_analytics/domain/costs.py +50 -0
  15. atif_analytics/domain/friction.py +117 -0
  16. atif_analytics/domain/layout.py +94 -0
  17. atif_analytics/domain/models.py +385 -0
  18. atif_analytics/domain/ports.py +100 -0
  19. atif_analytics/domain/transcript.py +359 -0
  20. atif_analytics/infrastructure/__init__.py +3 -0
  21. atif_analytics/infrastructure/corpus_reader.py +546 -0
  22. atif_analytics/infrastructure/parquet_cache.py +186 -0
  23. atif_analytics/infrastructure/settings.py +114 -0
  24. atif_analytics/infrastructure/sqlite_state/__init__.py +3 -0
  25. atif_analytics/infrastructure/sqlite_state/checkpointer.py +326 -0
  26. atif_analytics/infrastructure/sqlite_state/retry_queue.py +296 -0
  27. atif_analytics/py.typed +0 -0
  28. atif_cli/__init__.py +3 -0
  29. atif_cli/__main__.py +8 -0
  30. atif_cli/app.py +2459 -0
  31. atif_cli/converter_adapter.py +151 -0
  32. atif_cli/corpus.py +409 -0
  33. atif_cli/cron.py +311 -0
  34. atif_cli/duck_errors.py +97 -0
  35. atif_cli/embed_lake.py +101 -0
  36. atif_cli/errors.py +89 -0
  37. atif_cli/lake.py +373 -0
  38. atif_cli/lake_sessions.py +270 -0
  39. atif_cli/output.py +297 -0
  40. atif_cli/py.typed +0 -0
  41. atif_converter/__init__.py +3 -0
  42. atif_converter/application/__init__.py +3 -0
  43. atif_converter/application/convert_and_audit.py +294 -0
  44. atif_converter/application/convert_codex.py +210 -0
  45. atif_converter/domain/__init__.py +9 -0
  46. atif_converter/domain/agents.py +40 -0
  47. atif_converter/domain/atif/__init__.py +55 -0
  48. atif_converter/domain/atif/agent.py +37 -0
  49. atif_converter/domain/atif/content.py +161 -0
  50. atif_converter/domain/atif/final_metrics.py +45 -0
  51. atif_converter/domain/atif/metrics.py +49 -0
  52. atif_converter/domain/atif/observation.py +21 -0
  53. atif_converter/domain/atif/observation_result.py +47 -0
  54. atif_converter/domain/atif/step.py +144 -0
  55. atif_converter/domain/atif/subagent_trajectory_ref.py +90 -0
  56. atif_converter/domain/atif/tool_call.py +36 -0
  57. atif_converter/domain/atif/trajectory.py +211 -0
  58. atif_converter/domain/atif/trajectory_validator.py +299 -0
  59. atif_converter/domain/blobs.py +510 -0
  60. atif_converter/domain/claude_code_conversion.py +1077 -0
  61. atif_converter/domain/codex_conversion.py +1036 -0
  62. atif_converter/domain/codex_edges.py +192 -0
  63. atif_converter/domain/codex_enrichment.py +419 -0
  64. atif_converter/domain/codex_fidelity.py +124 -0
  65. atif_converter/domain/codex_nested_calls.py +165 -0
  66. atif_converter/domain/edges.py +113 -0
  67. atif_converter/domain/enrichment.py +412 -0
  68. atif_converter/domain/errors.py +60 -0
  69. atif_converter/domain/fidelity.py +171 -0
  70. atif_converter/domain/model_prices.json +8538 -0
  71. atif_converter/domain/pricing.py +790 -0
  72. atif_converter/domain/result_signals.py +774 -0
  73. atif_converter/domain/schema_version.py +90 -0
  74. atif_converter/domain/session_events.py +408 -0
  75. atif_converter/infrastructure/__init__.py +3 -0
  76. atif_converter/infrastructure/census.py +89 -0
  77. atif_converter/infrastructure/claude_code_converter.py +127 -0
  78. atif_converter/infrastructure/codex_adapter.py +132 -0
  79. atif_converter/infrastructure/codex_census.py +100 -0
  80. atif_converter/infrastructure/codex_converter.py +82 -0
  81. atif_converter/infrastructure/harbor_adapter.py +187 -0
  82. atif_converter/infrastructure/raw_records.py +426 -0
  83. atif_converter/infrastructure/source_archive.py +154 -0
  84. atif_converter/py.typed +0 -0
  85. atif_corpus/__init__.py +13 -0
  86. atif_corpus/application/__init__.py +7 -0
  87. atif_corpus/application/materialize.py +1826 -0
  88. atif_corpus/domain/__init__.py +9 -0
  89. atif_corpus/domain/agents.py +33 -0
  90. atif_corpus/domain/generation.py +34 -0
  91. atif_corpus/domain/layout.py +168 -0
  92. atif_corpus/domain/ports.py +175 -0
  93. atif_corpus/domain/session_id.py +68 -0
  94. atif_corpus/domain/sessions.py +228 -0
  95. atif_corpus/domain/slug.py +72 -0
  96. atif_corpus/domain/source_layout.py +143 -0
  97. atif_corpus/domain/watermark.py +76 -0
  98. atif_corpus/infrastructure/__init__.py +7 -0
  99. atif_corpus/infrastructure/atomic.py +266 -0
  100. atif_corpus/infrastructure/compress_artifacts.py +161 -0
  101. atif_corpus/infrastructure/fake_converter.py +174 -0
  102. atif_corpus/infrastructure/scanner.py +316 -0
  103. atif_corpus/infrastructure/settings.py +130 -0
  104. atif_corpus/infrastructure/source_archive.py +202 -0
  105. atif_corpus/py.typed +0 -0
  106. atif_duck/__init__.py +43 -0
  107. atif_duck/domain/__init__.py +32 -0
  108. atif_duck/domain/artifacts.py +66 -0
  109. atif_duck/domain/authorship.py +165 -0
  110. atif_duck/domain/catalog.py +626 -0
  111. atif_duck/domain/columnar.py +149 -0
  112. atif_duck/domain/embedding_guard.py +88 -0
  113. atif_duck/domain/examples.py +265 -0
  114. atif_duck/domain/lake.py +427 -0
  115. atif_duck/domain/lake_sessions.py +97 -0
  116. atif_duck/domain/raw_readers.py +140 -0
  117. atif_duck/domain/session_id.py +69 -0
  118. atif_duck/domain/sql_literal.py +50 -0
  119. atif_duck/infrastructure/__init__.py +34 -0
  120. atif_duck/infrastructure/analytics.py +469 -0
  121. atif_duck/infrastructure/authorship.py +139 -0
  122. atif_duck/infrastructure/columnar.py +641 -0
  123. atif_duck/infrastructure/lake.py +1383 -0
  124. atif_duck/infrastructure/lake_sessions.py +194 -0
  125. atif_duck/infrastructure/lake_settings.py +63 -0
  126. atif_duck/infrastructure/lake_steps.py +225 -0
  127. atif_duck/infrastructure/projections.py +221 -0
  128. atif_duck/infrastructure/registry.py +2194 -0
  129. atif_duck/infrastructure/stored_artifacts.py +115 -0
  130. atif_duck/py.typed +0 -0
  131. atif_embed/__init__.py +12 -0
  132. atif_embed/application/__init__.py +3 -0
  133. atif_embed/application/embed.py +293 -0
  134. atif_embed/application/prune.py +80 -0
  135. atif_embed/domain/__init__.py +3 -0
  136. atif_embed/domain/discovery.py +70 -0
  137. atif_embed/domain/embedding_guard.py +83 -0
  138. atif_embed/domain/errors.py +94 -0
  139. atif_embed/domain/ports.py +223 -0
  140. atif_embed/domain/sql_literal.py +49 -0
  141. atif_embed/domain/text_stamp.py +71 -0
  142. atif_embed/infrastructure/__init__.py +3 -0
  143. atif_embed/infrastructure/cohere_bedrock.py +433 -0
  144. atif_embed/infrastructure/corpus_text_rows.py +273 -0
  145. atif_embed/infrastructure/gemma_local.py +355 -0
  146. atif_embed/infrastructure/lake_text_rows.py +203 -0
  147. atif_embed/infrastructure/lance_store.py +440 -0
  148. atif_embed/infrastructure/providers.py +30 -0
  149. atif_embed/infrastructure/settings.py +154 -0
  150. atif_embed/py.typed +0 -0
  151. atif_models/__init__.py +14 -0
  152. atif_models/domain/__init__.py +9 -0
  153. atif_models/domain/ports.py +143 -0
  154. atif_models/domain/registry.py +151 -0
  155. atif_models/domain/schema.py +109 -0
  156. atif_models/infrastructure/__init__.py +8 -0
  157. atif_models/infrastructure/openai_bedrock.py +314 -0
  158. atif_models/infrastructure/settings.py +85 -0
  159. atif_models/py.typed +0 -0
  160. atif_sql-0.2.0.dist-info/METADATA +272 -0
  161. atif_sql-0.2.0.dist-info/RECORD +164 -0
  162. atif_sql-0.2.0.dist-info/WHEEL +4 -0
  163. atif_sql-0.2.0.dist-info/entry_points.txt +2 -0
  164. atif_sql-0.2.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,1383 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """The corpora's DuckLake: write it, rebuild it, verify it, compact it, read it.
4
+
5
+ Tables, partitions and schema identity are declared in
6
+ :mod:`atif_duck.domain.lake`; this module does the I/O.
7
+
8
+ Layout
9
+ ------
10
+ One lake root (default ``~/.atif-sql/lake/``, see
11
+ :class:`atif_duck.infrastructure.lake_settings.LakeSettings`) holds::
12
+
13
+ catalog.duckdb the writer's DuckLake catalog (DuckDB file)
14
+ catalog.reader.duckdb the published copy every reader attaches
15
+ data/ the parquet data and delete files
16
+
17
+ plus a sibling lock file, ``<root>.lock``, held by whichever process writes.
18
+
19
+ Why readers attach a published copy rather than the catalog itself
20
+ ------------------------------------------------------------------
21
+ The query sandbox runs SQL an agent composed, so the reader may only load
22
+ extensions whose table functions respect ``enable_external_access``. A SQLite
23
+ catalog needs the ``sqlite`` extension in the reader, and its
24
+ ``sqlite_scan`` / ``sqlite_attach`` read any SQLite file on the host after
25
+ lockdown (measured on DuckDB 1.5.5: both ignore the allowlist), which is the
26
+ arbitrary-file read the sandbox exists to refuse. A DuckDB-file catalog needs
27
+ nothing but ``ducklake``, but DuckDB locks the file while a writer holds it,
28
+ so a reader could not attach during a write. The writer therefore keeps
29
+ ``catalog.duckdb`` to itself, and after every commit (still under the lock,
30
+ catalog detached so it is checkpointed) copies it to a temporary name and
31
+ renames that over ``catalog.reader.duckdb``. A reader attaches the copy
32
+ ``READ_ONLY``: it never waits on a writer, sees the last committed state, and
33
+ a query in flight keeps the file it opened when the next copy lands.
34
+
35
+ Why readers get per-file grants
36
+ -------------------------------
37
+ DuckDB grants a directory read-write. With ``data/`` granted,
38
+ ``ducklake_cleanup_old_files`` run from caller SQL on a READ_ONLY attach
39
+ deleted the files scheduled for deletion before it failed on the metadata
40
+ write (measured). Granting each data and delete file of the current snapshot
41
+ through ``allowed_paths`` instead lets every scan open what it needs and
42
+ refuses every delete, rewrite and merge at the filesystem layer. ``COPY`` to a
43
+ granted path is the statement gate's to refuse, as for the per-session parquet.
44
+
45
+ The writer
46
+ ----------
47
+ :class:`DuckLakeSessionSink` is the adapter atif-cli plugs into atif-corpus's
48
+ ``SessionSink`` port. materialize calls it in the PARENT process, after the
49
+ sessions' directories were swapped into place, one batch of session ids at a
50
+ time: one transaction deletes the batch's rows from every table and inserts
51
+ them again from the registry's raw relations over the published artifacts
52
+ (:func:`atif_duck.infrastructure.registry.register_raw` with a session
53
+ filter), so the lake holds exactly what the per-session path would read.
54
+
55
+ A missing lake is not an error: the sink does nothing until
56
+ ``atif-sql lake rebuild`` creates one. A lake whose recorded schema differs
57
+ from this code's is rebuilt by the first sink call that finds it so. A corpus
58
+ the lake does not hold yet is loaded whole by the first sink call for it.
59
+ """
60
+
61
+ from __future__ import annotations
62
+
63
+ import contextlib
64
+ import dataclasses
65
+ import errno
66
+ import fcntl
67
+ import os
68
+ import shutil
69
+ import tempfile
70
+ import time
71
+ from dataclasses import dataclass, field
72
+ from pathlib import Path
73
+ from typing import TYPE_CHECKING, Any
74
+
75
+ from loguru import logger
76
+
77
+ from atif_duck.domain.artifacts import SESSION_EVENTS_JSONL, TRAJECTORY_JSON
78
+ from atif_duck.domain.lake import (
79
+ AGENT_COLUMN,
80
+ ARTIFACT_HASH_SQL,
81
+ BOOTSTRAP_CREATE_SQL,
82
+ CORPORA_TABLE,
83
+ CREATE_TABLE_SQL,
84
+ DELETE_CORPUS_SQL,
85
+ DELETE_SESSIONS_SQL,
86
+ INSERT_SQL,
87
+ LAKE_ALIAS,
88
+ LAKE_HASH_SQL,
89
+ LAKE_INFO_TABLE,
90
+ LAKE_METADATA_ALIAS,
91
+ LAKE_ROW_GROUP_SIZE,
92
+ LAKE_TABLES,
93
+ PARTITION_SQL,
94
+ READER_SELECT_SQL,
95
+ SOURCE_ORDERED_TABLES,
96
+ expected_lake_info,
97
+ lake_info_mismatches,
98
+ )
99
+ from atif_duck.domain.raw_readers import CORPUS_COLUMN
100
+ from atif_duck.domain.session_id import session_id_rejection
101
+ from atif_duck.domain.sql_literal import SqlFragment, sql_literal
102
+ from atif_duck.infrastructure.stored_artifacts import read_bytes, stored_artifact
103
+
104
+ if TYPE_CHECKING:
105
+ from collections.abc import Callable, Generator, Mapping, Sequence
106
+ from concurrent.futures import Executor
107
+
108
+ import duckdb
109
+
110
+ from atif_duck.infrastructure.registry import RawSources
111
+
112
+ #: The DuckDB extension that reads and writes the lake.
113
+ DUCKLAKE_EXTENSION: str = "ducklake"
114
+
115
+ #: The agent a corpus holds when its ``meta.json`` predates the ``agent`` key:
116
+ #: no corpus from before Codex support can hold Codex sessions.
117
+ DEFAULT_AGENT: str = "claude-code"
118
+
119
+ #: Sessions per transaction when materialize hands the sink its published
120
+ #: sessions. Measured on the copied corpora (see the PR): per-batch cost is
121
+ #: dominated by the fixed attach/commit/publish overhead up to about this size.
122
+ DEFAULT_SYNC_BATCH_SIZE: int = 64
123
+
124
+ #: Sessions per transaction when a whole corpus is loaded (rebuild, or a
125
+ #: corpus the lake does not hold yet). Bounds the TEMP tables the registry
126
+ #: builds per batch (edges, loss reports, JSON-path trajectories).
127
+ DEFAULT_LOAD_BATCH_SIZE: int = 512
128
+
129
+ #: Processes that stage parquet from trajectories while the lake loads a
130
+ #: batch (``ATIF_SQL_LAKE_STAGE_WORKERS``). Each holds one decoded document
131
+ #: at a time; measured on the copied corpora in the PR that added it.
132
+ DEFAULT_STAGE_WORKERS: int = min(4, os.cpu_count() or 1)
133
+
134
+ #: How long a writer waits for another writer's lock before giving up. A
135
+ #: materialize tick that gives up records its sessions as pending and the
136
+ #: next tick retries them.
137
+ DEFAULT_LOCK_TIMEOUT_SECONDS: float = 600.0
138
+
139
+ #: Files ``lake compact`` scheduled for deletion, or found orphaned, are
140
+ #: removed only once they are this old, so a reader that opened the previous
141
+ #: published catalog a moment ago still finds every file it names.
142
+ CLEANUP_GRACE_HOURS: int = 1
143
+
144
+ #: The writer's DuckDB memory cap (lowered further by the caller's host- and
145
+ #: cgroup-derived cap). Measured on the copied corpora: loading every corpus
146
+ #: peaks at 3.5 GB RSS under a 2 GiB cap against 6.3 GB under 4 GiB, in about
147
+ #: the same time; spilling covers the rest.
148
+ DEFAULT_WRITER_MEMORY_BYTES: int = 2 * 1024**3
149
+
150
+ #: DuckDB threads for the writer's connection.
151
+ _WRITER_THREADS: int = 4
152
+
153
+ #: DuckDB threads while DuckLake merges or rewrites files. Each thread holds
154
+ #: its own share of the files being merged, and ``tool_results`` rows carry
155
+ #: whole tool outputs: under the 2 GiB cap, merging the copied corpora ran out
156
+ #: of memory at two threads and four, and finished at one (1.9 GB RSS).
157
+ _MAINTENANCE_THREADS: int = 1
158
+
159
+ #: Who the lake's snapshots name as their author.
160
+ _COMMIT_AUTHOR: str = "atif-sql"
161
+
162
+ _LOCK_POLL_SECONDS: float = 0.2
163
+
164
+
165
+ class LakeError(RuntimeError):
166
+ """The lake could not be written or read as asked."""
167
+
168
+
169
+ class LakeExtensionMissingError(LakeError):
170
+ """The ``ducklake`` DuckDB extension is not installed (``atif-sql lake rebuild`` installs it)."""
171
+
172
+
173
+ class LakeLockTimeoutError(LakeError):
174
+ """Another process held the lake's writer lock for longer than the timeout."""
175
+
176
+
177
+ class LakeCorpusConflictError(LakeError):
178
+ """Two corpus roots share a name, or a registered corpus moved; the lake keys corpora by name."""
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # Layout, lock, connections
183
+ # ---------------------------------------------------------------------------
184
+
185
+
186
+ @dataclass(frozen=True, slots=True)
187
+ class LakeLayout:
188
+ """Every path under one lake root. Pure path arithmetic."""
189
+
190
+ root: Path
191
+
192
+ @property
193
+ def catalog_path(self) -> Path:
194
+ """The writer's catalog, never opened by a reader."""
195
+ return self.root / "catalog.duckdb"
196
+
197
+ @property
198
+ def reader_catalog_path(self) -> Path:
199
+ """The catalog copy readers attach ``READ_ONLY``."""
200
+ return self.root / "catalog.reader.duckdb"
201
+
202
+ @property
203
+ def data_dir(self) -> Path:
204
+ """The data and delete files."""
205
+ return self.root / "data"
206
+
207
+ @property
208
+ def lock_path(self) -> Path:
209
+ """Beside the root rather than in it, because a rebuild swaps the root."""
210
+ return self.root.with_name(f"{self.root.name}.lock")
211
+
212
+ def exists(self) -> bool:
213
+ """True when a lake has been built here (both catalogs present)."""
214
+ return self.catalog_path.is_file() and self.reader_catalog_path.is_file()
215
+
216
+
217
+ @dataclass(frozen=True, slots=True)
218
+ class LakeCorpus:
219
+ """One corpus the lake holds: its root and the agent it holds."""
220
+
221
+ root: Path
222
+ agent: str
223
+
224
+ @property
225
+ def name(self) -> str:
226
+ """The corpus directory's name, which is how the lake keys a corpus."""
227
+ return self.root.name
228
+
229
+
230
+ def corpus_agent(corpus_root: Path, *, probe: int = 8) -> str:
231
+ """The agent a corpus holds, read from its first readable ``meta.json``.
232
+
233
+ Mirrors atif-corpus's own probe: a meta without the key answers
234
+ :data:`DEFAULT_AGENT`, and an empty corpus does too.
235
+ """
236
+ import json
237
+
238
+ sessions = corpus_root / "sessions"
239
+ if not sessions.is_dir():
240
+ return DEFAULT_AGENT
241
+ for session_dir in sorted(sessions.iterdir())[:probe]:
242
+ try:
243
+ meta = json.loads((session_dir / "meta.json").read_text(encoding="utf-8"))
244
+ except (OSError, ValueError):
245
+ continue
246
+ agent = meta.get(AGENT_COLUMN) if isinstance(meta, dict) else None
247
+ return agent if isinstance(agent, str) and agent else DEFAULT_AGENT
248
+ return DEFAULT_AGENT
249
+
250
+
251
+ def corpus_session_ids(corpus_root: Path) -> list[str]:
252
+ """Complete, well-named session dirs of a corpus, sorted (what a load registers)."""
253
+ sessions = corpus_root / "sessions"
254
+ if not sessions.is_dir():
255
+ return []
256
+ return sorted(
257
+ entry.name
258
+ for entry in sessions.iterdir()
259
+ if entry.is_dir()
260
+ and session_id_rejection(entry.name) is None
261
+ and (entry / "meta.json").is_file()
262
+ )
263
+
264
+
265
+ @contextlib.contextmanager
266
+ def writer_lock(layout: LakeLayout, *, timeout_seconds: float) -> Generator[None]:
267
+ """Hold the lake's writer lock (``flock`` on ``<root>.lock``), waiting up to the timeout."""
268
+ layout.lock_path.parent.mkdir(parents=True, exist_ok=True)
269
+ fd = os.open(layout.lock_path, os.O_RDWR | os.O_CREAT, 0o600)
270
+ try:
271
+ deadline = time.monotonic() + timeout_seconds
272
+ while True:
273
+ try:
274
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
275
+ break
276
+ except OSError as exc:
277
+ if exc.errno not in (errno.EAGAIN, errno.EACCES):
278
+ raise
279
+ if time.monotonic() >= deadline:
280
+ msg = f"another process held {layout.lock_path} for over {timeout_seconds:.0f}s"
281
+ raise LakeLockTimeoutError(msg) from exc
282
+ time.sleep(_LOCK_POLL_SECONDS)
283
+ try:
284
+ yield
285
+ finally:
286
+ fcntl.flock(fd, fcntl.LOCK_UN)
287
+ finally:
288
+ os.close(fd)
289
+
290
+
291
+ def ducklake_extension_installed(con: duckdb.DuckDBPyConnection) -> bool:
292
+ """True when the ducklake extension is in the local extension directory (no network)."""
293
+ row = con.execute(
294
+ "SELECT installed FROM duckdb_extensions() WHERE extension_name = ?", [DUCKLAKE_EXTENSION]
295
+ ).fetchone()
296
+ return row is not None and bool(row[0])
297
+
298
+
299
+ def install_ducklake_extension() -> None:
300
+ """Download the ducklake extension if it is absent. The one place the lake reaches the network."""
301
+ import duckdb
302
+
303
+ con = duckdb.connect()
304
+ try:
305
+ if not ducklake_extension_installed(con):
306
+ logger.info("lake: installing the {} DuckDB extension", DUCKLAKE_EXTENSION)
307
+ con.execute(f"INSTALL {DUCKLAKE_EXTENSION};")
308
+ finally:
309
+ con.close()
310
+
311
+
312
+ def load_ducklake(con: duckdb.DuckDBPyConnection) -> None:
313
+ """``LOAD ducklake``, never installing it; raise when it is absent."""
314
+ if not ducklake_extension_installed(con):
315
+ msg = (
316
+ f"the {DUCKLAKE_EXTENSION} DuckDB extension is not installed; "
317
+ "`atif-sql lake rebuild` installs it"
318
+ )
319
+ raise LakeExtensionMissingError(msg)
320
+ con.execute(f"LOAD {DUCKLAKE_EXTENSION};")
321
+
322
+
323
+ @contextlib.contextmanager
324
+ def _writer_connection(
325
+ memory_limit_bytes: int | None,
326
+ ) -> Generator[duckdb.DuckDBPyConnection]:
327
+ """An in-memory connection for writing: ducklake loaded, spill dir private and removed after."""
328
+ import duckdb
329
+
330
+ spill = Path(tempfile.mkdtemp(prefix="atif-sql-lake-"))
331
+ con = duckdb.connect()
332
+ try:
333
+ con.execute(f"SET threads={int(_WRITER_THREADS)}")
334
+ cap = min(memory_limit_bytes or DEFAULT_WRITER_MEMORY_BYTES, DEFAULT_WRITER_MEMORY_BYTES)
335
+ con.execute(f"SET memory_limit='{int(cap)}B'")
336
+ con.execute(f"SET temp_directory={sql_literal(str(spill))}")
337
+ con.execute("SET autoinstall_known_extensions=false")
338
+ con.execute("SET autoload_known_extensions=false")
339
+ load_ducklake(con)
340
+ yield con
341
+ finally:
342
+ con.close()
343
+ shutil.rmtree(spill, ignore_errors=True)
344
+
345
+
346
+ @contextlib.contextmanager
347
+ def _file_maintenance(con: duckdb.DuckDBPyConnection) -> Generator[None]:
348
+ """Run DuckLake's file merges and rewrites at :data:`_MAINTENANCE_THREADS`."""
349
+ con.execute(f"SET threads={int(_MAINTENANCE_THREADS)}")
350
+ try:
351
+ yield
352
+ finally:
353
+ con.execute(f"SET threads={int(_WRITER_THREADS)}")
354
+
355
+
356
+ @contextlib.contextmanager
357
+ def _source_order(con: duckdb.DuckDBPyConnection, table: str) -> Generator[None]:
358
+ """Insert a :data:`~atif_duck.domain.lake.SOURCE_ORDERED_TABLES` table on one thread.
359
+
360
+ A parallel partitioned insert interleaves its threads' rows, and those
361
+ tables' within-step order exists only as insertion order (``rowid``).
362
+ Measured on the copied corpora, one thread makes a whole rebuild take
363
+ about 2.5 times as long, which a rebuild can afford and a reader cannot
364
+ work around.
365
+ """
366
+ if table not in SOURCE_ORDERED_TABLES:
367
+ yield
368
+ return
369
+ con.execute("SET threads=1")
370
+ try:
371
+ yield
372
+ finally:
373
+ con.execute(f"SET threads={int(_WRITER_THREADS)}")
374
+
375
+
376
+ def _attach(
377
+ con: duckdb.DuckDBPyConnection, catalog: Path, data_dir: Path, *, read_only: bool
378
+ ) -> None:
379
+ """Attach a lake catalog as :data:`LAKE_ALIAS` with its data path pinned to ``data_dir``.
380
+
381
+ ``OVERRIDE_DATA_PATH`` on every attach: the catalog records the data path
382
+ it was created with, and a rebuild creates it under a temporary name
383
+ before swapping the directory into place. File paths are stored relative
384
+ to the data path, so the override is all a moved lake needs. ``ATTACH``
385
+ cannot be prepared, so both paths enter through :func:`sql_literal`.
386
+ """
387
+ if "?" in str(catalog):
388
+ # DuckDB reads everything after a '?' in a database path as options,
389
+ # so the catalog's WAL lands at a different path than the catalog
390
+ # and every commit fails (measured on 1.5.5).
391
+ msg = f"the lake root may not contain '?': {catalog.parent}"
392
+ raise LakeError(msg)
393
+ options = [
394
+ f"DATA_PATH {sql_literal(str(data_dir) + os.sep)}",
395
+ "OVERRIDE_DATA_PATH true",
396
+ "READ_ONLY" if read_only else "DATA_INLINING_ROW_LIMIT 0",
397
+ ]
398
+ con.execute(
399
+ f"ATTACH {sql_literal('ducklake:' + str(catalog))} AS {LAKE_ALIAS} ({', '.join(options)});"
400
+ )
401
+
402
+
403
+ def _detach(con: duckdb.DuckDBPyConnection) -> None:
404
+ con.execute(f"DETACH {LAKE_ALIAS};")
405
+
406
+
407
+ def _fsync_dir(path: Path) -> None:
408
+ fd = os.open(path, os.O_RDONLY)
409
+ try:
410
+ os.fsync(fd)
411
+ finally:
412
+ os.close(fd)
413
+
414
+
415
+ def _publish_reader_catalog(layout: LakeLayout) -> None:
416
+ """Copy the (detached, checkpointed) writer catalog over the reader copy, atomically."""
417
+ wal = layout.catalog_path.with_name(layout.catalog_path.name + ".wal")
418
+ if wal.exists():
419
+ msg = f"refusing to publish {layout.catalog_path}: it still has a WAL ({wal})"
420
+ raise LakeError(msg)
421
+ tmp = layout.root / f".{layout.reader_catalog_path.name}.tmp-{os.getpid()}"
422
+ shutil.copyfile(layout.catalog_path, tmp)
423
+ fd = os.open(tmp, os.O_RDONLY)
424
+ try:
425
+ os.fsync(fd)
426
+ finally:
427
+ os.close(fd)
428
+ tmp.chmod(0o444)
429
+ tmp.replace(layout.reader_catalog_path)
430
+ _fsync_dir(layout.root)
431
+
432
+
433
+ # ---------------------------------------------------------------------------
434
+ # Schema and bookkeeping
435
+ # ---------------------------------------------------------------------------
436
+
437
+
438
+ def _create_schema(con: duckdb.DuckDBPyConnection) -> None:
439
+ """Create every table in a fresh lake and record this code's schema identity."""
440
+ con.execute(f"CALL {LAKE_ALIAS}.set_option('parquet_compression', 'zstd');")
441
+ con.execute(f"CALL {LAKE_ALIAS}.set_option('data_inlining_row_limit', 0);")
442
+ con.execute(
443
+ f"CALL {LAKE_ALIAS}.set_option('parquet_row_group_size', {int(LAKE_ROW_GROUP_SIZE)});"
444
+ )
445
+ # One transaction, one snapshot: each DDL statement on its own is a
446
+ # catalog commit, which made schema creation most of a small rebuild.
447
+ con.execute("BEGIN TRANSACTION")
448
+ for statement in BOOTSTRAP_CREATE_SQL:
449
+ con.execute(statement)
450
+ for table in LAKE_TABLES:
451
+ con.execute(CREATE_TABLE_SQL[table.name])
452
+ con.execute(PARTITION_SQL[table.name])
453
+ con.executemany(
454
+ f"INSERT INTO {LAKE_ALIAS}.{LAKE_INFO_TABLE} VALUES (?, ?)", # noqa: S608 # nosec B608 - constants only
455
+ sorted(expected_lake_info().items()),
456
+ )
457
+ con.execute("COMMIT")
458
+
459
+
460
+ def _read_info(con: duckdb.DuckDBPyConnection) -> dict[str, str]:
461
+ rows = con.execute(f"SELECT key, value FROM {LAKE_ALIAS}.{LAKE_INFO_TABLE}").fetchall() # noqa: S608 # nosec B608 - constants only
462
+ return {str(key): str(value) for key, value in rows}
463
+
464
+
465
+ def _read_corpora(con: duckdb.DuckDBPyConnection) -> dict[str, LakeCorpus]:
466
+ rows = con.execute(
467
+ f"SELECT {CORPUS_COLUMN}, {AGENT_COLUMN}, corpus_root FROM {LAKE_ALIAS}.{CORPORA_TABLE}" # noqa: S608 # nosec B608 - constants only
468
+ ).fetchall()
469
+ return {
470
+ str(name): LakeCorpus(root=Path(str(root)), agent=str(agent)) for name, agent, root in rows
471
+ }
472
+
473
+
474
+ def _register_corpus(
475
+ con: duckdb.DuckDBPyConnection, corpus: LakeCorpus, registered_at: str
476
+ ) -> None:
477
+ con.execute(
478
+ f"DELETE FROM {LAKE_ALIAS}.{CORPORA_TABLE} WHERE {CORPUS_COLUMN} = ?", # noqa: S608 # nosec B608 - constants only
479
+ [corpus.name],
480
+ )
481
+ con.execute(
482
+ f"INSERT INTO {LAKE_ALIAS}.{CORPORA_TABLE} VALUES (?, ?, ?, ?)", # noqa: S608 # nosec B608 - constants only
483
+ [corpus.name, corpus.agent, str(corpus.root), registered_at],
484
+ )
485
+
486
+
487
+ def _commit_message(con: duckdb.DuckDBPyConnection, message: str) -> None:
488
+ con.execute(
489
+ f"CALL ducklake_set_commit_message({sql_literal(LAKE_ALIAS)}, ?, ?)",
490
+ [_COMMIT_AUTHOR, message],
491
+ )
492
+
493
+
494
+ def _now_iso() -> str:
495
+ from datetime import UTC, datetime
496
+
497
+ return datetime.now(tz=UTC).isoformat()
498
+
499
+
500
+ # ---------------------------------------------------------------------------
501
+ # Loading
502
+ # ---------------------------------------------------------------------------
503
+
504
+
505
+ @dataclass(frozen=True, slots=True)
506
+ class Staging:
507
+ """Where the loader stages a batch's parquet, and how many processes stage it."""
508
+
509
+ root: Path
510
+ workers: int = 1
511
+ #: The process pool staging runs on, opened by :func:`_stage_pool` for a
512
+ #: whole load (``None``: stage in this process).
513
+ pool: Executor | None = None
514
+
515
+ @classmethod
516
+ def beside(cls, layout: LakeLayout, *, workers: int = 1) -> Staging:
517
+ """``<root>.load-<pid>``, a sibling of the lake root (swept if a dead process left it)."""
518
+ return cls(layout.root.with_name(f"{layout.root.name}.load-{os.getpid()}"), workers)
519
+
520
+
521
+ @contextlib.contextmanager
522
+ def _stage_pool(staging: Staging) -> Generator[Staging]:
523
+ """``staging`` with a spawn-context process pool of ``staging.workers``, for a whole load.
524
+
525
+ One pool for every batch of a load, so workers start once rather than
526
+ once per batch. With one worker, staging runs in this process.
527
+ """
528
+ if staging.workers <= 1 or staging.pool is not None:
529
+ yield staging
530
+ return
531
+ import multiprocessing
532
+ from concurrent.futures import ProcessPoolExecutor
533
+
534
+ with ProcessPoolExecutor(
535
+ max_workers=staging.workers, mp_context=multiprocessing.get_context("spawn")
536
+ ) as pool:
537
+ yield dataclasses.replace(staging, pool=pool)
538
+
539
+
540
+ def _stage_session(corpus_root: Path, stage_root: Path, session_id: str) -> bool:
541
+ """Write one session's five parquet files under ``stage_root/<id>/`` from its stored trajectory.
542
+
543
+ Module-level so a spawned pool worker can run it. ``False`` when the
544
+ session has no readable trajectory document (the registry then reads it
545
+ through the JSON path, which reports the problem).
546
+ """
547
+ import json
548
+
549
+ from atif_duck.infrastructure.columnar import ColumnarArtifactProducer
550
+
551
+ session_dir = corpus_root / "sessions" / session_id
552
+ trajectory_file = stored_artifact(session_dir, TRAJECTORY_JSON)
553
+ if trajectory_file is None:
554
+ return False
555
+ trajectory = json.loads(read_bytes(trajectory_file))
556
+ if not isinstance(trajectory, dict):
557
+ return False
558
+ target = stage_root / session_id
559
+ shutil.rmtree(target, ignore_errors=True)
560
+ target.mkdir(parents=True)
561
+ ColumnarArtifactProducer(durable=False).produce(
562
+ target,
563
+ session_id=session_id,
564
+ trajectory=trajectory,
565
+ events_path=stored_artifact(session_dir, SESSION_EVENTS_JSONL),
566
+ )
567
+ return True
568
+
569
+
570
+ @contextlib.contextmanager
571
+ def _staged_columnar(
572
+ staging: Staging, corpus_root: Path
573
+ ) -> Generator[Callable[[Sequence[str]], dict[str, Path]]]:
574
+ """A ``stage_columnar`` callback for :func:`register_raw`, and the cleanup of what it staged.
575
+
576
+ For each session the registry would read through DuckDB's JSON reader,
577
+ the callback decodes the stored trajectory in Python and hands it to the
578
+ columnar producer, which writes the session's five parquet files under
579
+ ``<staging root>/<id>/``: the same rows the JSON reader yields (the producer
580
+ is held to that by the columnar parity tests), typed in bounded memory.
581
+ DuckDB's JSON reader needs several times a document's size to parse it,
582
+ which on the largest sessions is more than the writer's whole memory cap.
583
+
584
+ With a pool (:func:`_stage_pool`) and more than one session, the
585
+ sessions are staged in parallel, one per task so a large one doesn't hold
586
+ up a chunk of small ones (each worker holds one document at a time); the
587
+ files, and so the rows the batch inserts, are the same either way. The
588
+ files are deleted when the batch is done with them.
589
+ """
590
+ stage_root, pool = staging.root, staging.pool
591
+
592
+ def stage(session_ids: Sequence[str]) -> dict[str, Path]:
593
+ ids = list(session_ids)
594
+ if pool is not None and len(ids) > 1:
595
+ done = list(
596
+ pool.map(_stage_session, [corpus_root] * len(ids), [stage_root] * len(ids), ids)
597
+ )
598
+ else:
599
+ done = [_stage_session(corpus_root, stage_root, sid) for sid in ids]
600
+ return {sid: stage_root / sid for sid, ok in zip(ids, done, strict=True) if ok}
601
+
602
+ shutil.rmtree(stage_root, ignore_errors=True)
603
+ try:
604
+ yield stage
605
+ finally:
606
+ shutil.rmtree(stage_root, ignore_errors=True)
607
+
608
+
609
+ def _load_batch(
610
+ con: duckdb.DuckDBPyConnection,
611
+ corpus: LakeCorpus,
612
+ session_ids: Sequence[str],
613
+ *,
614
+ staging: Staging,
615
+ replace_corpus: bool = False,
616
+ delete: bool = True,
617
+ register_at: str | None = None,
618
+ ) -> None:
619
+ """Replace ``session_ids``' rows (or, with ``replace_corpus``, the corpus's) in one transaction.
620
+
621
+ ``delete=False`` skips the per-session DELETE for a batch the caller
622
+ knows has no rows yet (a whole-corpus load after its first batch cleared
623
+ the corpus); each DELETE costs a scan of the table's files even when it
624
+ matches nothing.
625
+
626
+ The registry binds the batch's raw relations first (TEMP tables and
627
+ lazy parquet views in the in-memory catalog): a DuckDB transaction may
628
+ write to one database only, and the lake is that one. A session with
629
+ current parquet artifacts of its own is read from them; every other one
630
+ from parquet staged from its trajectory (:func:`_staged_columnar`).
631
+ """
632
+ with _staged_columnar(staging, corpus.root) as stage:
633
+ _insert_batch(
634
+ con,
635
+ corpus,
636
+ session_ids,
637
+ stage=stage,
638
+ replace_corpus=replace_corpus,
639
+ delete=delete,
640
+ register_at=register_at,
641
+ )
642
+
643
+
644
+ def _insert_batch(
645
+ con: duckdb.DuckDBPyConnection,
646
+ corpus: LakeCorpus,
647
+ session_ids: Sequence[str],
648
+ *,
649
+ stage: Callable[[Sequence[str]], Mapping[str, Path]],
650
+ replace_corpus: bool,
651
+ delete: bool,
652
+ register_at: str | None,
653
+ ) -> None:
654
+ from atif_duck.infrastructure.registry import register_raw
655
+
656
+ register_raw(con, corpus.root, session_ids=session_ids, stage_columnar=stage)
657
+ con.execute("BEGIN TRANSACTION")
658
+ try:
659
+ for table in LAKE_TABLES:
660
+ if replace_corpus:
661
+ con.execute(DELETE_CORPUS_SQL[table.name], [corpus.name])
662
+ elif delete:
663
+ con.execute(DELETE_SESSIONS_SQL[table.name], [corpus.name, list(session_ids)])
664
+ with _source_order(con, table.name):
665
+ con.execute(INSERT_SQL[table.name], [corpus.name, corpus.agent])
666
+ if register_at is not None:
667
+ _register_corpus(con, corpus, register_at)
668
+ _commit_message(con, f"{corpus.name}: {len(session_ids)} session(s)")
669
+ con.execute("COMMIT")
670
+ except BaseException:
671
+ with contextlib.suppress(Exception):
672
+ con.execute("ROLLBACK")
673
+ raise
674
+
675
+
676
+ def _load_corpus(
677
+ con: duckdb.DuckDBPyConnection, corpus: LakeCorpus, *, batch_size: int, staging: Staging
678
+ ) -> int:
679
+ """Load a whole corpus in batches; the corpus is registered by the LAST batch's transaction.
680
+
681
+ The first batch also deletes every row the corpus had, so a corpus a
682
+ crashed load left half-written starts clean. Until the last batch
683
+ commits the corpus is unregistered and a reader falls back to the
684
+ per-session path for it.
685
+ """
686
+ ids = corpus_session_ids(corpus.root)
687
+ batches = [ids[i : i + batch_size] for i in range(0, len(ids), batch_size)] or [[]]
688
+ registered_at = _now_iso()
689
+ with _stage_pool(staging) as pooled:
690
+ for index, batch in enumerate(batches):
691
+ _load_batch(
692
+ con,
693
+ corpus,
694
+ batch,
695
+ staging=pooled,
696
+ replace_corpus=index == 0,
697
+ delete=False,
698
+ register_at=registered_at if index == len(batches) - 1 else None,
699
+ )
700
+ logger.info("lake: loaded corpus {} ({} session(s))", corpus.name, len(ids))
701
+ return len(ids)
702
+
703
+
704
+ # ---------------------------------------------------------------------------
705
+ # Rebuild
706
+ # ---------------------------------------------------------------------------
707
+
708
+
709
+ @dataclass(frozen=True, slots=True)
710
+ class RebuildReport:
711
+ """What ``lake rebuild`` did."""
712
+
713
+ root: Path
714
+ corpora: tuple[tuple[str, str, int], ...]
715
+ seconds: float
716
+ data_files: int
717
+
718
+
719
+ def _pid_alive(pid: int) -> bool:
720
+ try:
721
+ os.kill(pid, 0)
722
+ except ProcessLookupError:
723
+ return False
724
+ except PermissionError:
725
+ return True
726
+ return True
727
+
728
+
729
+ def _sweep_siblings(layout: LakeLayout) -> None:
730
+ """Remove ``<root>.rebuild-<pid>`` / ``.old-<pid>`` / ``.load-<pid>`` left by a dead process."""
731
+ parent = layout.root.parent
732
+ if not parent.is_dir():
733
+ return
734
+ for suffix in ("rebuild", "old", "load"):
735
+ for entry in parent.glob(f"{layout.root.name}.{suffix}-*"):
736
+ tail = entry.name.rsplit("-", 1)[-1]
737
+ if tail.isdigit() and not _pid_alive(int(tail)):
738
+ logger.info("lake: removing {} left by a dead process", entry)
739
+ shutil.rmtree(entry, ignore_errors=True)
740
+
741
+
742
+ def _count_live_files(con: duckdb.DuckDBPyConnection) -> int:
743
+ row = con.execute(
744
+ f"SELECT count(*) FROM {LAKE_METADATA_ALIAS}.ducklake_data_file WHERE end_snapshot IS NULL" # noqa: S608 # nosec B608 - constants only
745
+ ).fetchone()
746
+ return int(row[0]) if row else 0
747
+
748
+
749
+ def _check_unique_names(corpora: Sequence[LakeCorpus]) -> None:
750
+ seen: dict[str, Path] = {}
751
+ for corpus in corpora:
752
+ other = seen.get(corpus.name)
753
+ if other is not None and other != corpus.root:
754
+ msg = f"two corpora share the name {corpus.name!r}: {other} and {corpus.root}"
755
+ raise LakeCorpusConflictError(msg)
756
+ seen[corpus.name] = corpus.root
757
+
758
+
759
+ def _rebuild_locked(
760
+ layout: LakeLayout,
761
+ corpora: Sequence[LakeCorpus],
762
+ *,
763
+ batch_size: int,
764
+ memory_limit_bytes: int | None,
765
+ stage_workers: int = 1,
766
+ ) -> RebuildReport:
767
+ """Build a fresh lake beside the old one, then swap it in. Caller holds the lock."""
768
+ started = time.perf_counter()
769
+ unique = {corpus.root: corpus for corpus in corpora}
770
+ ordered = sorted(unique.values(), key=lambda corpus: (corpus.name, str(corpus.root)))
771
+ _check_unique_names(ordered)
772
+ _sweep_siblings(layout)
773
+ build = LakeLayout(layout.root.with_name(f"{layout.root.name}.rebuild-{os.getpid()}"))
774
+ shutil.rmtree(build.root, ignore_errors=True)
775
+ build.data_dir.mkdir(parents=True)
776
+ loaded: list[tuple[str, str, int]] = []
777
+ try:
778
+ with _writer_connection(memory_limit_bytes) as con:
779
+ _attach(con, build.catalog_path, build.data_dir, read_only=False)
780
+ _create_schema(con)
781
+ for corpus in ordered:
782
+ count = _load_corpus(
783
+ con,
784
+ corpus,
785
+ batch_size=batch_size,
786
+ staging=Staging.beside(layout, workers=stage_workers),
787
+ )
788
+ loaded.append((corpus.name, corpus.agent, count))
789
+ # A load in batches leaves several small files per partition.
790
+ with _file_maintenance(con):
791
+ con.execute(f"CALL ducklake_merge_adjacent_files({sql_literal(LAKE_ALIAS)});")
792
+ con.execute(
793
+ f"CALL ducklake_expire_snapshots({sql_literal(LAKE_ALIAS)}, older_than => now());"
794
+ )
795
+ con.execute(
796
+ f"CALL ducklake_cleanup_old_files({sql_literal(LAKE_ALIAS)}, cleanup_all => true);"
797
+ )
798
+ data_files = _count_live_files(con)
799
+ _detach(con)
800
+ _publish_reader_catalog(build)
801
+ old = layout.root.with_name(f"{layout.root.name}.old-{os.getpid()}")
802
+ if layout.root.exists():
803
+ layout.root.replace(old)
804
+ build.root.replace(layout.root)
805
+ _fsync_dir(layout.root.parent)
806
+ shutil.rmtree(old, ignore_errors=True)
807
+ except BaseException:
808
+ shutil.rmtree(build.root, ignore_errors=True)
809
+ raise
810
+ seconds = time.perf_counter() - started
811
+ logger.info("lake: rebuilt {} with {} corpora in {:.1f}s", layout.root, len(loaded), seconds)
812
+ return RebuildReport(
813
+ root=layout.root, corpora=tuple(loaded), seconds=seconds, data_files=data_files
814
+ )
815
+
816
+
817
+ def registered_corpora(layout: LakeLayout) -> dict[str, LakeCorpus]:
818
+ """The corpora the published lake holds (empty when there is no lake or it cannot be read)."""
819
+ import duckdb
820
+
821
+ if not layout.exists():
822
+ return {}
823
+ con = duckdb.connect()
824
+ try:
825
+ load_ducklake(con)
826
+ _attach(con, layout.reader_catalog_path, layout.data_dir, read_only=True)
827
+ return _read_corpora(con)
828
+ except (duckdb.Error, LakeError) as exc:
829
+ logger.warning("lake: cannot read the corpora {} holds: {}", layout.root, exc)
830
+ return {}
831
+ finally:
832
+ con.close()
833
+
834
+
835
+ def rebuild_lake(
836
+ layout: LakeLayout,
837
+ corpora: Sequence[LakeCorpus],
838
+ *,
839
+ batch_size: int = DEFAULT_LOAD_BATCH_SIZE,
840
+ lock_timeout_seconds: float = DEFAULT_LOCK_TIMEOUT_SECONDS,
841
+ memory_limit_bytes: int | None = None,
842
+ stage_workers: int = 1,
843
+ ) -> RebuildReport:
844
+ """Load every corpus's per-session artifacts into a fresh lake and swap it into place.
845
+
846
+ The new lake is built at ``<root>.rebuild-<pid>`` and renamed over the old
847
+ root only once complete, so a reader sees the whole old lake or the whole
848
+ new one (or, for the instant between the two renames, none, and falls
849
+ back to the per-session path). The writer lock is held throughout, so no
850
+ materialize pass writes to the lake being replaced.
851
+ """
852
+ with writer_lock(layout, timeout_seconds=lock_timeout_seconds):
853
+ return _rebuild_locked(
854
+ layout,
855
+ corpora,
856
+ batch_size=batch_size,
857
+ memory_limit_bytes=memory_limit_bytes,
858
+ stage_workers=stage_workers,
859
+ )
860
+
861
+
862
+ # ---------------------------------------------------------------------------
863
+ # The materialize sink
864
+ # ---------------------------------------------------------------------------
865
+
866
+
867
+ @dataclass(slots=True)
868
+ class DuckLakeSessionSink:
869
+ """Replace published sessions' rows in the lake: atif-corpus's ``SessionSink``.
870
+
871
+ Satisfies the port structurally (the two packages may not import each
872
+ other). Runs in materialize's parent process only; nothing here is
873
+ pickled into a pool worker.
874
+ """
875
+
876
+ layout: LakeLayout
877
+ lock_timeout_seconds: float = DEFAULT_LOCK_TIMEOUT_SECONDS
878
+ load_batch_size: int = DEFAULT_LOAD_BATCH_SIZE
879
+ memory_limit_bytes: int | None = None
880
+ #: Processes that stage a batch's parquet from its trajectories.
881
+ stage_workers: int = 1
882
+ #: How many sync calls wrote rows (for tests and the report).
883
+ writes: int = field(default=0)
884
+
885
+ def sync_sessions(self, *, corpus_root: Path, agent: str, session_ids: Sequence[str]) -> None:
886
+ """Make the lake's rows for ``session_ids`` equal their published artifacts.
887
+
888
+ Returns normally when that holds afterwards, including when there is
889
+ no lake at all (``lake rebuild`` loads everything when one is made).
890
+ Raises on anything else; materialize then records the sessions as
891
+ pending and retries them next pass.
892
+ """
893
+ if not session_ids:
894
+ return
895
+ if not self.layout.exists():
896
+ logger.debug("lake: no lake at {}; nothing to sync", self.layout.root)
897
+ return
898
+ corpus = LakeCorpus(root=corpus_root, agent=agent)
899
+ with writer_lock(self.layout, timeout_seconds=self.lock_timeout_seconds):
900
+ action, known = self._plan(corpus)
901
+ if action == "rebuild":
902
+ logger.warning(
903
+ "lake: {} was written by another schema; rebuilding it", self.layout.root
904
+ )
905
+ _rebuild_locked(
906
+ self.layout,
907
+ [*known.values(), corpus],
908
+ batch_size=self.load_batch_size,
909
+ memory_limit_bytes=self.memory_limit_bytes,
910
+ stage_workers=self.stage_workers,
911
+ )
912
+ self.writes += 1
913
+ return
914
+ with _writer_connection(self.memory_limit_bytes) as con:
915
+ _attach(con, self.layout.catalog_path, self.layout.data_dir, read_only=False)
916
+ staging = Staging.beside(self.layout, workers=self.stage_workers)
917
+ if action == "load_corpus":
918
+ _load_corpus(con, corpus, batch_size=self.load_batch_size, staging=staging)
919
+ else:
920
+ ids = sorted(set(session_ids))
921
+ # A pool only pays for its start-up on more sessions than workers.
922
+ if len(ids) <= self.stage_workers:
923
+ staging = Staging(staging.root)
924
+ with _stage_pool(staging) as pooled:
925
+ _load_batch(con, corpus, ids, staging=pooled)
926
+ _detach(con)
927
+ _publish_reader_catalog(self.layout)
928
+ self.writes += 1
929
+
930
+ def _plan(self, corpus: LakeCorpus) -> tuple[str, dict[str, LakeCorpus]]:
931
+ """``rebuild`` (stale schema), ``load_corpus`` (not registered yet) or ``sync``."""
932
+ with _writer_connection(self.memory_limit_bytes) as con:
933
+ _attach(con, self.layout.catalog_path, self.layout.data_dir, read_only=False)
934
+ known = _read_corpora(con)
935
+ stale = lake_info_mismatches(_read_info(con))
936
+ _detach(con)
937
+ if stale:
938
+ return "rebuild", known
939
+ current = known.get(corpus.name)
940
+ if current is None:
941
+ return "load_corpus", known
942
+ if current.root.resolve() != corpus.root.resolve():
943
+ msg = (
944
+ f"the lake already holds a corpus named {corpus.name!r} from {current.root}, "
945
+ f"not {corpus.root}; run `atif-sql lake rebuild` with the roots you mean"
946
+ )
947
+ raise LakeCorpusConflictError(msg)
948
+ return "sync", known
949
+
950
+
951
+ # ---------------------------------------------------------------------------
952
+ # Reading (the query path)
953
+ # ---------------------------------------------------------------------------
954
+
955
+
956
+ @dataclass(frozen=True, slots=True)
957
+ class LakeReader:
958
+ """A lake attached to a query connection, and what the sandbox must grant."""
959
+
960
+ #: The corpus the views are scoped to, or ``None`` for every corpus.
961
+ corpus: str | None
962
+ #: Every data and delete file of the attached snapshot.
963
+ grants: tuple[Path, ...]
964
+ #: The corpora the lake holds.
965
+ corpora: tuple[str, ...]
966
+ #: Their roots, in corpus-name order: where each corpus's analytics
967
+ #: parquets live, which an every-corpus registration reads from all of.
968
+ corpus_roots: tuple[Path, ...] = ()
969
+
970
+
971
+ @dataclass(frozen=True, slots=True)
972
+ class LakeUnavailable:
973
+ """Why the query path cannot use the lake (it falls back to the per-session path)."""
974
+
975
+ reason: str
976
+
977
+
978
+ def _snapshot_files(con: duckdb.DuckDBPyConnection) -> tuple[Path, ...]:
979
+ paths: set[str] = set()
980
+ for table in (LAKE_INFO_TABLE, CORPORA_TABLE, *(t.name for t in LAKE_TABLES)):
981
+ rows = con.execute(
982
+ "SELECT data_file, delete_file FROM ducklake_list_files(?, ?)", [LAKE_ALIAS, table]
983
+ ).fetchall()
984
+ for data_file, delete_file in rows:
985
+ paths.add(str(data_file))
986
+ if delete_file:
987
+ paths.add(str(delete_file))
988
+ return tuple(Path(path) for path in sorted(paths))
989
+
990
+
991
+ def attach_lake_for_query(
992
+ con: duckdb.DuckDBPyConnection,
993
+ layout: LakeLayout,
994
+ *,
995
+ corpus_root: Path,
996
+ all_corpora: bool,
997
+ ) -> LakeReader | LakeUnavailable:
998
+ """Attach the published lake ``READ_ONLY`` for ``query``, or say why not.
999
+
1000
+ Never installs anything: a missing extension is a reason to fall back.
1001
+ Must run before the sandbox locks the connection, because ``ATTACH``
1002
+ is one of the statements the lockdown refuses.
1003
+ """
1004
+ import duckdb as _duckdb
1005
+
1006
+ if not layout.exists():
1007
+ return LakeUnavailable(f"no lake at {layout.root} (run `atif-sql lake rebuild`)")
1008
+ if not ducklake_extension_installed(con):
1009
+ return LakeUnavailable(f"the {DUCKLAKE_EXTENSION} extension is not installed")
1010
+ try:
1011
+ con.execute(f"LOAD {DUCKLAKE_EXTENSION};")
1012
+ _attach(con, layout.reader_catalog_path, layout.data_dir, read_only=True)
1013
+ except _duckdb.Error as exc:
1014
+ return LakeUnavailable(f"the lake at {layout.root} did not attach: {exc}")
1015
+ stale = lake_info_mismatches(_read_info(con))
1016
+ known = _read_corpora(con)
1017
+ reason: str | None = None
1018
+ if stale:
1019
+ reason = f"the lake's schema is stale ({', '.join(stale)}); materialize rebuilds it"
1020
+ elif not all_corpora:
1021
+ current = known.get(corpus_root.name)
1022
+ if current is None:
1023
+ reason = f"the lake holds no corpus {corpus_root.name!r}"
1024
+ elif current.root.resolve() != corpus_root.resolve():
1025
+ reason = f"the lake's corpus {corpus_root.name!r} is {current.root}, not {corpus_root}"
1026
+ if reason is not None:
1027
+ _detach(con)
1028
+ return LakeUnavailable(reason)
1029
+ return LakeReader(
1030
+ corpus=None if all_corpora else corpus_root.name,
1031
+ grants=_snapshot_files(con),
1032
+ corpora=tuple(sorted(known)),
1033
+ corpus_roots=tuple(known[name].root for name in sorted(known)),
1034
+ )
1035
+
1036
+
1037
+ def register_lake_raw(con: duckdb.DuckDBPyConnection, reader: LakeReader) -> RawSources:
1038
+ """Bind every raw relation the views read as a view over its lake table.
1039
+
1040
+ Scoped to ``reader.corpus`` unless it is ``None``. The corpus name enters
1041
+ the view text through :func:`sql_literal` (``CREATE VIEW`` cannot be
1042
+ prepared). Nothing is loaded eagerly: every view is a lake scan at
1043
+ caller-query time.
1044
+ """
1045
+ from atif_duck.infrastructure.registry import RawSources
1046
+
1047
+ where = (
1048
+ SqlFragment("")
1049
+ if reader.corpus is None
1050
+ else SqlFragment(f" WHERE {CORPUS_COLUMN} = {sql_literal(reader.corpus)}")
1051
+ )
1052
+ for table in LAKE_TABLES:
1053
+ con.execute(
1054
+ f"CREATE OR REPLACE VIEW {table.relation} AS {READER_SELECT_SQL[table.name]}{where};" # nosec B608 - constants; the corpus is a sql_literal
1055
+ )
1056
+ return RawSources(
1057
+ columnar_session_ids=(),
1058
+ json_session_ids=(),
1059
+ lazy_read_paths=reader.grants,
1060
+ lake_corpus=reader.corpus,
1061
+ from_lake=True,
1062
+ )
1063
+
1064
+
1065
+ # ---------------------------------------------------------------------------
1066
+ # Verify
1067
+ # ---------------------------------------------------------------------------
1068
+
1069
+
1070
+ @dataclass(frozen=True, slots=True)
1071
+ class LakeMismatch:
1072
+ """One (table, session) whose lake rows differ from its artifacts' rows."""
1073
+
1074
+ corpus: str
1075
+ table: str
1076
+ session_id: str
1077
+ artifact_rows: int
1078
+ lake_rows: int
1079
+
1080
+
1081
+ @dataclass(frozen=True, slots=True)
1082
+ class VerifyReport:
1083
+ """What ``lake verify`` compared and what differed."""
1084
+
1085
+ stale: tuple[str, ...]
1086
+ corpora: tuple[tuple[str, int], ...]
1087
+ mismatches: tuple[LakeMismatch, ...]
1088
+
1089
+ @property
1090
+ def clean(self) -> bool:
1091
+ """True when the schema is current and every session matched."""
1092
+ return not self.stale and not self.mismatches
1093
+
1094
+
1095
+ def _hashes(
1096
+ con: duckdb.DuckDBPyConnection, sql: SqlFragment, params: list[Any]
1097
+ ) -> dict[str, tuple[int, int]]:
1098
+ return {str(k): (int(n), int(h or 0)) for k, n, h in con.execute(sql, params).fetchall()}
1099
+
1100
+
1101
+ def verify_lake(
1102
+ layout: LakeLayout,
1103
+ corpora: Sequence[LakeCorpus] | None = None,
1104
+ *,
1105
+ memory_limit_bytes: int | None = None,
1106
+ use_session_parquet: bool = True,
1107
+ batch_size: int = DEFAULT_LOAD_BATCH_SIZE,
1108
+ stage_workers: int = 1,
1109
+ ) -> VerifyReport:
1110
+ """Compare every session's rows, table by table, between the lake and its artifacts.
1111
+
1112
+ Per (table, session): the row count and an order-free content hash
1113
+ (``sum(hash(columns))``) of the lake rows against the same over the
1114
+ registry's raw relation for that corpus, bound batch by batch exactly as
1115
+ a load binds it (a session's own current parquet, else parquet staged
1116
+ from its trajectory). A session present on one side only differs by
1117
+ construction. Reads the published catalog, so it needs no lock and can
1118
+ run beside a writer (a write that lands mid-verify can show up as a
1119
+ difference; run it again).
1120
+
1121
+ ``use_session_parquet=False`` reads every session from its trajectory,
1122
+ ignoring the parquet artifacts: what the corpus holds once they're gone,
1123
+ which is what ``corpus slim`` checks before it deletes them.
1124
+ """
1125
+ if not layout.exists():
1126
+ return VerifyReport(stale=("no lake",), corpora=(), mismatches=())
1127
+ from atif_duck.infrastructure.registry import register_raw
1128
+
1129
+ mismatches: list[LakeMismatch] = []
1130
+ checked: list[tuple[str, int]] = []
1131
+ with _writer_connection(memory_limit_bytes) as con, contextlib.ExitStack() as stack:
1132
+ _attach(con, layout.reader_catalog_path, layout.data_dir, read_only=True)
1133
+ stale = lake_info_mismatches(_read_info(con))
1134
+ known = _read_corpora(con)
1135
+ targets = list(corpora) if corpora is not None else list(known.values())
1136
+ if stale:
1137
+ return VerifyReport(stale=stale, corpora=(), mismatches=())
1138
+ staging = stack.enter_context(_stage_pool(Staging.beside(layout, workers=stage_workers)))
1139
+ for corpus in sorted(targets, key=lambda c: c.name):
1140
+ ids = corpus_session_ids(corpus.root)
1141
+ batches = [ids[i : i + batch_size] for i in range(0, len(ids), batch_size)] or [[]]
1142
+ artifacts: dict[str, dict[str, tuple[int, int]]] = {t.name: {} for t in LAKE_TABLES}
1143
+ for batch in batches:
1144
+ with _staged_columnar(staging, corpus.root) as stage:
1145
+ register_raw(
1146
+ con,
1147
+ corpus.root,
1148
+ session_ids=batch,
1149
+ use_session_parquet=use_session_parquet,
1150
+ stage_columnar=stage,
1151
+ )
1152
+ for table in LAKE_TABLES:
1153
+ artifacts[table.name].update(
1154
+ _hashes(con, ARTIFACT_HASH_SQL[table.name], [])
1155
+ )
1156
+ sessions: set[str] = set()
1157
+ for table in LAKE_TABLES:
1158
+ artifact = artifacts[table.name]
1159
+ lake = _hashes(con, LAKE_HASH_SQL[table.name], [corpus.name])
1160
+ sessions.update(artifact)
1161
+ sessions.update(lake)
1162
+ for session_id in sorted(set(artifact) | set(lake)):
1163
+ left, right = artifact.get(session_id), lake.get(session_id)
1164
+ if left != right:
1165
+ mismatches.append(
1166
+ LakeMismatch(
1167
+ corpus=corpus.name,
1168
+ table=table.name,
1169
+ session_id=session_id,
1170
+ artifact_rows=left[0] if left else 0,
1171
+ lake_rows=right[0] if right else 0,
1172
+ )
1173
+ )
1174
+ checked.append((corpus.name, len(sessions)))
1175
+ return VerifyReport(stale=(), corpora=tuple(checked), mismatches=tuple(mismatches))
1176
+
1177
+
1178
+ # ---------------------------------------------------------------------------
1179
+ # Status
1180
+ # ---------------------------------------------------------------------------
1181
+
1182
+
1183
+ @dataclass(frozen=True, slots=True)
1184
+ class LakeStatus:
1185
+ """What ``lake status`` (and the lake block of ``status``) reports."""
1186
+
1187
+ root: Path
1188
+ present: bool
1189
+ extension_installed: bool
1190
+ stale: tuple[str, ...] = ()
1191
+ schema: dict[str, str] = field(default_factory=dict)
1192
+ corpora: tuple[tuple[str, str, str, int], ...] = ()
1193
+ snapshots: int = 0
1194
+ data_files: int = 0
1195
+ delete_files: int = 0
1196
+ data_bytes: int = 0
1197
+ last_write: str | None = None
1198
+ error: str | None = None
1199
+
1200
+ def as_dict(self) -> dict[str, Any]:
1201
+ """JSON-ready form."""
1202
+ return {
1203
+ "root": str(self.root),
1204
+ "present": self.present,
1205
+ "extension_installed": self.extension_installed,
1206
+ "schema_current": self.present and not self.stale and self.error is None,
1207
+ "stale": list(self.stale),
1208
+ "schema": self.schema,
1209
+ "corpora": [
1210
+ {"corpus": name, "agent": agent, "corpus_root": root, "sessions": sessions}
1211
+ for name, agent, root, sessions in self.corpora
1212
+ ],
1213
+ "snapshots": self.snapshots,
1214
+ "data_files": self.data_files,
1215
+ "delete_files": self.delete_files,
1216
+ "data_bytes": self.data_bytes,
1217
+ "last_write": self.last_write,
1218
+ "error": self.error,
1219
+ }
1220
+
1221
+
1222
+ def lake_status(layout: LakeLayout) -> LakeStatus:
1223
+ """Read the published catalog: schema identity, corpora, snapshots, files, last write."""
1224
+ import duckdb
1225
+
1226
+ con = duckdb.connect()
1227
+ try:
1228
+ installed = ducklake_extension_installed(con)
1229
+ if not layout.exists() or not installed:
1230
+ return LakeStatus(
1231
+ root=layout.root, present=layout.exists(), extension_installed=installed
1232
+ )
1233
+ try:
1234
+ con.execute(f"LOAD {DUCKLAKE_EXTENSION};")
1235
+ _attach(con, layout.reader_catalog_path, layout.data_dir, read_only=True)
1236
+ info = _read_info(con)
1237
+ known = _read_corpora(con)
1238
+ counts = dict(
1239
+ con.execute(
1240
+ f"SELECT {CORPUS_COLUMN}, count(*) FROM {LAKE_ALIAS}.session_meta GROUP BY 1" # noqa: S608 # nosec B608 - constants only
1241
+ ).fetchall()
1242
+ )
1243
+ snap = con.execute(
1244
+ f"SELECT count(*), max(snapshot_time) FROM ducklake_snapshots({sql_literal(LAKE_ALIAS)})" # noqa: S608 # nosec B608 - constants only
1245
+ ).fetchone()
1246
+ files = con.execute(
1247
+ f"SELECT count(*), coalesce(sum(file_size_bytes), 0) FROM {LAKE_METADATA_ALIAS}.ducklake_data_file WHERE end_snapshot IS NULL" # noqa: S608 # nosec B608 - constants only
1248
+ ).fetchone()
1249
+ deletes = con.execute(
1250
+ f"SELECT count(*) FROM {LAKE_METADATA_ALIAS}.ducklake_delete_file WHERE end_snapshot IS NULL" # noqa: S608 # nosec B608 - constants only
1251
+ ).fetchone()
1252
+ except duckdb.Error as exc:
1253
+ return LakeStatus(
1254
+ root=layout.root, present=True, extension_installed=installed, error=str(exc)
1255
+ )
1256
+ return LakeStatus(
1257
+ root=layout.root,
1258
+ present=True,
1259
+ extension_installed=True,
1260
+ stale=lake_info_mismatches(info),
1261
+ schema=info,
1262
+ corpora=tuple(
1263
+ (name, corpus.agent, str(corpus.root), int(counts.get(name, 0)))
1264
+ for name, corpus in sorted(known.items())
1265
+ ),
1266
+ snapshots=int(snap[0]) if snap else 0,
1267
+ last_write=snap[1].isoformat() if snap and snap[1] is not None else None,
1268
+ data_files=int(files[0]) if files else 0,
1269
+ data_bytes=int(files[1]) if files else 0,
1270
+ delete_files=int(deletes[0]) if deletes else 0,
1271
+ )
1272
+ finally:
1273
+ con.close()
1274
+
1275
+
1276
+ # ---------------------------------------------------------------------------
1277
+ # Compact
1278
+ # ---------------------------------------------------------------------------
1279
+
1280
+
1281
+ @dataclass(frozen=True, slots=True)
1282
+ class CompactReport:
1283
+ """File and snapshot counts before and after ``lake compact``."""
1284
+
1285
+ files_before: int
1286
+ files_after: int
1287
+ snapshots_before: int
1288
+ snapshots_after: int
1289
+ seconds: float
1290
+
1291
+
1292
+ def _snapshot_count(con: duckdb.DuckDBPyConnection) -> int:
1293
+ row = con.execute(
1294
+ f"SELECT count(*) FROM ducklake_snapshots({sql_literal(LAKE_ALIAS)})" # noqa: S608 # nosec B608 - constants only
1295
+ ).fetchone()
1296
+ return int(row[0]) if row else 0
1297
+
1298
+
1299
+ def compact_lake(
1300
+ layout: LakeLayout,
1301
+ *,
1302
+ expire_older_than_days: int = 30,
1303
+ lock_timeout_seconds: float = DEFAULT_LOCK_TIMEOUT_SECONDS,
1304
+ memory_limit_bytes: int | None = None,
1305
+ ) -> CompactReport:
1306
+ """Merge small files, rewrite delete-heavy ones, expire old snapshots, remove unreferenced files.
1307
+
1308
+ Snapshots older than ``expire_older_than_days`` are expired; the files
1309
+ only they referenced, and any orphaned file, are then removed once
1310
+ :data:`CLEANUP_GRACE_HOURS` old, so a reader holding the previous
1311
+ published catalog keeps every file it names.
1312
+ """
1313
+ if not layout.exists():
1314
+ msg = f"no lake at {layout.root}"
1315
+ raise LakeError(msg)
1316
+ started = time.perf_counter()
1317
+ alias = sql_literal(LAKE_ALIAS)
1318
+ with writer_lock(layout, timeout_seconds=lock_timeout_seconds):
1319
+ with _writer_connection(memory_limit_bytes) as con:
1320
+ _attach(con, layout.catalog_path, layout.data_dir, read_only=False)
1321
+ files_before, snapshots_before = _count_live_files(con), _snapshot_count(con)
1322
+ with _file_maintenance(con):
1323
+ con.execute(f"CALL ducklake_merge_adjacent_files({alias});")
1324
+ con.execute(f"CALL ducklake_rewrite_data_files({alias});")
1325
+ con.execute(f"CALL ducklake_merge_adjacent_files({alias});")
1326
+ con.execute(
1327
+ f"CALL ducklake_expire_snapshots({alias}, older_than => now() - INTERVAL {int(expire_older_than_days)} DAY);"
1328
+ )
1329
+ con.execute(
1330
+ f"CALL ducklake_cleanup_old_files({alias}, older_than => now() - INTERVAL {int(CLEANUP_GRACE_HOURS)} HOUR);"
1331
+ )
1332
+ con.execute(
1333
+ f"CALL ducklake_delete_orphaned_files({alias}, older_than => now() - INTERVAL {int(CLEANUP_GRACE_HOURS)} HOUR);"
1334
+ )
1335
+ files_after, snapshots_after = _count_live_files(con), _snapshot_count(con)
1336
+ _detach(con)
1337
+ _publish_reader_catalog(layout)
1338
+ return CompactReport(
1339
+ files_before=files_before,
1340
+ files_after=files_after,
1341
+ snapshots_before=snapshots_before,
1342
+ snapshots_after=snapshots_after,
1343
+ seconds=time.perf_counter() - started,
1344
+ )
1345
+
1346
+
1347
+ __all__ = [
1348
+ "CLEANUP_GRACE_HOURS",
1349
+ "DEFAULT_LOAD_BATCH_SIZE",
1350
+ "DEFAULT_LOCK_TIMEOUT_SECONDS",
1351
+ "DEFAULT_STAGE_WORKERS",
1352
+ "DEFAULT_SYNC_BATCH_SIZE",
1353
+ "DEFAULT_WRITER_MEMORY_BYTES",
1354
+ "DUCKLAKE_EXTENSION",
1355
+ "CompactReport",
1356
+ "DuckLakeSessionSink",
1357
+ "LakeCorpus",
1358
+ "LakeCorpusConflictError",
1359
+ "LakeError",
1360
+ "LakeExtensionMissingError",
1361
+ "LakeLayout",
1362
+ "LakeLockTimeoutError",
1363
+ "LakeMismatch",
1364
+ "LakeReader",
1365
+ "LakeStatus",
1366
+ "LakeUnavailable",
1367
+ "RebuildReport",
1368
+ "Staging",
1369
+ "VerifyReport",
1370
+ "attach_lake_for_query",
1371
+ "compact_lake",
1372
+ "corpus_agent",
1373
+ "corpus_session_ids",
1374
+ "ducklake_extension_installed",
1375
+ "install_ducklake_extension",
1376
+ "lake_status",
1377
+ "load_ducklake",
1378
+ "rebuild_lake",
1379
+ "register_lake_raw",
1380
+ "registered_corpora",
1381
+ "verify_lake",
1382
+ "writer_lock",
1383
+ ]