continuo-duckdb-adapter 0.1.0__tar.gz

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 (34) hide show
  1. continuo_duckdb_adapter-0.1.0/.gitignore +13 -0
  2. continuo_duckdb_adapter-0.1.0/PKG-INFO +124 -0
  3. continuo_duckdb_adapter-0.1.0/README.md +109 -0
  4. continuo_duckdb_adapter-0.1.0/continuo_duckdb_adapter/__init__.py +1 -0
  5. continuo_duckdb_adapter-0.1.0/continuo_duckdb_adapter/adapter.py +25 -0
  6. continuo_duckdb_adapter-0.1.0/continuo_duckdb_adapter/application/__init__.py +1 -0
  7. continuo_duckdb_adapter-0.1.0/continuo_duckdb_adapter/application/ports.py +77 -0
  8. continuo_duckdb_adapter-0.1.0/continuo_duckdb_adapter/application/warehouse.py +205 -0
  9. continuo_duckdb_adapter-0.1.0/continuo_duckdb_adapter/domain/__init__.py +1 -0
  10. continuo_duckdb_adapter-0.1.0/continuo_duckdb_adapter/domain/columns.py +46 -0
  11. continuo_duckdb_adapter-0.1.0/continuo_duckdb_adapter/domain/identifiers.py +33 -0
  12. continuo_duckdb_adapter-0.1.0/continuo_duckdb_adapter/domain/layout.py +162 -0
  13. continuo_duckdb_adapter-0.1.0/continuo_duckdb_adapter/infrastructure/__init__.py +1 -0
  14. continuo_duckdb_adapter-0.1.0/continuo_duckdb_adapter/infrastructure/ddl.py +229 -0
  15. continuo_duckdb_adapter-0.1.0/continuo_duckdb_adapter/infrastructure/extensions.py +51 -0
  16. continuo_duckdb_adapter-0.1.0/continuo_duckdb_adapter/infrastructure/passfile.py +67 -0
  17. continuo_duckdb_adapter-0.1.0/continuo_duckdb_adapter/infrastructure/session.py +281 -0
  18. continuo_duckdb_adapter-0.1.0/continuo_duckdb_adapter/infrastructure/settings.py +101 -0
  19. continuo_duckdb_adapter-0.1.0/pyproject.toml +59 -0
  20. continuo_duckdb_adapter-0.1.0/tests/__init__.py +0 -0
  21. continuo_duckdb_adapter-0.1.0/tests/conftest.py +403 -0
  22. continuo_duckdb_adapter-0.1.0/tests/test_adapter_duckdb_proxy.py +25 -0
  23. continuo_duckdb_adapter-0.1.0/tests/test_adapter_duckdb_runtime.py +106 -0
  24. continuo_duckdb_adapter-0.1.0/tests/test_adapter_duckdb_validation.py +150 -0
  25. continuo_duckdb_adapter-0.1.0/tests/test_adapter_duckdb_wiring.py +25 -0
  26. continuo_duckdb_adapter-0.1.0/tests/test_domain_duckdb_columns.py +55 -0
  27. continuo_duckdb_adapter-0.1.0/tests/test_domain_duckdb_identifiers.py +26 -0
  28. continuo_duckdb_adapter-0.1.0/tests/test_domain_duckdb_layout.py +135 -0
  29. continuo_duckdb_adapter-0.1.0/tests/test_infra_duckdb_session.py +458 -0
  30. continuo_duckdb_adapter-0.1.0/tests/test_infra_duckdb_settings_ddl.py +295 -0
  31. continuo_duckdb_adapter-0.1.0/tests/test_integration_duckdb_layout.py +269 -0
  32. continuo_duckdb_adapter-0.1.0/tests/test_integration_duckdb_runtime.py +253 -0
  33. continuo_duckdb_adapter-0.1.0/tests/test_integration_duckdb_stack.py +133 -0
  34. continuo_duckdb_adapter-0.1.0/tests/test_integration_duckdb_validation.py +226 -0
@@ -0,0 +1,13 @@
1
+ .idea/
2
+ .DS_Store
3
+ __pycache__/
4
+ .venv/
5
+ dist/
6
+ .superpowers/
7
+ docs/superpowers/
8
+ docs/ddd-violations/
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+ .ruff_cache/
12
+ .coverage
13
+ .worktrees/
@@ -0,0 +1,124 @@
1
+ Metadata-Version: 2.5
2
+ Name: continuo-duckdb-adapter
3
+ Version: 0.1.0
4
+ Summary: DuckDB (DuckLake) warehouse adapter for Continuo: validation and python-node runtime.
5
+ Author: Simone Carolini
6
+ Maintainer: Simone Carolini
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Programming Language :: Python :: 3.14
10
+ Requires-Python: >=3.14
11
+ Requires-Dist: continuo-engine-contract==0.7.3
12
+ Requires-Dist: duckdb==1.5.6
13
+ Requires-Dist: pyarrow==25.0.1
14
+ Description-Content-Type: text/markdown
15
+
16
+ # continuo-duckdb-adapter
17
+
18
+ DuckDB warehouse adapter for Continuo, running on a **DuckLake**: the catalog
19
+ lives in Postgres and table data is Parquet on S3/MinIO, so every Kubernetes
20
+ Job shares one transactional warehouse. Implements
21
+ `continuo_engine_contract.port.WarehouseAdapter`; registered under the
22
+ `continuo_engine.adapters` entry-point group as `duckdb`.
23
+
24
+ ## Environment
25
+
26
+ | Variable | Required | Default | Meaning |
27
+ |---|---|---|---|
28
+ | `DUCKDB_CATALOG_HOST` | yes | | Postgres host holding the DuckLake catalog |
29
+ | `DUCKDB_CATALOG_PORT` | no | `5432` | |
30
+ | `DUCKDB_CATALOG_DB` | yes | | |
31
+ | `DUCKDB_CATALOG_USER` | yes | | |
32
+ | `DUCKDB_CATALOG_PASSWORD` | no | empty | |
33
+ | `DUCKDB_DATA_PATH` | yes | | Data location, e.g. `s3://bucket/lake/` (fixed once the catalog exists) |
34
+ | `DUCKDB_S3_ENDPOINT` | no | AWS | `host:port`, for MinIO and other S3-compatible stores |
35
+ | `DUCKDB_S3_ACCESS_KEY_ID` / `DUCKDB_S3_SECRET_ACCESS_KEY` | no | credential chain | Static credentials; omit to use the AWS credential chain |
36
+ | `DUCKDB_S3_REGION` | no | `us-east-1` | |
37
+ | `DUCKDB_S3_URL_STYLE` | no | `path` with an endpoint, else `vhost` | `path` or `vhost` |
38
+ | `DUCKDB_S3_USE_SSL` | no | `true` | |
39
+ | `DUCKDB_EXTENSION_DIRECTORY` | no | DuckDB default | Where `ducklake`, `postgres`, `httpfs` and `aws` (the credential chain) are loaded from (set in the image) |
40
+ | `DUCKDB_TEMP_DIRECTORY` | no | `.tmp` in the working directory | Where DuckDB spills larger-than-memory work; must be writable by the runtime user (the image sets `/tmp/duckdb-tmp`) |
41
+ | `DUCKDB_DATA_INLINING_ROW_LIMIT` | no | DuckLake default | `0` writes every insert as a Parquet file instead of inlining small ones in the catalog |
42
+
43
+ Settings are parsed strictly and fail fast with the variable named:
44
+
45
+ - `DUCKDB_S3_USE_SSL` accepts `true`/`false`/`1`/`0`/`yes`/`no` (any case);
46
+ anything else is an error rather than silently meaning "true".
47
+ - `DUCKDB_S3_ACCESS_KEY_ID` and `DUCKDB_S3_SECRET_ACCESS_KEY` are set together or
48
+ not at all; one without the other is rejected (it would otherwise fall back to
49
+ the credential chain without saying so).
50
+ - `DUCKDB_CATALOG_PORT` and `DUCKDB_DATA_INLINING_ROW_LIMIT` take ASCII digits
51
+ only.
52
+
53
+ ## Credentials and failure modes
54
+
55
+ The catalog password is handed to libpq through a private (mode 0600) temporary
56
+ passfile, not in the connection string, so it does not appear in DuckDB's error
57
+ text or in `duckdb_databases()`. Every DuckDB error also passes through one
58
+ redaction point that masks the catalog password and the S3 secret (every
59
+ spelling) before the error reaches the result block or the pod logs; host, port
60
+ and database stay visible. The password travels inline instead (still redacted from errors) when it
61
+ contains a line break, when `PGPASSWORD` is set in the environment (libpq would
62
+ prefer it to any passfile), or when no temp file can be created. The `aws`
63
+ extension is only loaded for the AWS credential-chain S3 path.
64
+
65
+ A first attach to a brand-new catalog from several Jobs at once can race on
66
+ DuckLake's metadata creation; the adapter retries exactly that failure a few
67
+ times, and surfaces every other connection error immediately.
68
+
69
+ ## Physical layout (`config`)
70
+
71
+ - `partitioned_by`: non-empty list of a column name, or
72
+ `{column, transform, buckets}` with `transform` in `identity`, `bucket`
73
+ (`buckets` required), `year`, `month`, `day`, `hour` (time transforms need a
74
+ `DATE` or `TIMESTAMP` column).
75
+ - `sorted_by`: non-empty list of a column name, or
76
+ `{column, direction: asc|desc, nulls: first|last}`.
77
+ - Columns must be declared in `output_columns`. Any other key, including
78
+ postgres's `indexes`, is rejected before any DDL runs.
79
+ - Layout is applied when `ensure_table` creates the table; changing it on an
80
+ existing table is a no-op. `build_empty_from_columns` (the release gate)
81
+ always rebuilds.
82
+
83
+ ## Engine behaviour to know
84
+
85
+ - DuckDB drops the length of `VARCHAR(n)` / `CHAR(n)`: the catalog shows plain
86
+ `VARCHAR`. Length is enforced by `conform()` for python nodes only, not by the
87
+ table.
88
+ - DuckLake inlines small inserts into the catalog (see
89
+ `DUCKDB_DATA_INLINING_ROW_LIMIT`), so partitioning and sorting are applied to
90
+ Parquet files only after a flush; set the limit to `0` when files must be
91
+ laid out per write.
92
+ - A read is one single query: top-level `PIVOT` / `UNPIVOT` statements are
93
+ rejected by the single-read gate. Wrap them: `SELECT * FROM (PIVOT ...)`.
94
+
95
+ ## Parity with the postgres and trino adapters
96
+
97
+ | Behaviour | postgres | trino | duckdb |
98
+ |---|---|---|---|
99
+ | `drop_schema` | `DROP SCHEMA IF EXISTS ... CASCADE` | same | same (tables and views go too) |
100
+ | `ensure_schema` under concurrency | session advisory lock | `IF NOT EXISTS`, tolerating a concurrent creation | `IF NOT EXISTS`, bounded retry on a DuckLake snapshot conflict |
101
+ | `check_binds` | `EXPLAIN` in `BEGIN READ ONLY` | `EXPLAIN (TYPE VALIDATE)` | `EXPLAIN` in `BEGIN TRANSACTION READ ONLY`, always rolled back |
102
+ | `ensure_table` | creates if absent, layout on create | same | same, in one transaction with the existence check |
103
+ | `load` (replace contents) | one transaction | atomic table swap (no multi-statement transactions) | one transaction: `DELETE` then `INSERT` |
104
+ | Physical layout keys | `indexes` | `partitioning`, `sorted_by`, `format`, `format_version` | `partitioned_by`, `sorted_by` |
105
+
106
+ Not applicable here: postgres `indexes`; trino `format` and `format_version`
107
+ (DuckLake always writes Parquet). The postgres advisory lock has no DuckLake
108
+ counterpart and is replaced by the bounded conflict retry; the trino table swap
109
+ is replaced by the single `DELETE` + `INSERT` transaction.
110
+
111
+ ## Layout of the code
112
+
113
+ `domain/` (pure rules) <- `application/` (use cases, `LakeGateway` port) <-
114
+ `infrastructure/` (DuckDB/DuckLake I/O, SQL rendering); `adapter.py` is the
115
+ composition root and the entry-point target.
116
+
117
+ ## Tests
118
+
119
+ ```bash
120
+ uv run pytest adapters/duckdb/tests -m "not integration" -v
121
+ docker compose -f tests/smoke/duckdb-stack/docker-compose.yml up -d --wait
122
+ uv run pytest adapters/duckdb/tests -m integration -v
123
+ docker compose -f tests/smoke/duckdb-stack/docker-compose.yml down -v
124
+ ```
@@ -0,0 +1,109 @@
1
+ # continuo-duckdb-adapter
2
+
3
+ DuckDB warehouse adapter for Continuo, running on a **DuckLake**: the catalog
4
+ lives in Postgres and table data is Parquet on S3/MinIO, so every Kubernetes
5
+ Job shares one transactional warehouse. Implements
6
+ `continuo_engine_contract.port.WarehouseAdapter`; registered under the
7
+ `continuo_engine.adapters` entry-point group as `duckdb`.
8
+
9
+ ## Environment
10
+
11
+ | Variable | Required | Default | Meaning |
12
+ |---|---|---|---|
13
+ | `DUCKDB_CATALOG_HOST` | yes | | Postgres host holding the DuckLake catalog |
14
+ | `DUCKDB_CATALOG_PORT` | no | `5432` | |
15
+ | `DUCKDB_CATALOG_DB` | yes | | |
16
+ | `DUCKDB_CATALOG_USER` | yes | | |
17
+ | `DUCKDB_CATALOG_PASSWORD` | no | empty | |
18
+ | `DUCKDB_DATA_PATH` | yes | | Data location, e.g. `s3://bucket/lake/` (fixed once the catalog exists) |
19
+ | `DUCKDB_S3_ENDPOINT` | no | AWS | `host:port`, for MinIO and other S3-compatible stores |
20
+ | `DUCKDB_S3_ACCESS_KEY_ID` / `DUCKDB_S3_SECRET_ACCESS_KEY` | no | credential chain | Static credentials; omit to use the AWS credential chain |
21
+ | `DUCKDB_S3_REGION` | no | `us-east-1` | |
22
+ | `DUCKDB_S3_URL_STYLE` | no | `path` with an endpoint, else `vhost` | `path` or `vhost` |
23
+ | `DUCKDB_S3_USE_SSL` | no | `true` | |
24
+ | `DUCKDB_EXTENSION_DIRECTORY` | no | DuckDB default | Where `ducklake`, `postgres`, `httpfs` and `aws` (the credential chain) are loaded from (set in the image) |
25
+ | `DUCKDB_TEMP_DIRECTORY` | no | `.tmp` in the working directory | Where DuckDB spills larger-than-memory work; must be writable by the runtime user (the image sets `/tmp/duckdb-tmp`) |
26
+ | `DUCKDB_DATA_INLINING_ROW_LIMIT` | no | DuckLake default | `0` writes every insert as a Parquet file instead of inlining small ones in the catalog |
27
+
28
+ Settings are parsed strictly and fail fast with the variable named:
29
+
30
+ - `DUCKDB_S3_USE_SSL` accepts `true`/`false`/`1`/`0`/`yes`/`no` (any case);
31
+ anything else is an error rather than silently meaning "true".
32
+ - `DUCKDB_S3_ACCESS_KEY_ID` and `DUCKDB_S3_SECRET_ACCESS_KEY` are set together or
33
+ not at all; one without the other is rejected (it would otherwise fall back to
34
+ the credential chain without saying so).
35
+ - `DUCKDB_CATALOG_PORT` and `DUCKDB_DATA_INLINING_ROW_LIMIT` take ASCII digits
36
+ only.
37
+
38
+ ## Credentials and failure modes
39
+
40
+ The catalog password is handed to libpq through a private (mode 0600) temporary
41
+ passfile, not in the connection string, so it does not appear in DuckDB's error
42
+ text or in `duckdb_databases()`. Every DuckDB error also passes through one
43
+ redaction point that masks the catalog password and the S3 secret (every
44
+ spelling) before the error reaches the result block or the pod logs; host, port
45
+ and database stay visible. The password travels inline instead (still redacted from errors) when it
46
+ contains a line break, when `PGPASSWORD` is set in the environment (libpq would
47
+ prefer it to any passfile), or when no temp file can be created. The `aws`
48
+ extension is only loaded for the AWS credential-chain S3 path.
49
+
50
+ A first attach to a brand-new catalog from several Jobs at once can race on
51
+ DuckLake's metadata creation; the adapter retries exactly that failure a few
52
+ times, and surfaces every other connection error immediately.
53
+
54
+ ## Physical layout (`config`)
55
+
56
+ - `partitioned_by`: non-empty list of a column name, or
57
+ `{column, transform, buckets}` with `transform` in `identity`, `bucket`
58
+ (`buckets` required), `year`, `month`, `day`, `hour` (time transforms need a
59
+ `DATE` or `TIMESTAMP` column).
60
+ - `sorted_by`: non-empty list of a column name, or
61
+ `{column, direction: asc|desc, nulls: first|last}`.
62
+ - Columns must be declared in `output_columns`. Any other key, including
63
+ postgres's `indexes`, is rejected before any DDL runs.
64
+ - Layout is applied when `ensure_table` creates the table; changing it on an
65
+ existing table is a no-op. `build_empty_from_columns` (the release gate)
66
+ always rebuilds.
67
+
68
+ ## Engine behaviour to know
69
+
70
+ - DuckDB drops the length of `VARCHAR(n)` / `CHAR(n)`: the catalog shows plain
71
+ `VARCHAR`. Length is enforced by `conform()` for python nodes only, not by the
72
+ table.
73
+ - DuckLake inlines small inserts into the catalog (see
74
+ `DUCKDB_DATA_INLINING_ROW_LIMIT`), so partitioning and sorting are applied to
75
+ Parquet files only after a flush; set the limit to `0` when files must be
76
+ laid out per write.
77
+ - A read is one single query: top-level `PIVOT` / `UNPIVOT` statements are
78
+ rejected by the single-read gate. Wrap them: `SELECT * FROM (PIVOT ...)`.
79
+
80
+ ## Parity with the postgres and trino adapters
81
+
82
+ | Behaviour | postgres | trino | duckdb |
83
+ |---|---|---|---|
84
+ | `drop_schema` | `DROP SCHEMA IF EXISTS ... CASCADE` | same | same (tables and views go too) |
85
+ | `ensure_schema` under concurrency | session advisory lock | `IF NOT EXISTS`, tolerating a concurrent creation | `IF NOT EXISTS`, bounded retry on a DuckLake snapshot conflict |
86
+ | `check_binds` | `EXPLAIN` in `BEGIN READ ONLY` | `EXPLAIN (TYPE VALIDATE)` | `EXPLAIN` in `BEGIN TRANSACTION READ ONLY`, always rolled back |
87
+ | `ensure_table` | creates if absent, layout on create | same | same, in one transaction with the existence check |
88
+ | `load` (replace contents) | one transaction | atomic table swap (no multi-statement transactions) | one transaction: `DELETE` then `INSERT` |
89
+ | Physical layout keys | `indexes` | `partitioning`, `sorted_by`, `format`, `format_version` | `partitioned_by`, `sorted_by` |
90
+
91
+ Not applicable here: postgres `indexes`; trino `format` and `format_version`
92
+ (DuckLake always writes Parquet). The postgres advisory lock has no DuckLake
93
+ counterpart and is replaced by the bounded conflict retry; the trino table swap
94
+ is replaced by the single `DELETE` + `INSERT` transaction.
95
+
96
+ ## Layout of the code
97
+
98
+ `domain/` (pure rules) <- `application/` (use cases, `LakeGateway` port) <-
99
+ `infrastructure/` (DuckDB/DuckLake I/O, SQL rendering); `adapter.py` is the
100
+ composition root and the entry-point target.
101
+
102
+ ## Tests
103
+
104
+ ```bash
105
+ uv run pytest adapters/duckdb/tests -m "not integration" -v
106
+ docker compose -f tests/smoke/duckdb-stack/docker-compose.yml up -d --wait
107
+ uv run pytest adapters/duckdb/tests -m integration -v
108
+ docker compose -f tests/smoke/duckdb-stack/docker-compose.yml down -v
109
+ ```
@@ -0,0 +1 @@
1
+ """DuckDB (DuckLake) warehouse adapter for Continuo."""
@@ -0,0 +1,25 @@
1
+ """Composition root: wires settings, session and use cases. Entry-point target.
2
+
3
+ The only module that knows every layer. The use cases live in
4
+ ``application.warehouse.LakeWarehouse``; this class supplies the two hooks the
5
+ port leaves to the concrete engine, ``required_env`` and ``from_env``.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from .application.warehouse import LakeWarehouse
10
+ from .infrastructure.session import DuckLakeSession
11
+ from .infrastructure.settings import REQUIRED_ENV, DuckLakeSettings
12
+
13
+
14
+ class DuckDBAdapter(LakeWarehouse):
15
+ """WarehouseAdapter for DuckDB on a DuckLake (Postgres catalog, S3 data)."""
16
+
17
+ @classmethod
18
+ def required_env(cls) -> list[str]:
19
+ """Vars that must be non-empty before connecting."""
20
+ return list(REQUIRED_ENV)
21
+
22
+ @classmethod
23
+ def from_env(cls) -> "DuckDBAdapter":
24
+ """Connect from DUCKDB_* env (see DuckLakeSettings for every variable)."""
25
+ return cls(DuckLakeSession.connect(DuckLakeSettings.from_env()))
@@ -0,0 +1 @@
1
+ """Use cases. Depends on the LakeGateway port, never on infrastructure."""
@@ -0,0 +1,77 @@
1
+ """The port the use cases need from a DuckLake. Implemented by infrastructure."""
2
+ from __future__ import annotations
3
+
4
+ from abc import ABC, abstractmethod
5
+ from collections.abc import Sequence
6
+ from contextlib import AbstractContextManager
7
+ from typing import TYPE_CHECKING
8
+
9
+ from ..domain.columns import ColumnDefinition
10
+ from ..domain.identifiers import Identifier, QualifiedTable
11
+ from ..domain.layout import TableLayout
12
+
13
+ if TYPE_CHECKING: # pragma: no cover
14
+ import pyarrow # type: ignore[import-untyped]
15
+
16
+
17
+ class LakeConflictError(Exception):
18
+ """A concurrent DuckLake transaction won a snapshot conflict; safe to retry."""
19
+
20
+
21
+ class LakeGateway(ABC):
22
+ """Everything the warehouse use cases ask of one DuckLake connection."""
23
+
24
+ @abstractmethod
25
+ def transaction(self) -> AbstractContextManager[None]:
26
+ """BEGIN ... COMMIT; ROLLBACK (logged, never masking) if the body raises."""
27
+
28
+ @abstractmethod
29
+ def create_schema_if_not_exists(self, schema: Identifier) -> None: ...
30
+
31
+ @abstractmethod
32
+ def drop_schema_cascade(self, schema: Identifier) -> None:
33
+ """Drop the schema and everything in it; a no-op when absent."""
34
+
35
+ @abstractmethod
36
+ def table_exists(self, table: QualifiedTable) -> bool: ...
37
+
38
+ @abstractmethod
39
+ def drop_table_if_exists(self, table: QualifiedTable) -> None: ...
40
+
41
+ @abstractmethod
42
+ def create_table(
43
+ self,
44
+ table: QualifiedTable,
45
+ columns: Sequence[ColumnDefinition],
46
+ *,
47
+ if_not_exists: bool = False,
48
+ ) -> None: ...
49
+
50
+ @abstractmethod
51
+ def create_empty_table_as(self, table: QualifiedTable, select_sql: str) -> None:
52
+ """Create *table* empty, shaped by the SELECT (no terminator, single read)."""
53
+
54
+ @abstractmethod
55
+ def create_empty_clone(self, target: QualifiedTable, source: QualifiedTable) -> None:
56
+ """Create *target* empty with *source*'s shape."""
57
+
58
+ @abstractmethod
59
+ def apply_layout(self, table: QualifiedTable, layout: TableLayout) -> None:
60
+ """Apply partitioning and sort order to an existing table."""
61
+
62
+ @abstractmethod
63
+ def explain_read(self, sql: str) -> None:
64
+ """Bind-check one read without scanning data; raise if it does not bind."""
65
+
66
+ @abstractmethod
67
+ def fetch_arrow(self, sql: str) -> "pyarrow.Table": ...
68
+
69
+ @abstractmethod
70
+ def delete_all(self, table: QualifiedTable) -> None: ...
71
+
72
+ @abstractmethod
73
+ def insert_arrow(self, table: QualifiedTable, data: "pyarrow.Table") -> None:
74
+ """Append *data* in its own column order."""
75
+
76
+ @abstractmethod
77
+ def close(self) -> None: ...
@@ -0,0 +1,205 @@
1
+ """The warehouse use cases: one class for both roles the port serves.
2
+
3
+ Validation DDL (this task) and python-node data-plane I/O (next task) share one
4
+ connection, so they are one implementation, as in the postgres and trino
5
+ adapters. The class depends only on the LakeGateway port; ``required_env`` and
6
+ ``from_env`` stay abstract and are supplied by the composition root
7
+ (``continuo_duckdb_adapter.adapter.DuckDBAdapter``).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import time
13
+ from collections.abc import Callable
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from continuo_engine_contract.port import WarehouseAdapter # type: ignore[import-untyped]
17
+ from continuo_engine_contract.sql import ensure_single_read # type: ignore[import-untyped]
18
+
19
+ from ..domain.columns import ColumnDefinition, column_types
20
+ from ..domain.identifiers import Identifier, QualifiedTable
21
+ from ..domain.layout import TableLayout
22
+ from .ports import LakeConflictError, LakeGateway
23
+
24
+ if TYPE_CHECKING: # pragma: no cover
25
+ import pyarrow as pa # type: ignore[import-untyped]
26
+
27
+ logger = logging.getLogger("continuo_duckdb_adapter")
28
+
29
+ # DuckLake has no advisory lock. Parallel validation nodes race on creation and
30
+ # the loser fails its COMMIT with a snapshot conflict; the retry then finds the
31
+ # object exists. Bounded so a genuine, persistent conflict still surfaces.
32
+ _CONFLICT_ATTEMPTS = 5
33
+ _CONFLICT_BACKOFF_SECONDS = 0.05
34
+
35
+
36
+ def _bare_read(sql: str) -> str:
37
+ """*sql* without surrounding whitespace or one trailing terminator."""
38
+ return sql.strip().rstrip(";").strip()
39
+
40
+
41
+ def _columns(raw: list[dict]) -> list[ColumnDefinition]:
42
+ return [ColumnDefinition.from_mapping(entry) for entry in raw]
43
+
44
+
45
+ class LakeWarehouse(WarehouseAdapter):
46
+ """WarehouseAdapter implemented over a LakeGateway."""
47
+
48
+ def __init__(
49
+ self, gateway: LakeGateway, *, sleep: Callable[[float], None] = time.sleep
50
+ ) -> None:
51
+ self._gateway = gateway
52
+ self._sleep = sleep
53
+
54
+ def _retrying(self, operation: Callable[[], None]) -> None:
55
+ for attempt in range(1, _CONFLICT_ATTEMPTS + 1):
56
+ try:
57
+ operation()
58
+ return
59
+ except LakeConflictError:
60
+ if attempt == _CONFLICT_ATTEMPTS:
61
+ raise
62
+ logger.info("concurrent change conflicted (attempt %d); retrying", attempt)
63
+ self._sleep(_CONFLICT_BACKOFF_SECONDS * attempt)
64
+
65
+ # --- Schema lifecycle ---------------------------------------------------
66
+
67
+ def ensure_schema(self, schema: str) -> None:
68
+ """Idempotently create *schema*; safe under concurrent callers."""
69
+ target = Identifier(schema)
70
+ logger.info("ensuring schema %s exists", schema)
71
+ self._retrying(lambda: self._gateway.create_schema_if_not_exists(target))
72
+
73
+ def drop_schema(self, schema: str) -> None:
74
+ """Idempotently drop *schema* and everything in it; no-op if absent."""
75
+ logger.info("dropping candidate schema %s", schema)
76
+ self._gateway.drop_schema_cascade(Identifier(schema))
77
+
78
+ # --- Validation builds --------------------------------------------------
79
+
80
+ def build_empty_from_sql(self, schema: str, table: str, compiled_sql: str) -> None:
81
+ """Create ``schema.table`` empty, shaped by the compiled SELECT.
82
+
83
+ The parse gate runs first: DuckDB executes every ``;``-separated
84
+ statement handed to it, so a stacked statement would run for real.
85
+ """
86
+ ensure_single_read(compiled_sql, dialect="duckdb")
87
+ inner = _bare_read(compiled_sql)
88
+ target = QualifiedTable.of(schema, table)
89
+ with self._gateway.transaction():
90
+ self._gateway.drop_table_if_exists(target)
91
+ self._gateway.create_empty_table_as(target, inner)
92
+
93
+ def clone_empty_from_prod(self, candidate_schema: str, prod_schema: str, table: str) -> None:
94
+ """Create ``candidate_schema.table`` empty, shaped like ``prod_schema.table``."""
95
+ target = QualifiedTable.of(candidate_schema, table)
96
+ source = QualifiedTable.of(prod_schema, table)
97
+ with self._gateway.transaction():
98
+ self._gateway.drop_table_if_exists(target)
99
+ self._gateway.create_empty_clone(target, source)
100
+
101
+ def build_empty_from_columns(
102
+ self, schema: str, table: str, columns: list[dict], config: dict
103
+ ) -> None:
104
+ """Create ``schema.table`` empty from declared typed columns (drop-then-create).
105
+
106
+ Types and layout are validated before the first statement; the rebuild is
107
+ one transaction, so a failure leaves the prior table in place.
108
+ """
109
+ defs = _columns(columns)
110
+ layout = TableLayout.from_config(config, column_types(defs))
111
+ target = QualifiedTable.of(schema, table)
112
+ with self._gateway.transaction():
113
+ self._gateway.drop_table_if_exists(target)
114
+ self._gateway.create_table(target, defs)
115
+ if not layout.is_empty:
116
+ self._gateway.apply_layout(target, layout)
117
+
118
+ def check_binds(self, sql: str) -> None:
119
+ """Verify *sql* binds against current schema state; scans no data.
120
+
121
+ The parse gate runs first: DuckDB executes every ``;``-separated
122
+ statement handed to it, so a stacked statement would run for real.
123
+ """
124
+ ensure_single_read(sql, dialect="duckdb")
125
+ inner = _bare_read(sql)
126
+ logger.info("bind-checking read via EXPLAIN")
127
+ self._gateway.explain_read(inner)
128
+
129
+ # --- Python-node data plane ---------------------------------------------
130
+
131
+ def fetch(self, sql: str) -> "pa.Table":
132
+ """Execute one declared read and return the result as an Arrow table.
133
+
134
+ Not re-gated here, matching the postgres adapter: the harness loads the
135
+ contract with ``check_reads=False``, so ``check_binds`` is the gated path
136
+ (single-read parse gate, then a bind check).
137
+ """
138
+ data = self._gateway.fetch_arrow(sql)
139
+ seen: set[str] = set()
140
+ duplicates: set[str] = set()
141
+ for name in data.schema.names:
142
+ if name in seen:
143
+ duplicates.add(name)
144
+ seen.add(name)
145
+ if duplicates:
146
+ raise ValueError(f"duplicate column name(s) in SELECT result: {sorted(duplicates)!r}")
147
+ return data
148
+
149
+ @classmethod
150
+ def validate_config(cls, config: dict[str, Any] | None, column_names: list[str]) -> None:
151
+ """Validate *config* against this engine's vocabulary, without connecting.
152
+
153
+ The harness calls this right after selecting the node so a malformed
154
+ ``config`` fails in the first second, not after the script has run. It
155
+ only knows column names, so the DATE/TIMESTAMP check for time transforms
156
+ is deferred; ``ensure_table`` runs the complete check and remains the
157
+ enforcement point (the harness skips adapters without this method; see
158
+ docs/boundary-contract.md section 13.4).
159
+ """
160
+ TableLayout.from_config(config, {name: None for name in column_names})
161
+
162
+ def ensure_table(
163
+ self,
164
+ schema: str,
165
+ table: str,
166
+ columns: list[dict[str, Any]],
167
+ *,
168
+ config: dict[str, Any],
169
+ ) -> None:
170
+ """Create the table if absent; layout is applied only when this call creates it.
171
+
172
+ Validation comes first, so a bad config or type emits no statement. The
173
+ existence check and the create share one transaction; a concurrent
174
+ creator makes one side fail with a conflict, and the retry then finds
175
+ the table and returns.
176
+ """
177
+ defs = _columns(columns)
178
+ layout = TableLayout.from_config(config, column_types(defs))
179
+ self.ensure_schema(schema)
180
+ target = QualifiedTable.of(schema, table)
181
+ self._retrying(lambda: self._create_if_absent(target, defs, layout))
182
+
183
+ def _create_if_absent(
184
+ self, target: QualifiedTable, defs: list[ColumnDefinition], layout: TableLayout
185
+ ) -> None:
186
+ with self._gateway.transaction():
187
+ if self._gateway.table_exists(target):
188
+ return
189
+ logger.info("creating table %s.%s", target.schema.name, target.table.name)
190
+ self._gateway.create_table(target, defs)
191
+ if not layout.is_empty:
192
+ self._gateway.apply_layout(target, layout)
193
+
194
+ def load(self, schema: str, table: str, data: "pa.Table") -> None:
195
+ """Atomically replace the table's contents with *data* (one transaction)."""
196
+ target = QualifiedTable.of(schema, table)
197
+ with self._gateway.transaction():
198
+ self._gateway.delete_all(target)
199
+ if data.num_rows:
200
+ logger.info("loading %d row(s) into %s.%s", data.num_rows, schema, table)
201
+ self._gateway.insert_arrow(target, data)
202
+
203
+ def close(self) -> None:
204
+ """Release the underlying connection."""
205
+ self._gateway.close()
@@ -0,0 +1 @@
1
+ """Pure domain rules: no duckdb, psycopg2, pyarrow or boto imports."""
@@ -0,0 +1,46 @@
1
+ """Typed column definitions, validated against the contract's SQL type grammar."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from collections.abc import Mapping, Sequence
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from continuo_engine_contract.types import validate_column_type # type: ignore[import-untyped]
10
+
11
+ from .identifiers import Identifier
12
+
13
+ _TEMPORAL = re.compile(r"^(TIMESTAMP|DATE)\Z", re.IGNORECASE | re.ASCII)
14
+
15
+
16
+ def is_temporal_type(type_str: str) -> bool:
17
+ """True for the contract types that support year/month/day/hour partitioning."""
18
+ return _TEMPORAL.match(type_str) is not None
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ColumnDefinition:
23
+ """One declared output column. ``type`` is validated on construction because
24
+ the text is interpolated into DDL; the name is quoted by the renderer."""
25
+
26
+ name: Identifier
27
+ type: str
28
+ nullable: bool = True
29
+
30
+ def __post_init__(self) -> None:
31
+ validate_column_type(self.type)
32
+
33
+ @property
34
+ def is_temporal(self) -> bool:
35
+ return is_temporal_type(self.type)
36
+
37
+ @classmethod
38
+ def from_mapping(cls, raw: Mapping[str, Any]) -> "ColumnDefinition":
39
+ if not isinstance(raw, Mapping) or "name" not in raw or "type" not in raw:
40
+ raise ValueError(f"column must be a mapping with 'name' and 'type', got {raw!r}")
41
+ return cls(Identifier(raw["name"]), raw["type"], bool(raw.get("nullable", True)))
42
+
43
+
44
+ def column_types(columns: Sequence[ColumnDefinition]) -> dict[str, str]:
45
+ """Declared column name -> contract type, for layout validation."""
46
+ return {column.name.name: column.type for column in columns}
@@ -0,0 +1,33 @@
1
+ """Identifier value objects.
2
+
3
+ They validate and carry names. Turning a name into quoted SQL is the job of the
4
+ infrastructure layer's ``DdlRenderer``, so this module stays engine-neutral.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Identifier:
13
+ """A schema, table or column name, validated but never quoted."""
14
+
15
+ name: str
16
+
17
+ def __post_init__(self) -> None:
18
+ if not isinstance(self.name, str) or not self.name:
19
+ raise ValueError(f"identifier must be a non-empty string, got {self.name!r}")
20
+ if "\x00" in self.name:
21
+ raise ValueError("identifier must not contain a NUL character")
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class QualifiedTable:
26
+ """A table addressed by schema and name."""
27
+
28
+ schema: Identifier
29
+ table: Identifier
30
+
31
+ @classmethod
32
+ def of(cls, schema: str, table: str) -> "QualifiedTable":
33
+ return cls(Identifier(schema), Identifier(table))