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.
- continuo_duckdb_adapter-0.1.0/.gitignore +13 -0
- continuo_duckdb_adapter-0.1.0/PKG-INFO +124 -0
- continuo_duckdb_adapter-0.1.0/README.md +109 -0
- continuo_duckdb_adapter-0.1.0/continuo_duckdb_adapter/__init__.py +1 -0
- continuo_duckdb_adapter-0.1.0/continuo_duckdb_adapter/adapter.py +25 -0
- continuo_duckdb_adapter-0.1.0/continuo_duckdb_adapter/application/__init__.py +1 -0
- continuo_duckdb_adapter-0.1.0/continuo_duckdb_adapter/application/ports.py +77 -0
- continuo_duckdb_adapter-0.1.0/continuo_duckdb_adapter/application/warehouse.py +205 -0
- continuo_duckdb_adapter-0.1.0/continuo_duckdb_adapter/domain/__init__.py +1 -0
- continuo_duckdb_adapter-0.1.0/continuo_duckdb_adapter/domain/columns.py +46 -0
- continuo_duckdb_adapter-0.1.0/continuo_duckdb_adapter/domain/identifiers.py +33 -0
- continuo_duckdb_adapter-0.1.0/continuo_duckdb_adapter/domain/layout.py +162 -0
- continuo_duckdb_adapter-0.1.0/continuo_duckdb_adapter/infrastructure/__init__.py +1 -0
- continuo_duckdb_adapter-0.1.0/continuo_duckdb_adapter/infrastructure/ddl.py +229 -0
- continuo_duckdb_adapter-0.1.0/continuo_duckdb_adapter/infrastructure/extensions.py +51 -0
- continuo_duckdb_adapter-0.1.0/continuo_duckdb_adapter/infrastructure/passfile.py +67 -0
- continuo_duckdb_adapter-0.1.0/continuo_duckdb_adapter/infrastructure/session.py +281 -0
- continuo_duckdb_adapter-0.1.0/continuo_duckdb_adapter/infrastructure/settings.py +101 -0
- continuo_duckdb_adapter-0.1.0/pyproject.toml +59 -0
- continuo_duckdb_adapter-0.1.0/tests/__init__.py +0 -0
- continuo_duckdb_adapter-0.1.0/tests/conftest.py +403 -0
- continuo_duckdb_adapter-0.1.0/tests/test_adapter_duckdb_proxy.py +25 -0
- continuo_duckdb_adapter-0.1.0/tests/test_adapter_duckdb_runtime.py +106 -0
- continuo_duckdb_adapter-0.1.0/tests/test_adapter_duckdb_validation.py +150 -0
- continuo_duckdb_adapter-0.1.0/tests/test_adapter_duckdb_wiring.py +25 -0
- continuo_duckdb_adapter-0.1.0/tests/test_domain_duckdb_columns.py +55 -0
- continuo_duckdb_adapter-0.1.0/tests/test_domain_duckdb_identifiers.py +26 -0
- continuo_duckdb_adapter-0.1.0/tests/test_domain_duckdb_layout.py +135 -0
- continuo_duckdb_adapter-0.1.0/tests/test_infra_duckdb_session.py +458 -0
- continuo_duckdb_adapter-0.1.0/tests/test_infra_duckdb_settings_ddl.py +295 -0
- continuo_duckdb_adapter-0.1.0/tests/test_integration_duckdb_layout.py +269 -0
- continuo_duckdb_adapter-0.1.0/tests/test_integration_duckdb_runtime.py +253 -0
- continuo_duckdb_adapter-0.1.0/tests/test_integration_duckdb_stack.py +133 -0
- continuo_duckdb_adapter-0.1.0/tests/test_integration_duckdb_validation.py +226 -0
|
@@ -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))
|