ducklens 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.
- ducklens-0.1.0/.gitignore +12 -0
- ducklens-0.1.0/LICENSE +21 -0
- ducklens-0.1.0/PKG-INFO +132 -0
- ducklens-0.1.0/README.md +101 -0
- ducklens-0.1.0/ducklens/__init__.py +6 -0
- ducklens-0.1.0/ducklens/cli.py +138 -0
- ducklens-0.1.0/ducklens/config.py +95 -0
- ducklens-0.1.0/ducklens/engine.py +213 -0
- ducklens-0.1.0/ducklens/export_sql/bigquery_jobs.sql +8 -0
- ducklens-0.1.0/ducklens/export_sql/snowflake_export.sql +69 -0
- ducklens-0.1.0/ducklens/export_sql/snowflake_metering_daily.sql +15 -0
- ducklens-0.1.0/ducklens/export_sql/snowflake_query_history.sql +11 -0
- ducklens-0.1.0/ducklens/export_sql/snowflake_warehouse_metering.sql +6 -0
- ducklens-0.1.0/ducklens/export_sql/snowset_to_history.sql +35 -0
- ducklens-0.1.0/ducklens/ingest/__init__.py +16 -0
- ducklens-0.1.0/ducklens/ingest/bigquery_live.py +86 -0
- ducklens-0.1.0/ducklens/ingest/snowflake_live.py +106 -0
- ducklens-0.1.0/ducklens/normalize_bigquery.sql +119 -0
- ducklens-0.1.0/ducklens/normalize_snowflake.sql +87 -0
- ducklens-0.1.0/ducklens/report.py +705 -0
- ducklens-0.1.0/ducklens/score.py +161 -0
- ducklens-0.1.0/ducklens/scoring.sql +266 -0
- ducklens-0.1.0/ducklens/synth/__init__.py +11 -0
- ducklens-0.1.0/ducklens/synth/bigquery.py +199 -0
- ducklens-0.1.0/ducklens/synth/generator.py +420 -0
- ducklens-0.1.0/pyproject.toml +54 -0
- ducklens-0.1.0/scripts/tpch_to_history.py +182 -0
- ducklens-0.1.0/tests/conftest.py +86 -0
- ducklens-0.1.0/tests/test_adversarial.py +65 -0
- ducklens-0.1.0/tests/test_bigquery.py +224 -0
- ducklens-0.1.0/tests/test_cli.py +84 -0
- ducklens-0.1.0/tests/test_concurrency.py +52 -0
- ducklens-0.1.0/tests/test_cost.py +62 -0
- ducklens-0.1.0/tests/test_end_to_end.py +46 -0
- ducklens-0.1.0/tests/test_pull.py +258 -0
- ducklens-0.1.0/tests/test_round2_hardening.py +221 -0
- ducklens-0.1.0/tests/test_scoring.py +120 -0
- ducklens-0.1.0/tests/test_synth.py +47 -0
ducklens-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Munim Zafar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
ducklens-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ducklens
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Score how much of your Snowflake or BigQuery bill fits on one DuckDB machine
|
|
5
|
+
Project-URL: Repository, https://github.com/munimdev/ducklens
|
|
6
|
+
Author: Munim Zafar
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: bigquery,cost,duckdb,finops,migration,snowflake
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Database
|
|
14
|
+
Classifier: Topic :: Utilities
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: duckdb>=1.5.0
|
|
17
|
+
Requires-Dist: pyarrow>=14.0
|
|
18
|
+
Requires-Dist: pytz>=2024.1
|
|
19
|
+
Requires-Dist: rich>=13.0
|
|
20
|
+
Requires-Dist: typer>=0.12
|
|
21
|
+
Provides-Extra: bigquery
|
|
22
|
+
Requires-Dist: google-cloud-bigquery>=3.0; extra == 'bigquery'
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
28
|
+
Provides-Extra: snowflake
|
|
29
|
+
Requires-Dist: snowflake-connector-python>=3.0; extra == 'snowflake'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# ducklens
|
|
33
|
+
|
|
34
|
+
ducklens reads your Snowflake or BigQuery query history and works out how much of the bill could run on a single DuckDB machine. It scores every query, rolls the scores up per warehouse into a move, split, or keep decision, and reconciles the totals to your metered invoice.
|
|
35
|
+
|
|
36
|
+
This is worth measuring because DuckDB is an out-of-core engine. A query that scans two terabytes but streams through a filter and an aggregate runs fine on a box that holds a few gigabytes in memory. What breaks a single machine is the working set, not the scan. So the real question is whether a query spilled, and ducklens answers it from observed spill instead of guessing from scan size.
|
|
37
|
+
|
|
38
|
+
The scorer is one SQL file, `ducklens/scoring.sql`. Every threshold is a named key you can override. Read it, disagree with a number, change it, and re-run.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
uv venv -p 3.12 .venv
|
|
44
|
+
uv pip install --python .venv/bin/python -e '.[dev]'
|
|
45
|
+
.venv/bin/ducklens --help
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Try it without an account
|
|
49
|
+
|
|
50
|
+
`demo` generates synthetic history and runs a full audit:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
ducklens demo
|
|
54
|
+
ducklens demo --source bigquery
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
For a real analytical workload, `scripts/tpch_to_history.py` runs TPC-H locally, captures the actual execution times and scan footprints, and replays them across a few warehouses:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
python scripts/tpch_to_history.py --sf 8 --days 90 --ram-gb 8 --out ./tpch
|
|
61
|
+
ducklens audit --source snowflake \
|
|
62
|
+
--query-history ./tpch/query_history.parquet \
|
|
63
|
+
--metering ./tpch/warehouse_metering_history.parquet \
|
|
64
|
+
--metering-daily ./tpch/metering_daily_history.parquet \
|
|
65
|
+
--ram-gb 8 --db audit.duckdb
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Example output:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
43% of your total Snowflake bill is movable query compute
|
|
72
|
+
= $31,379/yr of $72,178/yr invoice
|
|
73
|
+
|
|
74
|
+
GROSS ANNUAL RUN-RATE DELTA $18,410 - $24,908
|
|
75
|
+
|
|
76
|
+
HYBRID SPLIT
|
|
77
|
+
BI_SERVING_WH 100% $19,769 MOVE
|
|
78
|
+
DBT_WH 78% $14,386 SPLIT
|
|
79
|
+
AD_HOC_WH 6% $6,804 KEEP
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Audit your own account
|
|
83
|
+
|
|
84
|
+
You run a read-only export and ducklens reads the files locally. Nothing leaves your machine and it never sees a credential. The export SQL is in `ducklens/export_sql/`. `snowflake_export.sql` copies the three Account Usage views to a temp stage as parquet, with a switch to drop query text if you would rather not share it.
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
ducklens audit --source snowflake \
|
|
88
|
+
--query-history 'query_history*.parquet' \
|
|
89
|
+
--metering 'warehouse_metering*.parquet' \
|
|
90
|
+
--metering-daily 'metering_daily*.parquet' \
|
|
91
|
+
--db audit.duckdb
|
|
92
|
+
|
|
93
|
+
ducklens audit ... --format html -o report.html
|
|
94
|
+
ducklens explain <query_id> --db audit.duckdb
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`ducklens pull` runs the read-only export for you if you would rather hand it credentials. It needs the `[snowflake]` or `[bigquery]` extra.
|
|
98
|
+
|
|
99
|
+
## How it scores
|
|
100
|
+
|
|
101
|
+
A query fits unless a flag says otherwise. The flags, in priority order:
|
|
102
|
+
|
|
103
|
+
- remote spill, or local spill past the box's memory
|
|
104
|
+
- warehouse-specific SQL that would not port
|
|
105
|
+
- sustained high concurrency, which is a serving workload rather than a batch one
|
|
106
|
+
- multi-cluster scale-out
|
|
107
|
+
- long queue times
|
|
108
|
+
- stored procedures, multi-statement write transactions, and high-frequency writes
|
|
109
|
+
|
|
110
|
+
Spill leads and scan size never blocks a query on its own. Concurrency counts only sustained overlap: sixteen 200ms dashboard pings score zero, while sixteen overlapping 30-second queries score sixteen. Each blocked query is attributed to a single flag, so the residual dollars never double-count.
|
|
111
|
+
|
|
112
|
+
Cost comes from your metered credits, spread across queries by runtime and calibrated so the per-query numbers sum back to what you were billed. The headline anchors to `METERING_DAILY_HISTORY`. Idle warehouse time and serverless spend are separate lines, never folded into the movable number. On BigQuery the model switches to bytes billed.
|
|
113
|
+
|
|
114
|
+
The report prints the movable share of the bill, the per-warehouse split, the costliest queries keeping each warehouse in place, a saving range, and a table of how fit changes with box size. `--format` gives you rich, html, markdown, or json.
|
|
115
|
+
|
|
116
|
+
## On real data
|
|
117
|
+
|
|
118
|
+
Snowset is a public trace of about 70 million real Snowflake queries, released with the NSDI 2020 paper on Snowflake's architecture. It records real spill bytes per query, which is what the scorer needs. On a 5.8 million query sample across 1,290 warehouses, ducklens scores in about 20 seconds and puts 81% of the query compute in the single-machine range, holding the genuinely spill-heavy warehouses back. `ducklens/export_sql/snowset_to_history.sql` maps the trace onto the input schema so you can reproduce this.
|
|
119
|
+
|
|
120
|
+
## When it says stay
|
|
121
|
+
|
|
122
|
+
The report is built to talk you out of a migration when the numbers do not hold. Below roughly a $40k/yr bill, the machine and object storage cost more than you save. Serving workloads, sustained writes, multi-petabyte estates, and regulated governance surfaces stay on the warehouse. The report says so per warehouse, with the dollars attached.
|
|
123
|
+
|
|
124
|
+
## Development
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
.venv/bin/pytest
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## License
|
|
131
|
+
|
|
132
|
+
MIT.
|
ducklens-0.1.0/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# ducklens
|
|
2
|
+
|
|
3
|
+
ducklens reads your Snowflake or BigQuery query history and works out how much of the bill could run on a single DuckDB machine. It scores every query, rolls the scores up per warehouse into a move, split, or keep decision, and reconciles the totals to your metered invoice.
|
|
4
|
+
|
|
5
|
+
This is worth measuring because DuckDB is an out-of-core engine. A query that scans two terabytes but streams through a filter and an aggregate runs fine on a box that holds a few gigabytes in memory. What breaks a single machine is the working set, not the scan. So the real question is whether a query spilled, and ducklens answers it from observed spill instead of guessing from scan size.
|
|
6
|
+
|
|
7
|
+
The scorer is one SQL file, `ducklens/scoring.sql`. Every threshold is a named key you can override. Read it, disagree with a number, change it, and re-run.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uv venv -p 3.12 .venv
|
|
13
|
+
uv pip install --python .venv/bin/python -e '.[dev]'
|
|
14
|
+
.venv/bin/ducklens --help
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Try it without an account
|
|
18
|
+
|
|
19
|
+
`demo` generates synthetic history and runs a full audit:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
ducklens demo
|
|
23
|
+
ducklens demo --source bigquery
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
For a real analytical workload, `scripts/tpch_to_history.py` runs TPC-H locally, captures the actual execution times and scan footprints, and replays them across a few warehouses:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
python scripts/tpch_to_history.py --sf 8 --days 90 --ram-gb 8 --out ./tpch
|
|
30
|
+
ducklens audit --source snowflake \
|
|
31
|
+
--query-history ./tpch/query_history.parquet \
|
|
32
|
+
--metering ./tpch/warehouse_metering_history.parquet \
|
|
33
|
+
--metering-daily ./tpch/metering_daily_history.parquet \
|
|
34
|
+
--ram-gb 8 --db audit.duckdb
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Example output:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
43% of your total Snowflake bill is movable query compute
|
|
41
|
+
= $31,379/yr of $72,178/yr invoice
|
|
42
|
+
|
|
43
|
+
GROSS ANNUAL RUN-RATE DELTA $18,410 - $24,908
|
|
44
|
+
|
|
45
|
+
HYBRID SPLIT
|
|
46
|
+
BI_SERVING_WH 100% $19,769 MOVE
|
|
47
|
+
DBT_WH 78% $14,386 SPLIT
|
|
48
|
+
AD_HOC_WH 6% $6,804 KEEP
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Audit your own account
|
|
52
|
+
|
|
53
|
+
You run a read-only export and ducklens reads the files locally. Nothing leaves your machine and it never sees a credential. The export SQL is in `ducklens/export_sql/`. `snowflake_export.sql` copies the three Account Usage views to a temp stage as parquet, with a switch to drop query text if you would rather not share it.
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
ducklens audit --source snowflake \
|
|
57
|
+
--query-history 'query_history*.parquet' \
|
|
58
|
+
--metering 'warehouse_metering*.parquet' \
|
|
59
|
+
--metering-daily 'metering_daily*.parquet' \
|
|
60
|
+
--db audit.duckdb
|
|
61
|
+
|
|
62
|
+
ducklens audit ... --format html -o report.html
|
|
63
|
+
ducklens explain <query_id> --db audit.duckdb
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`ducklens pull` runs the read-only export for you if you would rather hand it credentials. It needs the `[snowflake]` or `[bigquery]` extra.
|
|
67
|
+
|
|
68
|
+
## How it scores
|
|
69
|
+
|
|
70
|
+
A query fits unless a flag says otherwise. The flags, in priority order:
|
|
71
|
+
|
|
72
|
+
- remote spill, or local spill past the box's memory
|
|
73
|
+
- warehouse-specific SQL that would not port
|
|
74
|
+
- sustained high concurrency, which is a serving workload rather than a batch one
|
|
75
|
+
- multi-cluster scale-out
|
|
76
|
+
- long queue times
|
|
77
|
+
- stored procedures, multi-statement write transactions, and high-frequency writes
|
|
78
|
+
|
|
79
|
+
Spill leads and scan size never blocks a query on its own. Concurrency counts only sustained overlap: sixteen 200ms dashboard pings score zero, while sixteen overlapping 30-second queries score sixteen. Each blocked query is attributed to a single flag, so the residual dollars never double-count.
|
|
80
|
+
|
|
81
|
+
Cost comes from your metered credits, spread across queries by runtime and calibrated so the per-query numbers sum back to what you were billed. The headline anchors to `METERING_DAILY_HISTORY`. Idle warehouse time and serverless spend are separate lines, never folded into the movable number. On BigQuery the model switches to bytes billed.
|
|
82
|
+
|
|
83
|
+
The report prints the movable share of the bill, the per-warehouse split, the costliest queries keeping each warehouse in place, a saving range, and a table of how fit changes with box size. `--format` gives you rich, html, markdown, or json.
|
|
84
|
+
|
|
85
|
+
## On real data
|
|
86
|
+
|
|
87
|
+
Snowset is a public trace of about 70 million real Snowflake queries, released with the NSDI 2020 paper on Snowflake's architecture. It records real spill bytes per query, which is what the scorer needs. On a 5.8 million query sample across 1,290 warehouses, ducklens scores in about 20 seconds and puts 81% of the query compute in the single-machine range, holding the genuinely spill-heavy warehouses back. `ducklens/export_sql/snowset_to_history.sql` maps the trace onto the input schema so you can reproduce this.
|
|
88
|
+
|
|
89
|
+
## When it says stay
|
|
90
|
+
|
|
91
|
+
The report is built to talk you out of a migration when the numbers do not hold. Below roughly a $40k/yr bill, the machine and object storage cost more than you save. Serving workloads, sustained writes, multi-petabyte estates, and regulated governance surfaces stay on the warehouse. The report says so per warehouse, with the dollars attached.
|
|
92
|
+
|
|
93
|
+
## Development
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
.venv/bin/pytest
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
MIT.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""ducklens CLI: the four commands audit, pull, demo, explain.
|
|
2
|
+
|
|
3
|
+
This module owns the command-line surface: flags, help text, and exit codes.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
add_completion=False,
|
|
17
|
+
no_args_is_help=True,
|
|
18
|
+
help=(
|
|
19
|
+
"Assess what percentage of your Snowflake or BigQuery spend fits on one "
|
|
20
|
+
"DuckDB machine."
|
|
21
|
+
),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Source(str, Enum):
|
|
26
|
+
snowflake = "snowflake"
|
|
27
|
+
bigquery = "bigquery"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Edition(str, Enum):
|
|
31
|
+
standard = "standard"
|
|
32
|
+
enterprise = "enterprise"
|
|
33
|
+
business_critical = "business_critical"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class OutFormat(str, Enum):
|
|
37
|
+
rich = "rich"
|
|
38
|
+
json = "json"
|
|
39
|
+
md = "md"
|
|
40
|
+
html = "html"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DemoProfile(str, Enum):
|
|
44
|
+
balanced = "balanced"
|
|
45
|
+
serving_heavy = "serving-heavy"
|
|
46
|
+
batch_heavy = "batch-heavy"
|
|
47
|
+
already_small = "already-small"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _version_callback(value: bool) -> None:
|
|
51
|
+
if value:
|
|
52
|
+
typer.echo(f"ducklens {__version__}")
|
|
53
|
+
raise typer.Exit()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.callback()
|
|
57
|
+
def _root(
|
|
58
|
+
_version: bool = typer.Option(
|
|
59
|
+
False, "--version", callback=_version_callback, is_eager=True, help="Show version and exit."
|
|
60
|
+
),
|
|
61
|
+
) -> None:
|
|
62
|
+
"""See what fits on one DuckDB machine."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.command()
|
|
66
|
+
def audit(
|
|
67
|
+
source: Source = typer.Option(None, help="Warehouse source (auto-detected from columns if omitted)."),
|
|
68
|
+
query_history: Path = typer.Option(None, help="Snowflake QUERY_HISTORY export (parquet/csv; globs ok)."),
|
|
69
|
+
metering: Path = typer.Option(None, help="Snowflake WAREHOUSE_METERING_HISTORY export."),
|
|
70
|
+
metering_daily: Path = typer.Option(None, help="Snowflake METERING_DAILY_HISTORY export (invoice anchor)."),
|
|
71
|
+
jobs: Path = typer.Option(None, help="BigQuery JOBS_BY_PROJECT export (parquet preferred)."),
|
|
72
|
+
edition: Edition = typer.Option(None, help="Snowflake edition, sets the $/credit rate (default enterprise)."),
|
|
73
|
+
credit_rate: float = typer.Option(None, help="Override $/credit with your effective negotiated rate."),
|
|
74
|
+
ram_gb: int = typer.Option(None, help="Target DuckDB machine RAM in GB (default 128)."),
|
|
75
|
+
concurrency_max: int = typer.Option(None, help="Concurrency above which a workload is 'serving' (default 16)."),
|
|
76
|
+
machine_monthly: float = typer.Option(None, help="Assumed DuckDB box $/month (default 800)."),
|
|
77
|
+
migration_cost: float = typer.Option(None, help="One-time migration cost in dollars, used to compute the payback period."),
|
|
78
|
+
config: Path = typer.Option(None, exists=True, help="TOML overriding any scoring threshold (CLI flags win when set)."),
|
|
79
|
+
fmt: OutFormat = typer.Option(OutFormat.rich, "--format", help="Output format."),
|
|
80
|
+
db: Path = typer.Option(None, help="Persist the scored DuckDB database for follow-up querying."),
|
|
81
|
+
output: Path = typer.Option(None, "--output", "-o", help="Write the report to a file."),
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Score a warehouse usage export and print the one-page fit report."""
|
|
84
|
+
from .engine import run_audit # deferred import keeps --help fast
|
|
85
|
+
|
|
86
|
+
run_audit(locals())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@app.command()
|
|
90
|
+
def demo(
|
|
91
|
+
source: Source = typer.Option(Source.snowflake, help="Warehouse model to simulate (snowflake credits or bigquery bytes-billed)."),
|
|
92
|
+
queries: int = typer.Option(25_000, help="Number of synthetic queries to generate."),
|
|
93
|
+
days: int = typer.Option(90, help="Days of synthetic history (>=1 month-end recommended)."),
|
|
94
|
+
seed: int = typer.Option(7, help="RNG seed for deterministic output."),
|
|
95
|
+
profile: DemoProfile = typer.Option(DemoProfile.balanced, help="Workload shape to simulate (snowflake only)."),
|
|
96
|
+
fmt: OutFormat = typer.Option(OutFormat.rich, "--format", help="Output format."),
|
|
97
|
+
db: Path = typer.Option(None, help="Persist the scored DuckDB database (enables `ducklens explain`)."),
|
|
98
|
+
output: Path = typer.Option(None, "--output", "-o", help="Write the report to a file."),
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Generate synthetic usage and run a full audit, no warehouse account needed."""
|
|
101
|
+
from .engine import run_demo
|
|
102
|
+
|
|
103
|
+
run_demo(locals())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@app.command()
|
|
107
|
+
def pull(
|
|
108
|
+
source: Source = typer.Argument(..., help="Warehouse to export from (read-only)."),
|
|
109
|
+
out_dir: Path = typer.Option(Path("./export"), help="Where to write the exported parquet files."),
|
|
110
|
+
account: str = typer.Option(None, help="Snowflake account identifier (or BigQuery project)."),
|
|
111
|
+
user: str = typer.Option(None, help="Snowflake user (password via $SNOWFLAKE_PASSWORD)."),
|
|
112
|
+
role: str = typer.Option(None, help="Snowflake role with ACCOUNT_USAGE privileges."),
|
|
113
|
+
region: str = typer.Option(None, help="BigQuery region (INFORMATION_SCHEMA.JOBS is region-scoped)."),
|
|
114
|
+
days: int = typer.Option(90, help="Window to export."),
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Run the usage export yourself (read-only) and write parquet you can hand off.
|
|
117
|
+
|
|
118
|
+
Live mode needs your OWN credentials and the connector extra
|
|
119
|
+
(pip install ducklens with the snowflake / bigquery extra); ducklens never sees them.
|
|
120
|
+
"""
|
|
121
|
+
from .engine import run_pull # deferred import keeps --help fast and avoids the connector
|
|
122
|
+
|
|
123
|
+
run_pull(locals())
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@app.command()
|
|
127
|
+
def explain(
|
|
128
|
+
query_id: str = typer.Argument(..., help="The query_id to explain."),
|
|
129
|
+
db: Path = typer.Option(..., exists=True, help="A scored DuckDB database (from `audit --db`)."),
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Explain why one query was scored as fitting / not fitting."""
|
|
132
|
+
from .engine import run_explain
|
|
133
|
+
|
|
134
|
+
run_explain(query_id, db)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
if __name__ == "__main__":
|
|
138
|
+
app()
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Scoring configuration. Every threshold is a named, overridable key.
|
|
2
|
+
|
|
3
|
+
`Config.setup(con)` materializes a one-row `config` table and a `wh_size_credits` table in
|
|
4
|
+
DuckDB, so `scoring.sql` references thresholds by name and holds no magic constants. Read the
|
|
5
|
+
SQL and re-run with `--config thresholds.toml` to change any of them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import tomllib
|
|
11
|
+
from dataclasses import dataclass, fields
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import duckdb
|
|
15
|
+
|
|
16
|
+
EDITION_CREDIT_RATE = {"standard": 2.0, "enterprise": 3.0, "business_critical": 4.0}
|
|
17
|
+
|
|
18
|
+
# Snowflake Gen1 warehouse size to credits/hour (doubling table).
|
|
19
|
+
SIZE_CREDITS_HR = {
|
|
20
|
+
"XS": 1, "S": 2, "M": 4, "L": 8, "XL": 16,
|
|
21
|
+
"2XL": 32, "3XL": 64, "4XL": 128, "5XL": 256, "6XL": 512,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
GB = 1_000_000_000
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Config:
|
|
29
|
+
# cost
|
|
30
|
+
edition: str = "enterprise"
|
|
31
|
+
credit_rate: float | None = None # override $/credit (effective negotiated rate)
|
|
32
|
+
machine_monthly: float = 800.0 # assumed DuckDB box $/month
|
|
33
|
+
scan_per_tib: float = 0.50 # object-storage scan $/TiB (conservative)
|
|
34
|
+
# bigquery
|
|
35
|
+
bq_per_tib: float = 6.25
|
|
36
|
+
bq_free_tib_per_month: float = 1.0
|
|
37
|
+
# fit thresholds
|
|
38
|
+
machine_ram_gb: int = 128
|
|
39
|
+
ram_headroom: float = 0.6 # working set tolerated = ram * headroom
|
|
40
|
+
concurrency_max: float = 16.0 # sustained queries above which a workload is serving
|
|
41
|
+
concurrency_min_ms: float = 1000.0 # only queries this long count toward concurrency
|
|
42
|
+
overload_frac: float = 0.2
|
|
43
|
+
write_rate_per_hr: float = 120.0
|
|
44
|
+
# warehouse roll-up
|
|
45
|
+
warehouse_move_pct: float = 0.80
|
|
46
|
+
warehouse_split_pct: float = 0.40
|
|
47
|
+
# one-time migration cost (FTE-months x loaded rate, or a flat dollar amount) for payback
|
|
48
|
+
migration_cost: float | None = None
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def resolved_credit_rate(self) -> float:
|
|
52
|
+
if self.credit_rate is not None:
|
|
53
|
+
return self.credit_rate
|
|
54
|
+
return EDITION_CREDIT_RATE[self.edition]
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def load(cls, toml_path: str | Path | None = None, **overrides) -> Config:
|
|
58
|
+
data: dict = {}
|
|
59
|
+
if toml_path:
|
|
60
|
+
data = tomllib.loads(Path(toml_path).read_text())
|
|
61
|
+
data.update({k: v for k, v in overrides.items() if v is not None})
|
|
62
|
+
known = {f.name for f in fields(cls)}
|
|
63
|
+
unknown = set(data) - known
|
|
64
|
+
if unknown:
|
|
65
|
+
raise ValueError(f"unknown config keys: {sorted(unknown)}")
|
|
66
|
+
return cls(**{k: v for k, v in data.items() if k in known})
|
|
67
|
+
|
|
68
|
+
def setup(self, con: duckdb.DuckDBPyConnection) -> None:
|
|
69
|
+
"""Materialize `config` (one row) and `wh_size_credits` in the connection."""
|
|
70
|
+
# All date_trunc/epoch math must be UTC so query timestamps align to the UTC-hour
|
|
71
|
+
# metering buckets, or +5:30/+5:45 sessions silently miss the metering join.
|
|
72
|
+
con.execute("SET TimeZone='UTC'")
|
|
73
|
+
cols = {
|
|
74
|
+
"credit_rate": self.resolved_credit_rate,
|
|
75
|
+
"machine_monthly": self.machine_monthly,
|
|
76
|
+
"scan_per_tib": self.scan_per_tib,
|
|
77
|
+
"bq_per_tib": self.bq_per_tib,
|
|
78
|
+
"bq_free_tib_per_month": self.bq_free_tib_per_month,
|
|
79
|
+
"ram_bytes": self.machine_ram_gb * GB,
|
|
80
|
+
"ram_headroom": self.ram_headroom,
|
|
81
|
+
"concurrency_max": self.concurrency_max,
|
|
82
|
+
"concurrency_min_ms": self.concurrency_min_ms,
|
|
83
|
+
"overload_frac": self.overload_frac,
|
|
84
|
+
"write_rate_per_hr": self.write_rate_per_hr,
|
|
85
|
+
"warehouse_move_pct": self.warehouse_move_pct,
|
|
86
|
+
"warehouse_split_pct": self.warehouse_split_pct,
|
|
87
|
+
}
|
|
88
|
+
select = ", ".join(f"{float(v)} AS {k}" for k, v in cols.items())
|
|
89
|
+
con.execute(f"CREATE OR REPLACE TABLE config AS SELECT {select}")
|
|
90
|
+
|
|
91
|
+
con.execute("CREATE OR REPLACE TABLE wh_size_credits(size VARCHAR, credits_hr DOUBLE)")
|
|
92
|
+
con.executemany(
|
|
93
|
+
"INSERT INTO wh_size_credits VALUES (?, ?)",
|
|
94
|
+
[(s, float(c)) for s, c in SIZE_CREDITS_HR.items()],
|
|
95
|
+
)
|