Skip to content

Commit d5e8cdb

Browse files
committed
feat(sql): load remote SQLite and DuckDB databases
- Load SQLite and DuckDB source files from S3, Azure Blob Storage, and Google Cloud Storage. - Stage each object for the complete ingestion lifetime with size checks, byte verification, cleanup, and no remote writeback. - Reuse filesystem credential parsing while keeping credentials out of logs, errors, and object representations. - Document the remote URI contract and cover parsing, lifecycle, compatibility, and emulator-backed ingestion.
1 parent 747b6aa commit d5e8cdb

15 files changed

Lines changed: 1151 additions & 91 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# ADR-001: Stage remote file databases before SQL ingestion
2+
3+
**Status**: Proposed
4+
**Date**: 2026-08-03
5+
6+
## Context
7+
8+
SQLite and DuckDB require random access to a database file through their
9+
existing SQLAlchemy integrations. Object-storage streams do not provide a
10+
shared database interface that both engines can open directly.
11+
12+
Remote database access needs the same URI and lifecycle semantics across
13+
supported storage transports. Storage credentials must remain separate from
14+
logged object locations, and the database file must remain available while dlt
15+
constructs and consumes its lazy SQL resource.
16+
17+
## Decision
18+
19+
We materialize remote SQLite and DuckDB source files into a run-scoped local
20+
temporary directory before invoking the existing SQL source path.
21+
22+
Remote sources use a parameter-form URI grammar with an encoded `location`
23+
value and outer storage options. Initial transport support is limited to
24+
`s3://`, `az://`, and `gs://`, which share the repository's existing filesystem
25+
credential parsing and emulator coverage.
26+
27+
The staging operation checks the remote object's reported size and available
28+
disk space, streams the object while counting bytes, and keeps the local copy
29+
alive through the complete ingestion run. It removes the copy on normal
30+
completion and exception exits. The staged database is source-only, and local
31+
changes are never written back to object storage.
32+
33+
## Alternatives considered
34+
35+
- **Stack storage schemes inside the SQL URI**: rejected because nested URI
36+
authorities, query strings, and credential ownership are ambiguous to
37+
standard URL parsers.
38+
- **Use DuckDB `httpfs` or `ATTACH` as the primary path**: rejected because it
39+
does not provide the same mechanism for SQLite. Engine-specific fast paths
40+
can be added later without changing the public URI grammar.
41+
- **Expose object storage through an engine-specific VFS**: rejected because
42+
there is no shared, maintained random-access interface for both engines and
43+
all three launch transports.
44+
- **Keep a persistent local cache**: rejected because cache invalidation,
45+
credential boundaries, and stale-object behavior require a separate policy.
46+
Run-scoped staging has explicit ownership and cleanup.
47+
- **Write staged database changes back remotely**: rejected because concurrent
48+
writers and atomic replacement require consistency guarantees that a source
49+
ingestion does not need.
50+
51+
## Consequences
52+
53+
- Remote file databases reuse the established SQL reflection, extraction, and
54+
loading path.
55+
- Each run downloads the whole object and requires enough local disk for the
56+
database plus any engine sidecar files.
57+
- Normal success and failure paths remove staged data. A process terminated
58+
without cleanup may leave a temporary directory when a persistent staging
59+
parent is configured.
60+
- Credentials remain storage options and are excluded from safe object
61+
locations and object representations.
62+
- Additional transports need an explicit grammar mapping, credential adapter,
63+
and integration coverage.
64+
- A future engine-specific fast path must preserve the same source-only behavior
65+
and public URI contract.

docs/index.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,14 @@ inherited by [dlt]: `append`, `merge`, and `delete+insert`.
1616

1717
We recommend using [uv](https://github.com/astral-sh/uv) to run `omniload`.
1818

19-
```
19+
```bash
2020
pip install uv
2121
uvx omniload
2222
```
2323

2424
Alternatively, if you'd like to install it globally:
25-
```
25+
26+
```bash
2627
uv pip install --system omniload
2728
```
2829

@@ -35,15 +36,15 @@ Check out the {ref}`Quickstart` guide to get started with omniload.
3536
### License
3637

3738
The project is licensed under the MIT License, see the [LICENSE] file for details.
38-
Some components are licensed under the Apache 2.0 license, see the [NOTICE] file for details.
39+
Some components are licensed under the Apache 2.0 license, see the [NOTICE]
40+
file for details.
3941

4042
### Acknowledgements
4143

4244
This project would not have been possible without the amazing work by the
4345
authors and contributors to [SQLAlchemy], [dlt], and [ingestr], turtles all
4446
the way down. Kudos.
4547

46-
4748
```{toctree}
4849
:caption: Commands and adapters
4950
:maxdepth: 1
@@ -79,13 +80,13 @@ tutorials/*
7980
:maxdepth: 1
8081
:hidden:
8182
:glob:
83+
decisions/*
8284
sandbox
8385
changelog
8486
contributors
8587
backlog
8688
```
8789

88-
8990
[dlt]: https://github.com/dlt-hub/dlt
9091
[ingestr]: https://bruin-data.github.io/ingestr/
9192
[LICENSE]: https://github.com/panodata/omniload/blob/main/LICENSE

docs/supported-sources/duckdb.md

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,56 @@
11
# DuckDB
2+
23
DuckDB is an in-memory database designed to be fast and reliable.
34

45
omniload supports DuckDB as both a source and destination.
56

67
## URI format
8+
79
The URI format for DuckDB is as follows:
810

911
```text
1012
duckdb:///<database-file>
1113
```
1214

1315
URI parameters:
16+
1417
- `database-file`: the path to the DuckDB database file
1518

16-
The same URI structure can be used both for sources and destinations. You can read more about SQLAlchemy's DuckDB dialect [here](https://github.com/Mause/duckdb_engine).
19+
The same URI structure can be used both for sources and destinations. See the
20+
[DuckDB SQLAlchemy dialect][duckdb-sqlalchemy] for details.
21+
22+
## Remote source files
23+
24+
A DuckDB source file can live in [Amazon S3](s3.md),
25+
[Azure Blob Storage](azure-storage.md), or
26+
[Google Cloud Storage](google-cloud-storage.md). Put its percent-encoded object
27+
URI in the `location` query parameter, then add the storage connector's
28+
credentials and options as outer query parameters:
29+
30+
```text
31+
duckdb:///?location=<percent-encoded-s3-az-or-gs-uri>&<storage-options>
32+
```
33+
34+
For example, this loads `main.events` from `s3://analytics/snapshots/events.duckdb`:
35+
36+
```bash
37+
LOCATION='s3%3A%2F%2Fanalytics%2Fsnapshots%2Fevents.duckdb'
38+
CREDENTIALS='access_key_id=ACCESS&secret_access_key=SECRET'
39+
omniload ingest \
40+
--source-uri "duckdb:///?location=${LOCATION}&${CREDENTIALS}" \
41+
--source-table 'main.events' \
42+
--dest-uri 'duckdb:///local.duckdb' \
43+
--dest-table 'raw.events'
44+
```
45+
46+
Percent-encode every query value that contains reserved characters. This is
47+
required for credentials containing characters such as `+`, `/`, `=`, `&`, or
48+
`?`, and for percent-encoded object names. The `location` value itself must not
49+
contain a query or fragment; put all storage options in the outer query.
50+
51+
omniload downloads the complete object into a run-scoped temporary directory
52+
before opening it. The file must fit on local disk. The staged copy is removed
53+
after success or failure, and changes made to it are never written back to
54+
object storage. Remote file databases are supported as sources only.
55+
56+
[duckdb-sqlalchemy]: https://github.com/Mause/duckdb_engine

docs/supported-sources/sqlite.md

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,57 @@
11
# SQLite
2-
SQLite is a C-language library that implements a small, fast, self-contained, high-reliability, full-featured, SQL database engine.
2+
3+
SQLite is a C-language library that implements a small, fast, self-contained,
4+
high-reliability, full-featured SQL database engine.
35

46
omniload supports SQLite as a source and a destination.
57

68
## URI format
9+
710
The URI format for SQLite is as follows:
811

912
```text
1013
sqlite:///<database-file>
1114
```
1215

1316
URI parameters:
17+
1418
- `database-file`: the path to the SQLite database file.
1519

16-
The same URI structure can be used both for sources and destinations. You can read more about SQLAlchemy's SQLite dialect [here](https://docs.sqlalchemy.org/en/20/core/engines.html#sqlite).
20+
The same URI structure can be used both for sources and destinations. See the
21+
[SQLite SQLAlchemy dialect][sqlite-sqlalchemy] for details.
22+
23+
## Remote source files
24+
25+
An SQLite source file can live in [Amazon S3](s3.md),
26+
[Azure Blob Storage](azure-storage.md), or
27+
[Google Cloud Storage](google-cloud-storage.md). Put its percent-encoded object
28+
URI in the `location` query parameter, then add the storage connector's
29+
credentials and options as outer query parameters:
30+
31+
```text
32+
sqlite:///?location=<percent-encoded-s3-az-or-gs-uri>&<storage-options>
33+
```
34+
35+
For example, this loads `main.events` from `s3://analytics/snapshots/events.sqlite`:
36+
37+
```bash
38+
LOCATION='s3%3A%2F%2Fanalytics%2Fsnapshots%2Fevents.sqlite'
39+
CREDENTIALS='access_key_id=ACCESS&secret_access_key=SECRET'
40+
omniload ingest \
41+
--source-uri "sqlite:///?location=${LOCATION}&${CREDENTIALS}" \
42+
--source-table 'main.events' \
43+
--dest-uri 'duckdb:///local.duckdb' \
44+
--dest-table 'raw.events'
45+
```
46+
47+
Percent-encode every query value that contains reserved characters. This is
48+
required for credentials containing characters such as `+`, `/`, `=`, `&`, or
49+
`?`, and for percent-encoded object names. The `location` value itself must not
50+
contain a query or fragment; put all storage options in the outer query.
51+
52+
omniload downloads the complete object into a run-scoped temporary directory
53+
before opening it. The file must fit on local disk. The staged copy is removed
54+
after success or failure, and changes made to it are never written back to
55+
object storage. Remote file databases are supported as sources only.
56+
57+
[sqlite-sqlalchemy]: https://docs.sqlalchemy.org/en/20/dialects/sqlite.html

src/dlt_filesystem/source/impl/remote.py

Lines changed: 9 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import base64
2-
import json
31
from abc import abstractmethod
42
from typing import TYPE_CHECKING, Any, Dict, Type
53
from urllib.parse import parse_qs, urlparse
@@ -16,7 +14,12 @@
1614
parse_uri,
1715
source_selects_single_file,
1816
)
19-
from dlt_filesystem.util.auth import AzureBlobAuth, parse_azure_blob_auth
17+
from dlt_filesystem.util.auth import (
18+
azure_blob_filesystem_kwargs,
19+
gcs_filesystem_kwargs,
20+
parse_azure_blob_auth,
21+
s3_filesystem_kwargs,
22+
)
2023

2124
if TYPE_CHECKING:
2225
from fsspec import AbstractFileSystem
@@ -52,25 +55,7 @@ def dlt_source(self, uri: str, table: str, **kwargs):
5255

5356
bucket_url = f"gs://{bucket_name}"
5457

55-
credentials_path = params.pop("credentials_path", [None])[0]
56-
credentials_base64 = params.pop("credentials_base64", [None])[0]
57-
58-
# Merge params into fs kwargs, without overriding kwargs already
59-
# supplied by the caller (e.g. filesystem_incremental, column_types).
60-
for key, value in params.items():
61-
kwargs.setdefault(key, value[0])
62-
63-
if "token" not in kwargs:
64-
credentials = None
65-
if credentials_path:
66-
credentials = credentials_path
67-
elif credentials_base64:
68-
credentials = json.loads(base64.b64decode(credentials_base64).decode())
69-
else:
70-
credentials = "anon"
71-
kwargs["token"] = credentials
72-
73-
fs = self.fs_class(**kwargs)
58+
fs = self.fs_class(**gcs_filesystem_kwargs(params, kwargs))
7459

7560
try:
7661
endpoint: str = determine_endpoint(table, path_to_file)
@@ -133,27 +118,14 @@ def dlt_source(self, uri: str, table: str, **kwargs):
133118

134119
parsed_uri = urlparse(uri)
135120
source_fields = parse_qs(parsed_uri.query)
136-
access_key_id = source_fields.get("access_key_id")
137-
if not access_key_id:
138-
raise MissingConnectorOption("access_key_id", self.fs_name)
139-
140-
secret_access_key = source_fields.get("secret_access_key")
141-
if not secret_access_key:
142-
raise MissingConnectorOption("secret_access_key", self.fs_name)
143-
121+
fs_kwargs = s3_filesystem_kwargs(source_fields, self.fs_name)
144122
bucket_name, path_to_file = parse_uri(parsed_uri, table)
145123
if not bucket_name or not path_to_file:
146124
raise InvalidBlobTableError(self.fs_name)
147125

148126
bucket_url = f"{self.fs_protocol}://{bucket_name}/"
149127

150128
endpoint_url = source_fields.get("endpoint_url")
151-
fs_kwargs: dict = {
152-
"key": access_key_id[0],
153-
"secret": secret_access_key[0],
154-
}
155-
if endpoint_url:
156-
fs_kwargs["endpoint_url"] = endpoint_url[0]
157129

158130
fs = self.fs_class(**fs_kwargs)
159131

@@ -190,35 +162,6 @@ def fs_name(self) -> str:
190162
return "S3"
191163

192164

193-
def _azure_kwargs(auth: AzureBlobAuth):
194-
"""Return AzureBlobAuth information as dictionary.
195-
196-
The ingestr-style short names already match adlfs kwargs, so they pass
197-
straight through; only the supplied ones are forwarded. ``adlfs`` is
198-
imported lazily so the CLI ``--help`` and every non-Azure path never load
199-
the Azure SDK (matching the s3fs/gcsfs deferred-import convention).
200-
"""
201-
202-
kwargs = {"account_name": auth.account_name}
203-
if auth.account_key is not None:
204-
kwargs["account_key"] = auth.account_key
205-
if auth.sas_token is not None:
206-
kwargs["sas_token"] = auth.sas_token
207-
if auth.tenant_id is not None:
208-
kwargs["tenant_id"] = auth.tenant_id
209-
if auth.client_id is not None:
210-
kwargs["client_id"] = auth.client_id
211-
if auth.client_secret is not None:
212-
kwargs["client_secret"] = auth.client_secret
213-
if auth.account_host is not None:
214-
kwargs["account_host"] = auth.account_host
215-
if auth.connection_string is not None:
216-
kwargs["connection_string"] = auth.connection_string
217-
if auth.api_version is not None:
218-
kwargs["api_version"] = auth.api_version
219-
return kwargs
220-
221-
222165
class AzureSource(FilesystemSource):
223166
"""Azure Blob Storage / ADLS Gen2 source (``az://``, ``adls://``, ``abfss://``).
224167
@@ -256,7 +199,7 @@ def dlt_source(self, uri: str, table: str, **kwargs):
256199

257200
bucket_url = f"az://{bucket_name}"
258201

259-
kwargs.update(_azure_kwargs(auth))
202+
kwargs.update(azure_blob_filesystem_kwargs(auth))
260203
fs = self.fs_class(**kwargs)
261204

262205
try:

0 commit comments

Comments
 (0)