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.
- atif_analytics/__init__.py +9 -0
- atif_analytics/application/__init__.py +3 -0
- atif_analytics/application/analyze.py +158 -0
- atif_analytics/application/prompts.py +750 -0
- atif_analytics/application/use_cases/__init__.py +3 -0
- atif_analytics/application/use_cases/_shared.py +264 -0
- atif_analytics/application/use_cases/classify.py +430 -0
- atif_analytics/application/use_cases/conflicts.py +448 -0
- atif_analytics/application/use_cases/friction.py +634 -0
- atif_analytics/application/use_cases/perceived.py +451 -0
- atif_analytics/domain/__init__.py +3 -0
- atif_analytics/domain/authorship.py +166 -0
- atif_analytics/domain/config.py +30 -0
- atif_analytics/domain/costs.py +50 -0
- atif_analytics/domain/friction.py +117 -0
- atif_analytics/domain/layout.py +94 -0
- atif_analytics/domain/models.py +385 -0
- atif_analytics/domain/ports.py +100 -0
- atif_analytics/domain/transcript.py +359 -0
- atif_analytics/infrastructure/__init__.py +3 -0
- atif_analytics/infrastructure/corpus_reader.py +546 -0
- atif_analytics/infrastructure/parquet_cache.py +186 -0
- atif_analytics/infrastructure/settings.py +114 -0
- atif_analytics/infrastructure/sqlite_state/__init__.py +3 -0
- atif_analytics/infrastructure/sqlite_state/checkpointer.py +326 -0
- atif_analytics/infrastructure/sqlite_state/retry_queue.py +296 -0
- atif_analytics/py.typed +0 -0
- atif_cli/__init__.py +3 -0
- atif_cli/__main__.py +8 -0
- atif_cli/app.py +2459 -0
- atif_cli/converter_adapter.py +151 -0
- atif_cli/corpus.py +409 -0
- atif_cli/cron.py +311 -0
- atif_cli/duck_errors.py +97 -0
- atif_cli/embed_lake.py +101 -0
- atif_cli/errors.py +89 -0
- atif_cli/lake.py +373 -0
- atif_cli/lake_sessions.py +270 -0
- atif_cli/output.py +297 -0
- atif_cli/py.typed +0 -0
- atif_converter/__init__.py +3 -0
- atif_converter/application/__init__.py +3 -0
- atif_converter/application/convert_and_audit.py +294 -0
- atif_converter/application/convert_codex.py +210 -0
- atif_converter/domain/__init__.py +9 -0
- atif_converter/domain/agents.py +40 -0
- atif_converter/domain/atif/__init__.py +55 -0
- atif_converter/domain/atif/agent.py +37 -0
- atif_converter/domain/atif/content.py +161 -0
- atif_converter/domain/atif/final_metrics.py +45 -0
- atif_converter/domain/atif/metrics.py +49 -0
- atif_converter/domain/atif/observation.py +21 -0
- atif_converter/domain/atif/observation_result.py +47 -0
- atif_converter/domain/atif/step.py +144 -0
- atif_converter/domain/atif/subagent_trajectory_ref.py +90 -0
- atif_converter/domain/atif/tool_call.py +36 -0
- atif_converter/domain/atif/trajectory.py +211 -0
- atif_converter/domain/atif/trajectory_validator.py +299 -0
- atif_converter/domain/blobs.py +510 -0
- atif_converter/domain/claude_code_conversion.py +1077 -0
- atif_converter/domain/codex_conversion.py +1036 -0
- atif_converter/domain/codex_edges.py +192 -0
- atif_converter/domain/codex_enrichment.py +419 -0
- atif_converter/domain/codex_fidelity.py +124 -0
- atif_converter/domain/codex_nested_calls.py +165 -0
- atif_converter/domain/edges.py +113 -0
- atif_converter/domain/enrichment.py +412 -0
- atif_converter/domain/errors.py +60 -0
- atif_converter/domain/fidelity.py +171 -0
- atif_converter/domain/model_prices.json +8538 -0
- atif_converter/domain/pricing.py +790 -0
- atif_converter/domain/result_signals.py +774 -0
- atif_converter/domain/schema_version.py +90 -0
- atif_converter/domain/session_events.py +408 -0
- atif_converter/infrastructure/__init__.py +3 -0
- atif_converter/infrastructure/census.py +89 -0
- atif_converter/infrastructure/claude_code_converter.py +127 -0
- atif_converter/infrastructure/codex_adapter.py +132 -0
- atif_converter/infrastructure/codex_census.py +100 -0
- atif_converter/infrastructure/codex_converter.py +82 -0
- atif_converter/infrastructure/harbor_adapter.py +187 -0
- atif_converter/infrastructure/raw_records.py +426 -0
- atif_converter/infrastructure/source_archive.py +154 -0
- atif_converter/py.typed +0 -0
- atif_corpus/__init__.py +13 -0
- atif_corpus/application/__init__.py +7 -0
- atif_corpus/application/materialize.py +1826 -0
- atif_corpus/domain/__init__.py +9 -0
- atif_corpus/domain/agents.py +33 -0
- atif_corpus/domain/generation.py +34 -0
- atif_corpus/domain/layout.py +168 -0
- atif_corpus/domain/ports.py +175 -0
- atif_corpus/domain/session_id.py +68 -0
- atif_corpus/domain/sessions.py +228 -0
- atif_corpus/domain/slug.py +72 -0
- atif_corpus/domain/source_layout.py +143 -0
- atif_corpus/domain/watermark.py +76 -0
- atif_corpus/infrastructure/__init__.py +7 -0
- atif_corpus/infrastructure/atomic.py +266 -0
- atif_corpus/infrastructure/compress_artifacts.py +161 -0
- atif_corpus/infrastructure/fake_converter.py +174 -0
- atif_corpus/infrastructure/scanner.py +316 -0
- atif_corpus/infrastructure/settings.py +130 -0
- atif_corpus/infrastructure/source_archive.py +202 -0
- atif_corpus/py.typed +0 -0
- atif_duck/__init__.py +43 -0
- atif_duck/domain/__init__.py +32 -0
- atif_duck/domain/artifacts.py +66 -0
- atif_duck/domain/authorship.py +165 -0
- atif_duck/domain/catalog.py +626 -0
- atif_duck/domain/columnar.py +149 -0
- atif_duck/domain/embedding_guard.py +88 -0
- atif_duck/domain/examples.py +265 -0
- atif_duck/domain/lake.py +427 -0
- atif_duck/domain/lake_sessions.py +97 -0
- atif_duck/domain/raw_readers.py +140 -0
- atif_duck/domain/session_id.py +69 -0
- atif_duck/domain/sql_literal.py +50 -0
- atif_duck/infrastructure/__init__.py +34 -0
- atif_duck/infrastructure/analytics.py +469 -0
- atif_duck/infrastructure/authorship.py +139 -0
- atif_duck/infrastructure/columnar.py +641 -0
- atif_duck/infrastructure/lake.py +1383 -0
- atif_duck/infrastructure/lake_sessions.py +194 -0
- atif_duck/infrastructure/lake_settings.py +63 -0
- atif_duck/infrastructure/lake_steps.py +225 -0
- atif_duck/infrastructure/projections.py +221 -0
- atif_duck/infrastructure/registry.py +2194 -0
- atif_duck/infrastructure/stored_artifacts.py +115 -0
- atif_duck/py.typed +0 -0
- atif_embed/__init__.py +12 -0
- atif_embed/application/__init__.py +3 -0
- atif_embed/application/embed.py +293 -0
- atif_embed/application/prune.py +80 -0
- atif_embed/domain/__init__.py +3 -0
- atif_embed/domain/discovery.py +70 -0
- atif_embed/domain/embedding_guard.py +83 -0
- atif_embed/domain/errors.py +94 -0
- atif_embed/domain/ports.py +223 -0
- atif_embed/domain/sql_literal.py +49 -0
- atif_embed/domain/text_stamp.py +71 -0
- atif_embed/infrastructure/__init__.py +3 -0
- atif_embed/infrastructure/cohere_bedrock.py +433 -0
- atif_embed/infrastructure/corpus_text_rows.py +273 -0
- atif_embed/infrastructure/gemma_local.py +355 -0
- atif_embed/infrastructure/lake_text_rows.py +203 -0
- atif_embed/infrastructure/lance_store.py +440 -0
- atif_embed/infrastructure/providers.py +30 -0
- atif_embed/infrastructure/settings.py +154 -0
- atif_embed/py.typed +0 -0
- atif_models/__init__.py +14 -0
- atif_models/domain/__init__.py +9 -0
- atif_models/domain/ports.py +143 -0
- atif_models/domain/registry.py +151 -0
- atif_models/domain/schema.py +109 -0
- atif_models/infrastructure/__init__.py +8 -0
- atif_models/infrastructure/openai_bedrock.py +314 -0
- atif_models/infrastructure/settings.py +85 -0
- atif_models/py.typed +0 -0
- atif_sql-0.2.0.dist-info/METADATA +272 -0
- atif_sql-0.2.0.dist-info/RECORD +164 -0
- atif_sql-0.2.0.dist-info/WHEEL +4 -0
- atif_sql-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|