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.
Files changed (38) hide show
  1. ducklens-0.1.0/.gitignore +12 -0
  2. ducklens-0.1.0/LICENSE +21 -0
  3. ducklens-0.1.0/PKG-INFO +132 -0
  4. ducklens-0.1.0/README.md +101 -0
  5. ducklens-0.1.0/ducklens/__init__.py +6 -0
  6. ducklens-0.1.0/ducklens/cli.py +138 -0
  7. ducklens-0.1.0/ducklens/config.py +95 -0
  8. ducklens-0.1.0/ducklens/engine.py +213 -0
  9. ducklens-0.1.0/ducklens/export_sql/bigquery_jobs.sql +8 -0
  10. ducklens-0.1.0/ducklens/export_sql/snowflake_export.sql +69 -0
  11. ducklens-0.1.0/ducklens/export_sql/snowflake_metering_daily.sql +15 -0
  12. ducklens-0.1.0/ducklens/export_sql/snowflake_query_history.sql +11 -0
  13. ducklens-0.1.0/ducklens/export_sql/snowflake_warehouse_metering.sql +6 -0
  14. ducklens-0.1.0/ducklens/export_sql/snowset_to_history.sql +35 -0
  15. ducklens-0.1.0/ducklens/ingest/__init__.py +16 -0
  16. ducklens-0.1.0/ducklens/ingest/bigquery_live.py +86 -0
  17. ducklens-0.1.0/ducklens/ingest/snowflake_live.py +106 -0
  18. ducklens-0.1.0/ducklens/normalize_bigquery.sql +119 -0
  19. ducklens-0.1.0/ducklens/normalize_snowflake.sql +87 -0
  20. ducklens-0.1.0/ducklens/report.py +705 -0
  21. ducklens-0.1.0/ducklens/score.py +161 -0
  22. ducklens-0.1.0/ducklens/scoring.sql +266 -0
  23. ducklens-0.1.0/ducklens/synth/__init__.py +11 -0
  24. ducklens-0.1.0/ducklens/synth/bigquery.py +199 -0
  25. ducklens-0.1.0/ducklens/synth/generator.py +420 -0
  26. ducklens-0.1.0/pyproject.toml +54 -0
  27. ducklens-0.1.0/scripts/tpch_to_history.py +182 -0
  28. ducklens-0.1.0/tests/conftest.py +86 -0
  29. ducklens-0.1.0/tests/test_adversarial.py +65 -0
  30. ducklens-0.1.0/tests/test_bigquery.py +224 -0
  31. ducklens-0.1.0/tests/test_cli.py +84 -0
  32. ducklens-0.1.0/tests/test_concurrency.py +52 -0
  33. ducklens-0.1.0/tests/test_cost.py +62 -0
  34. ducklens-0.1.0/tests/test_end_to_end.py +46 -0
  35. ducklens-0.1.0/tests/test_pull.py +258 -0
  36. ducklens-0.1.0/tests/test_round2_hardening.py +221 -0
  37. ducklens-0.1.0/tests/test_scoring.py +120 -0
  38. ducklens-0.1.0/tests/test_synth.py +47 -0
@@ -0,0 +1,12 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.duckdb
5
+ *.duckdb.wal
6
+ *.egg-info/
7
+ dist/
8
+ build/
9
+ .pytest_cache/
10
+ .ruff_cache/
11
+ .mypy_cache/
12
+ .DS_Store
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.
@@ -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.
@@ -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,6 @@
1
+ """ducklens: see what fits.
2
+
3
+ DuckDB migration fit assessment for Snowflake & BigQuery warehouse spend.
4
+ """
5
+
6
+ __version__ = "0.1.0"
@@ -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
+ )