boti-data 1.3.7__tar.gz → 1.3.9__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.
- {boti_data-1.3.7 → boti_data-1.3.9}/PKG-INFO +124 -1
- {boti_data-1.3.7 → boti_data-1.3.9}/README.md +123 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/pyproject.toml +1 -1
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/__init__.py +6 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/partitioned_execution.py +19 -16
- boti_data-1.3.9/src/boti_data/db/partitioned_fetch_gate.py +138 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/partitioned_loader.py +7 -1
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/enrichment/protocol.py +7 -4
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/enrichment/transformers.py +28 -7
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/core.py +66 -19
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/sql_partitioned_exec.py +11 -2
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/parquet/schema_filters.py +81 -19
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/pipelines/sinks_common.py +62 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/pipelines/sinks_parquet.py +1 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data.egg-info/PKG-INFO +124 -1
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data.egg-info/SOURCES.txt +1 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/setup.cfg +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/arrow_schema.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/connection_catalog.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/datacube/__init__.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/datacube/artifact.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/datacube/base.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/datacube/contract.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/datacube/resource.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/dataset/__init__.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/dataset/hybrid.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/dataset/hybrid_support.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/__init__.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/arrow_schema_mapper.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/engine_registry.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/partitioned_planner.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/partitioned_planner_async.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/partitioned_planning_math.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/partitioned_row_materialization.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/partitioned_statements.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/partitioned_types.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/sql_config.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/sql_engine.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/sql_manager.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/sql_model_builder.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/sql_model_module_injection.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/sql_model_registry.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/sql_model_registry_types.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/sql_readonly.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/sql_repository.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/sql_resource.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/sqlalchemy_async.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/vector_search.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/enrichment/__init__.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/enrichment/async_enricher.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/enrichment/composite.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/enrichment/specs.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/field_map.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/filters/__init__.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/filters/arrow_kernels.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/filters/arrow_kernels_comparison.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/filters/arrow_kernels_compile.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/filters/arrow_kernels_string.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/filters/backend_ops.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/filters/expressions.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/filters/handler.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/filters/parquet_compiler.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/filters/utils.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/filters/value_parsing.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/__init__.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/_backend_strategies.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/_factory.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/_gateway_init.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/_payloads.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/_series_filters.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/arrow_adapters.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/build_context.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/chunking.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/configured_load.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/core_describe.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/core_load.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/datacube_strategy.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/frame_convert.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/frame_strategies.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/incremental.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/load_execution.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/load_request.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/loaders.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/normalization.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/parquet_strategy.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/planning.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/policies.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/post_process.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/requests.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/return_type.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/select_cache.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/semi_join.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/sql_describe.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/sql_guard.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/sql_size_estimation.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/sql_strategy.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/sql_strategy_configured_async.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/helper.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/joins.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/parquet/__init__.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/parquet/config.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/parquet/discovery.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/parquet/file_info.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/parquet/filesystem.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/parquet/reader.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/parquet/reader_config.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/parquet/resource.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/pipelines/__init__.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/pipelines/base.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/pipelines/parquet_pipeline.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/pipelines/registry.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/pipelines/sinks.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/pipelines/sinks_text.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/py.typed +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/schema.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/watermark/__init__.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/watermark/incremental.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/watermark/store.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data.egg-info/dependency_links.txt +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data.egg-info/requires.txt +0 -0
- {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: boti-data
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.9
|
|
4
4
|
Summary: Data infrastructure for the Boti ecosystem
|
|
5
5
|
Author: Luis Valverde
|
|
6
6
|
Maintainer-email: Luis Valverde <lvalverdeb@gmail.com>
|
|
@@ -884,6 +884,129 @@ with DataHelper.session(cluster_factory=LocalCluster) as client:
|
|
|
884
884
|
|
|
885
885
|
---
|
|
886
886
|
|
|
887
|
+
## Sizing connections and fetch concurrency
|
|
888
|
+
|
|
889
|
+
More database connections do not mean more throughput. Past a modest number they
|
|
890
|
+
mean *less*, because connections compete for the same cores, disks and locks. Both
|
|
891
|
+
the [PostgreSQL wiki](https://wiki.postgresql.org/wiki/Number_Of_Database_Connections)
|
|
892
|
+
and the [HikariCP pool-sizing guide](https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing)
|
|
893
|
+
give the same starting formula:
|
|
894
|
+
|
|
895
|
+
```
|
|
896
|
+
connections = (core_count * 2) + effective_spindle_count
|
|
897
|
+
```
|
|
898
|
+
|
|
899
|
+
Count physical cores, not hyperthreads, and take `effective_spindle_count` as zero
|
|
900
|
+
when the working set is cached in RAM. For a 4-core server with one disk that is
|
|
901
|
+
about 10 connections in total — not per client, not per worker, **in total**.
|
|
902
|
+
|
|
903
|
+
The characteristic failure of getting this wrong is counter-intuitive: the database
|
|
904
|
+
sits nearly idle, at a few percent CPU, while clients time out. The connections are
|
|
905
|
+
all queued rather than working. Raising the pool size makes it worse.
|
|
906
|
+
|
|
907
|
+
`boti-data` exposes three separate numbers that multiply into that total. Size the
|
|
908
|
+
product, not the individual knobs.
|
|
909
|
+
|
|
910
|
+
### 1. Client-side pool: `pool_size + max_overflow`
|
|
911
|
+
|
|
912
|
+
`SqlDatabaseConfig` defaults to `pool_size=5` and `max_overflow=10`, so a single
|
|
913
|
+
engine bursts to **15** connections, not 5. `max_overflow` is three times
|
|
914
|
+
`pool_size`, so the steady-state number is a third of the real ceiling.
|
|
915
|
+
|
|
916
|
+
```python
|
|
917
|
+
config = SqlDatabaseConfig(
|
|
918
|
+
connection_url="postgresql+psycopg://...",
|
|
919
|
+
pool_size=5, # steady state
|
|
920
|
+
max_overflow=10, # burst headroom — the ceiling is 5 + 10 = 15
|
|
921
|
+
)
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
Engines are cached per distinct configuration, so two differently-configured helpers
|
|
925
|
+
against the same database hold two pools: 30 connections, not 15.
|
|
926
|
+
|
|
927
|
+
### 2. Distributed fetch concurrency: `n_workers × max_concurrent_fetches`
|
|
928
|
+
|
|
929
|
+
This is the one that surprises people. `max_concurrent_fetches` (default `4`, capped
|
|
930
|
+
at `16`) bounds concurrent partition fetches **per worker process**, because the
|
|
931
|
+
semaphore enforcing it lives in process memory. It is not a cluster-wide limit.
|
|
932
|
+
|
|
933
|
+
```
|
|
934
|
+
20 Dask workers × max_concurrent_fetches=4 = up to 80 concurrent connections
|
|
935
|
+
```
|
|
936
|
+
|
|
937
|
+
To work backwards from a budget:
|
|
938
|
+
|
|
939
|
+
```python
|
|
940
|
+
max_concurrent_fetches = max(1, connection_budget // n_workers)
|
|
941
|
+
```
|
|
942
|
+
|
|
943
|
+
With the 10-connection budget above and 5 workers, that is `2` — not the default `4`.
|
|
944
|
+
|
|
945
|
+
### 3. Worker connections are unpooled by design
|
|
946
|
+
|
|
947
|
+
Worker engines always use `NullPool`. Connection pools cannot be pickled and would
|
|
948
|
+
bind to the wrong event loop, so each partition fetch opens a fresh connection and
|
|
949
|
+
closes it. That is correct for distribution, but it means every fetch pays a full TCP
|
|
950
|
+
handshake, TLS negotiation and authentication — on a remote database, tens of
|
|
951
|
+
milliseconds each, before a single row is read. A 200-partition load pays it 200 times.
|
|
952
|
+
|
|
953
|
+
The fix is not to pool inside the workers, but to put a pooler in front of the
|
|
954
|
+
database, so that warm server-side connections are reused while workers keep opening
|
|
955
|
+
cheap local ones. **The pooling mode matters**, because it interacts with `query_only`:
|
|
956
|
+
|
|
957
|
+
| Pooler mode | Connection reuse | `query_only=True` |
|
|
958
|
+
|---|---|---|
|
|
959
|
+
| Session (e.g. PgBouncer `session`) | Yes | **Supported** — the read-only `SET` lasts for the session |
|
|
960
|
+
| Transaction (e.g. PgBouncer `transaction`) | Higher | **Not supported** — see below |
|
|
961
|
+
|
|
962
|
+
`query_only=True` issues its read-only statement on the SQLAlchemy `connect` event, so
|
|
963
|
+
it applies to a physical connection. Under transaction-mode pooling a physical
|
|
964
|
+
connection can be handed to a different logical client between transactions, and that
|
|
965
|
+
session state is not guaranteed to survive the handoff. The failure is silent: a load
|
|
966
|
+
that believes it is read-only quietly stops being so.
|
|
967
|
+
|
|
968
|
+
With transaction-mode pooling, set `query_only=False` and enforce read-only access
|
|
969
|
+
with database grants instead, where it cannot be lost in a handoff.
|
|
970
|
+
|
|
971
|
+
### Measuring it
|
|
972
|
+
|
|
973
|
+
Set `diagnostics=True` on a partitioned load and the completion line reports how long
|
|
974
|
+
fetches spent queueing for a slot:
|
|
975
|
+
|
|
976
|
+
```
|
|
977
|
+
Partitioned SQL load completed strategy=offset partitions=4 rows=40 result_partitions=1
|
|
978
|
+
elapsed=0.04s prepare_stmt=0.000s load_plan=0.031s
|
|
979
|
+
gate_waits=0/4 gate_wait_total=0.000s gate_wait_max_seen=0.000s
|
|
980
|
+
```
|
|
981
|
+
|
|
982
|
+
`gate_waits=0/4` means none of the four fetches had to wait. A rising
|
|
983
|
+
`gate_wait_total` alongside a database that is not CPU-bound is the signature of the
|
|
984
|
+
failure above — concurrency queued rather than working. The other stage timers cannot
|
|
985
|
+
show this, because they are all measured *inside* the gate.
|
|
986
|
+
|
|
987
|
+
The fields are omitted entirely when no fetch ran in the logging process. A lazy load
|
|
988
|
+
returns a Dask graph before anything is fetched, and in a distributed run the fetches
|
|
989
|
+
happen in other processes; reporting `0.000s` there would say "no contention" when the
|
|
990
|
+
truth is "not measured here".
|
|
991
|
+
|
|
992
|
+
For lazy loads, read the counters after `.compute()` instead:
|
|
993
|
+
|
|
994
|
+
```python
|
|
995
|
+
from boti_data import fetch_gate_stats, reset_fetch_gate_stats
|
|
996
|
+
|
|
997
|
+
reset_fetch_gate_stats()
|
|
998
|
+
result = helper.dask.load().compute()
|
|
999
|
+
|
|
1000
|
+
for gate_key, stats in fetch_gate_stats().items():
|
|
1001
|
+
print(gate_key, stats.waited_fetches, "/", stats.fetches, stats.total_wait_seconds)
|
|
1002
|
+
```
|
|
1003
|
+
|
|
1004
|
+
`fetch_gate_stats()` reports the **calling process only**. On a distributed cluster each
|
|
1005
|
+
worker accumulates its own counters, so run it on the workers (e.g. via
|
|
1006
|
+
`client.run(fetch_gate_stats)`) to see the whole picture.
|
|
1007
|
+
|
|
1008
|
+
---
|
|
1009
|
+
|
|
887
1010
|
## Choosing between distributed and non-distributed
|
|
888
1011
|
|
|
889
1012
|
Use the following as a guide:
|
|
@@ -845,6 +845,129 @@ with DataHelper.session(cluster_factory=LocalCluster) as client:
|
|
|
845
845
|
|
|
846
846
|
---
|
|
847
847
|
|
|
848
|
+
## Sizing connections and fetch concurrency
|
|
849
|
+
|
|
850
|
+
More database connections do not mean more throughput. Past a modest number they
|
|
851
|
+
mean *less*, because connections compete for the same cores, disks and locks. Both
|
|
852
|
+
the [PostgreSQL wiki](https://wiki.postgresql.org/wiki/Number_Of_Database_Connections)
|
|
853
|
+
and the [HikariCP pool-sizing guide](https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing)
|
|
854
|
+
give the same starting formula:
|
|
855
|
+
|
|
856
|
+
```
|
|
857
|
+
connections = (core_count * 2) + effective_spindle_count
|
|
858
|
+
```
|
|
859
|
+
|
|
860
|
+
Count physical cores, not hyperthreads, and take `effective_spindle_count` as zero
|
|
861
|
+
when the working set is cached in RAM. For a 4-core server with one disk that is
|
|
862
|
+
about 10 connections in total — not per client, not per worker, **in total**.
|
|
863
|
+
|
|
864
|
+
The characteristic failure of getting this wrong is counter-intuitive: the database
|
|
865
|
+
sits nearly idle, at a few percent CPU, while clients time out. The connections are
|
|
866
|
+
all queued rather than working. Raising the pool size makes it worse.
|
|
867
|
+
|
|
868
|
+
`boti-data` exposes three separate numbers that multiply into that total. Size the
|
|
869
|
+
product, not the individual knobs.
|
|
870
|
+
|
|
871
|
+
### 1. Client-side pool: `pool_size + max_overflow`
|
|
872
|
+
|
|
873
|
+
`SqlDatabaseConfig` defaults to `pool_size=5` and `max_overflow=10`, so a single
|
|
874
|
+
engine bursts to **15** connections, not 5. `max_overflow` is three times
|
|
875
|
+
`pool_size`, so the steady-state number is a third of the real ceiling.
|
|
876
|
+
|
|
877
|
+
```python
|
|
878
|
+
config = SqlDatabaseConfig(
|
|
879
|
+
connection_url="postgresql+psycopg://...",
|
|
880
|
+
pool_size=5, # steady state
|
|
881
|
+
max_overflow=10, # burst headroom — the ceiling is 5 + 10 = 15
|
|
882
|
+
)
|
|
883
|
+
```
|
|
884
|
+
|
|
885
|
+
Engines are cached per distinct configuration, so two differently-configured helpers
|
|
886
|
+
against the same database hold two pools: 30 connections, not 15.
|
|
887
|
+
|
|
888
|
+
### 2. Distributed fetch concurrency: `n_workers × max_concurrent_fetches`
|
|
889
|
+
|
|
890
|
+
This is the one that surprises people. `max_concurrent_fetches` (default `4`, capped
|
|
891
|
+
at `16`) bounds concurrent partition fetches **per worker process**, because the
|
|
892
|
+
semaphore enforcing it lives in process memory. It is not a cluster-wide limit.
|
|
893
|
+
|
|
894
|
+
```
|
|
895
|
+
20 Dask workers × max_concurrent_fetches=4 = up to 80 concurrent connections
|
|
896
|
+
```
|
|
897
|
+
|
|
898
|
+
To work backwards from a budget:
|
|
899
|
+
|
|
900
|
+
```python
|
|
901
|
+
max_concurrent_fetches = max(1, connection_budget // n_workers)
|
|
902
|
+
```
|
|
903
|
+
|
|
904
|
+
With the 10-connection budget above and 5 workers, that is `2` — not the default `4`.
|
|
905
|
+
|
|
906
|
+
### 3. Worker connections are unpooled by design
|
|
907
|
+
|
|
908
|
+
Worker engines always use `NullPool`. Connection pools cannot be pickled and would
|
|
909
|
+
bind to the wrong event loop, so each partition fetch opens a fresh connection and
|
|
910
|
+
closes it. That is correct for distribution, but it means every fetch pays a full TCP
|
|
911
|
+
handshake, TLS negotiation and authentication — on a remote database, tens of
|
|
912
|
+
milliseconds each, before a single row is read. A 200-partition load pays it 200 times.
|
|
913
|
+
|
|
914
|
+
The fix is not to pool inside the workers, but to put a pooler in front of the
|
|
915
|
+
database, so that warm server-side connections are reused while workers keep opening
|
|
916
|
+
cheap local ones. **The pooling mode matters**, because it interacts with `query_only`:
|
|
917
|
+
|
|
918
|
+
| Pooler mode | Connection reuse | `query_only=True` |
|
|
919
|
+
|---|---|---|
|
|
920
|
+
| Session (e.g. PgBouncer `session`) | Yes | **Supported** — the read-only `SET` lasts for the session |
|
|
921
|
+
| Transaction (e.g. PgBouncer `transaction`) | Higher | **Not supported** — see below |
|
|
922
|
+
|
|
923
|
+
`query_only=True` issues its read-only statement on the SQLAlchemy `connect` event, so
|
|
924
|
+
it applies to a physical connection. Under transaction-mode pooling a physical
|
|
925
|
+
connection can be handed to a different logical client between transactions, and that
|
|
926
|
+
session state is not guaranteed to survive the handoff. The failure is silent: a load
|
|
927
|
+
that believes it is read-only quietly stops being so.
|
|
928
|
+
|
|
929
|
+
With transaction-mode pooling, set `query_only=False` and enforce read-only access
|
|
930
|
+
with database grants instead, where it cannot be lost in a handoff.
|
|
931
|
+
|
|
932
|
+
### Measuring it
|
|
933
|
+
|
|
934
|
+
Set `diagnostics=True` on a partitioned load and the completion line reports how long
|
|
935
|
+
fetches spent queueing for a slot:
|
|
936
|
+
|
|
937
|
+
```
|
|
938
|
+
Partitioned SQL load completed strategy=offset partitions=4 rows=40 result_partitions=1
|
|
939
|
+
elapsed=0.04s prepare_stmt=0.000s load_plan=0.031s
|
|
940
|
+
gate_waits=0/4 gate_wait_total=0.000s gate_wait_max_seen=0.000s
|
|
941
|
+
```
|
|
942
|
+
|
|
943
|
+
`gate_waits=0/4` means none of the four fetches had to wait. A rising
|
|
944
|
+
`gate_wait_total` alongside a database that is not CPU-bound is the signature of the
|
|
945
|
+
failure above — concurrency queued rather than working. The other stage timers cannot
|
|
946
|
+
show this, because they are all measured *inside* the gate.
|
|
947
|
+
|
|
948
|
+
The fields are omitted entirely when no fetch ran in the logging process. A lazy load
|
|
949
|
+
returns a Dask graph before anything is fetched, and in a distributed run the fetches
|
|
950
|
+
happen in other processes; reporting `0.000s` there would say "no contention" when the
|
|
951
|
+
truth is "not measured here".
|
|
952
|
+
|
|
953
|
+
For lazy loads, read the counters after `.compute()` instead:
|
|
954
|
+
|
|
955
|
+
```python
|
|
956
|
+
from boti_data import fetch_gate_stats, reset_fetch_gate_stats
|
|
957
|
+
|
|
958
|
+
reset_fetch_gate_stats()
|
|
959
|
+
result = helper.dask.load().compute()
|
|
960
|
+
|
|
961
|
+
for gate_key, stats in fetch_gate_stats().items():
|
|
962
|
+
print(gate_key, stats.waited_fetches, "/", stats.fetches, stats.total_wait_seconds)
|
|
963
|
+
```
|
|
964
|
+
|
|
965
|
+
`fetch_gate_stats()` reports the **calling process only**. On a distributed cluster each
|
|
966
|
+
worker accumulates its own counters, so run it on the workers (e.g. via
|
|
967
|
+
`client.run(fetch_gate_stats)`) to see the whole picture.
|
|
968
|
+
|
|
969
|
+
---
|
|
970
|
+
|
|
848
971
|
## Choosing between distributed and non-distributed
|
|
849
972
|
|
|
850
973
|
Use the following as a guide:
|
|
@@ -60,6 +60,9 @@ __all__ = [
|
|
|
60
60
|
"SqlPartitionSpec",
|
|
61
61
|
"SqlPartitionedLoadRequest",
|
|
62
62
|
"SqlPartitionedLoader",
|
|
63
|
+
"GateWaitStats",
|
|
64
|
+
"fetch_gate_stats",
|
|
65
|
+
"reset_fetch_gate_stats",
|
|
63
66
|
"SqlModelRegistry",
|
|
64
67
|
"SqlRepository",
|
|
65
68
|
"SqlUnitOfWork",
|
|
@@ -119,6 +122,9 @@ _LAZY = {
|
|
|
119
122
|
# dask+pandas — the one heavy import inside the otherwise-clean db package.
|
|
120
123
|
"SqlPartitionedLoader": ("boti_data.db.partitioned_loader", "SqlPartitionedLoader"),
|
|
121
124
|
"SqlPartitionedLoadRequest": ("boti_data.db.partitioned_loader", "SqlPartitionedLoadRequest"),
|
|
125
|
+
"GateWaitStats": ("boti_data.db.partitioned_execution", "GateWaitStats"),
|
|
126
|
+
"fetch_gate_stats": ("boti_data.db.partitioned_execution", "fetch_gate_stats"),
|
|
127
|
+
"reset_fetch_gate_stats": ("boti_data.db.partitioned_execution", "reset_fetch_gate_stats"),
|
|
122
128
|
# dask/pandas/polars/pyarrow-touching — the dataframe-shaped, bulk-oriented side.
|
|
123
129
|
"ConnectionCatalog": ("boti_data.connection_catalog", "ConnectionCatalog"),
|
|
124
130
|
"ParquetDataConfig": ("boti_data.parquet", "ParquetDataConfig"),
|
|
@@ -10,6 +10,13 @@ import dask.dataframe as dd
|
|
|
10
10
|
import pandas as pd
|
|
11
11
|
from sqlalchemy.sql import Select
|
|
12
12
|
|
|
13
|
+
from boti_data.db.partitioned_fetch_gate import (
|
|
14
|
+
GateWaitStats,
|
|
15
|
+
_timed_fetch_gate,
|
|
16
|
+
fetch_gate_stats,
|
|
17
|
+
format_gate_wait_suffix,
|
|
18
|
+
reset_fetch_gate_stats,
|
|
19
|
+
)
|
|
13
20
|
from boti_data.db.partitioned_row_materialization import (
|
|
14
21
|
align_and_coerce_partition,
|
|
15
22
|
arrow_align_and_coerce_partition,
|
|
@@ -27,8 +34,16 @@ from boti_data.db.sql_engine import (
|
|
|
27
34
|
_get_worker_engine_identity,
|
|
28
35
|
)
|
|
29
36
|
|
|
30
|
-
|
|
31
|
-
|
|
37
|
+
# The fetch gate and its contention counters live in partitioned_fetch_gate.py
|
|
38
|
+
# (long-file headroom) and are re-exported here so existing import paths — and
|
|
39
|
+
# the tests and callers that use them — keep working unchanged.
|
|
40
|
+
__all__ = [
|
|
41
|
+
"GateWaitStats",
|
|
42
|
+
"SqlPartitionExecutor",
|
|
43
|
+
"fetch_gate_stats",
|
|
44
|
+
"format_gate_wait_suffix",
|
|
45
|
+
"reset_fetch_gate_stats",
|
|
46
|
+
]
|
|
32
47
|
|
|
33
48
|
# Thread-local caches: avoid recreating engine objects and event loops on every partition fetch.
|
|
34
49
|
# The engine itself is cheap state (dialect, URL, event listeners); caching it per worker
|
|
@@ -76,16 +91,6 @@ def _get_cached_worker_async_engine(config: WorkerSqlConfig) -> Any:
|
|
|
76
91
|
return engine
|
|
77
92
|
|
|
78
93
|
|
|
79
|
-
def _get_fetch_gate(gate_key: str, max_concurrent_fetches: int) -> threading.BoundedSemaphore:
|
|
80
|
-
cache_key = (gate_key, max_concurrent_fetches)
|
|
81
|
-
with _FETCH_GATES_LOCK:
|
|
82
|
-
gate = _FETCH_GATES.get(cache_key)
|
|
83
|
-
if gate is None:
|
|
84
|
-
gate = threading.BoundedSemaphore(max_concurrent_fetches)
|
|
85
|
-
_FETCH_GATES[cache_key] = gate
|
|
86
|
-
return gate
|
|
87
|
-
|
|
88
|
-
|
|
89
94
|
class SqlPartitionExecutor:
|
|
90
95
|
"""Execute planned partitions and assemble lazy Dask outputs."""
|
|
91
96
|
|
|
@@ -216,8 +221,7 @@ class SqlPartitionExecutor:
|
|
|
216
221
|
meta_dtypes: dict[str, str],
|
|
217
222
|
use_arrow: bool = True,
|
|
218
223
|
) -> pd.DataFrame:
|
|
219
|
-
|
|
220
|
-
with gate:
|
|
224
|
+
with _timed_fetch_gate(gate_key, max_concurrent_fetches):
|
|
221
225
|
engine = _get_cached_worker_sync_engine(config)
|
|
222
226
|
with engine.connect() as conn:
|
|
223
227
|
if not use_arrow:
|
|
@@ -253,8 +257,7 @@ class SqlPartitionExecutor:
|
|
|
253
257
|
NullPool means the engine carries no loop-bound connection pool state, so
|
|
254
258
|
both the loop and the engine are safe to reuse across calls in the same thread.
|
|
255
259
|
"""
|
|
256
|
-
|
|
257
|
-
with gate:
|
|
260
|
+
with _timed_fetch_gate(gate_key, max_concurrent_fetches):
|
|
258
261
|
|
|
259
262
|
async def _fetch() -> pd.DataFrame:
|
|
260
263
|
engine = _get_cached_worker_async_engine(config)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Partition-fetch concurrency gate and its contention accounting.
|
|
2
|
+
|
|
3
|
+
Split out of partitioned_execution.py purely for long-file headroom. Every name
|
|
4
|
+
here is re-exported from partitioned_execution.py so existing import paths keep
|
|
5
|
+
working unchanged.
|
|
6
|
+
|
|
7
|
+
The gate bounds how many partition fetches run at once, and the counters record
|
|
8
|
+
how long fetches spent queueing for a slot. That second half exists because the
|
|
9
|
+
gate is otherwise invisible: the ``diagnostics=True`` stage timers (``db_fetch``,
|
|
10
|
+
``prepare_stmt``, ``load_plan``) are all measured *inside* the gate, so under
|
|
11
|
+
saturation total elapsed time rises while every stage timer still looks healthy.
|
|
12
|
+
An idle database plus timing-out callers is the classic symptom, and without
|
|
13
|
+
these counters it is indistinguishable from a database that is simply slow.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import threading
|
|
19
|
+
from collections.abc import Iterator
|
|
20
|
+
from contextlib import contextmanager
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from time import perf_counter
|
|
23
|
+
|
|
24
|
+
_FETCH_GATES: dict[tuple[str, int], threading.BoundedSemaphore] = {}
|
|
25
|
+
_FETCH_GATES_LOCK = threading.Lock()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get_fetch_gate(gate_key: str, max_concurrent_fetches: int) -> threading.BoundedSemaphore:
|
|
29
|
+
cache_key = (gate_key, max_concurrent_fetches)
|
|
30
|
+
with _FETCH_GATES_LOCK:
|
|
31
|
+
gate = _FETCH_GATES.get(cache_key)
|
|
32
|
+
if gate is None:
|
|
33
|
+
gate = threading.BoundedSemaphore(max_concurrent_fetches)
|
|
34
|
+
_FETCH_GATES[cache_key] = gate
|
|
35
|
+
return gate
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class GateWaitStats:
|
|
40
|
+
"""Time spent waiting for a partition-fetch slot, accumulated per process.
|
|
41
|
+
|
|
42
|
+
``max_wait_seconds`` is cumulative for the life of the process and cannot be
|
|
43
|
+
differenced into a per-load figure the way the other three can (a running
|
|
44
|
+
maximum carries no record of when it was set). :meth:`since` therefore
|
|
45
|
+
carries it through unchanged; it reads as "worst wait seen in this process
|
|
46
|
+
so far", not "worst wait during this load".
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
fetches: int = 0
|
|
50
|
+
waited_fetches: int = 0
|
|
51
|
+
total_wait_seconds: float = 0.0
|
|
52
|
+
max_wait_seconds: float = 0.0
|
|
53
|
+
|
|
54
|
+
def since(self, baseline: GateWaitStats | None) -> GateWaitStats:
|
|
55
|
+
"""Return the stats accumulated since ``baseline`` was captured."""
|
|
56
|
+
if baseline is None:
|
|
57
|
+
return self
|
|
58
|
+
return GateWaitStats(
|
|
59
|
+
fetches=self.fetches - baseline.fetches,
|
|
60
|
+
waited_fetches=self.waited_fetches - baseline.waited_fetches,
|
|
61
|
+
total_wait_seconds=self.total_wait_seconds - baseline.total_wait_seconds,
|
|
62
|
+
max_wait_seconds=self.max_wait_seconds,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# An uncontended BoundedSemaphore.acquire() returns in ~1us; anything at or above
|
|
67
|
+
# a millisecond means the fetch genuinely queued behind another one.
|
|
68
|
+
_GATE_WAIT_EPSILON_SECONDS = 0.001
|
|
69
|
+
|
|
70
|
+
_GATE_WAIT_STATS: dict[str, GateWaitStats] = {}
|
|
71
|
+
_GATE_WAIT_LOCK = threading.Lock()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _record_gate_wait(gate_key: str, waited_seconds: float) -> None:
|
|
75
|
+
with _GATE_WAIT_LOCK:
|
|
76
|
+
current = _GATE_WAIT_STATS.get(gate_key, GateWaitStats())
|
|
77
|
+
_GATE_WAIT_STATS[gate_key] = GateWaitStats(
|
|
78
|
+
fetches=current.fetches + 1,
|
|
79
|
+
waited_fetches=current.waited_fetches
|
|
80
|
+
+ (1 if waited_seconds >= _GATE_WAIT_EPSILON_SECONDS else 0),
|
|
81
|
+
total_wait_seconds=current.total_wait_seconds + waited_seconds,
|
|
82
|
+
max_wait_seconds=max(current.max_wait_seconds, waited_seconds),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def fetch_gate_stats() -> dict[str, GateWaitStats]:
|
|
87
|
+
"""Snapshot partition-fetch gate contention for **this process**, by gate key.
|
|
88
|
+
|
|
89
|
+
Scope matters for interpreting the numbers. Partition fetches are dispatched
|
|
90
|
+
as Dask tasks, so in a distributed run each worker process accumulates its
|
|
91
|
+
own counters and this function only ever sees the calling process's share.
|
|
92
|
+
A lazy load returns before any fetch has run at all, so call this after
|
|
93
|
+
``.compute()``, not after the load call returns.
|
|
94
|
+
"""
|
|
95
|
+
with _GATE_WAIT_LOCK:
|
|
96
|
+
return dict(_GATE_WAIT_STATS)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def reset_fetch_gate_stats() -> None:
|
|
100
|
+
"""Clear accumulated gate-wait counters for this process."""
|
|
101
|
+
with _GATE_WAIT_LOCK:
|
|
102
|
+
_GATE_WAIT_STATS.clear()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def format_gate_wait_suffix(gate_key: str, baseline: GateWaitStats | None) -> str:
|
|
106
|
+
"""Format gate contention for a diagnostics line, or ``""`` if nothing ran here.
|
|
107
|
+
|
|
108
|
+
Shared by the sync loader and the async gateway so both diagnostics lines
|
|
109
|
+
report contention identically.
|
|
110
|
+
|
|
111
|
+
Returning empty is deliberate rather than reporting zeros. A lazy load
|
|
112
|
+
returns a Dask graph before any partition has been fetched, and in a
|
|
113
|
+
distributed run the fetches then happen inside other worker processes
|
|
114
|
+
entirely — ``gate_wait_total=0.000s`` there would read as "no contention"
|
|
115
|
+
when the truth is "not measured here". Callers wanting figures for a lazy
|
|
116
|
+
load should read :func:`fetch_gate_stats` after ``.compute()``.
|
|
117
|
+
"""
|
|
118
|
+
delta = fetch_gate_stats().get(gate_key, GateWaitStats()).since(baseline)
|
|
119
|
+
if delta.fetches <= 0:
|
|
120
|
+
return ""
|
|
121
|
+
return (
|
|
122
|
+
f" gate_waits={delta.waited_fetches}/{delta.fetches}"
|
|
123
|
+
f" gate_wait_total={delta.total_wait_seconds:.3f}s"
|
|
124
|
+
f" gate_wait_max_seen={delta.max_wait_seconds:.3f}s"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@contextmanager
|
|
129
|
+
def _timed_fetch_gate(gate_key: str, max_concurrent_fetches: int) -> Iterator[None]:
|
|
130
|
+
"""Hold a fetch slot, recording how long it took to get one."""
|
|
131
|
+
gate = _get_fetch_gate(gate_key, max_concurrent_fetches)
|
|
132
|
+
started = perf_counter()
|
|
133
|
+
gate.acquire()
|
|
134
|
+
_record_gate_wait(gate_key, perf_counter() - started)
|
|
135
|
+
try:
|
|
136
|
+
yield
|
|
137
|
+
finally:
|
|
138
|
+
gate.release()
|
|
@@ -11,7 +11,11 @@ from boti.core.lifecycle_pickle import PicklableLifecycleCoreMixin
|
|
|
11
11
|
from boti.core.logger import Logger
|
|
12
12
|
from sqlalchemy.engine import url as sqlalchemy_url
|
|
13
13
|
|
|
14
|
-
from boti_data.db.partitioned_execution import
|
|
14
|
+
from boti_data.db.partitioned_execution import (
|
|
15
|
+
SqlPartitionExecutor,
|
|
16
|
+
fetch_gate_stats,
|
|
17
|
+
format_gate_wait_suffix,
|
|
18
|
+
)
|
|
15
19
|
from boti_data.db.partitioned_planner import SqlPartitionPlanner
|
|
16
20
|
from boti_data.db.partitioned_types import (
|
|
17
21
|
SqlPartitionedLoadRequest,
|
|
@@ -200,6 +204,7 @@ class SqlPartitionedLoader(PicklableLifecycleCoreMixin, LifecycleCore):
|
|
|
200
204
|
t_plan_fetch = perf_counter()
|
|
201
205
|
prepared_stmt = self._planner.prepare_statement(request)
|
|
202
206
|
t_load = perf_counter()
|
|
207
|
+
gate_baseline = fetch_gate_stats().get(self._gate_key) if request.diagnostics else None
|
|
203
208
|
result = executor.load_plan(
|
|
204
209
|
plan,
|
|
205
210
|
as_pandas=request.as_pandas,
|
|
@@ -217,6 +222,7 @@ class SqlPartitionedLoader(PicklableLifecycleCoreMixin, LifecycleCore):
|
|
|
217
222
|
f"elapsed={t_end - started:.2f}s "
|
|
218
223
|
f"prepare_stmt={t_load - t_plan_fetch:.3f}s "
|
|
219
224
|
f"load_plan={t_end - t_load:.3f}s"
|
|
225
|
+
f"{format_gate_wait_suffix(self._gate_key, gate_baseline)}"
|
|
220
226
|
)
|
|
221
227
|
return result
|
|
222
228
|
|
|
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
from typing import Any, Protocol, runtime_checkable
|
|
12
12
|
|
|
13
|
+
import dask.dataframe as dd
|
|
13
14
|
import pandas as pd
|
|
14
15
|
|
|
15
16
|
|
|
@@ -17,13 +18,15 @@ import pandas as pd
|
|
|
17
18
|
class DataFrameTransformer(Protocol):
|
|
18
19
|
"""Generic DataFrame transform contract.
|
|
19
20
|
|
|
20
|
-
Implementations receive a ``pd.DataFrame``
|
|
21
|
-
arguments (used by higher layers to pass context)
|
|
22
|
-
a
|
|
21
|
+
Implementations receive a ``pd.DataFrame`` or ``dd.DataFrame`` and
|
|
22
|
+
arbitrary keyword arguments (used by higher layers to pass context)
|
|
23
|
+
and must return a frame of the same kind they were given.
|
|
23
24
|
|
|
24
25
|
This protocol is intentionally minimal — ``**kwargs`` keeps it
|
|
25
26
|
flexible so that etl-core can pass a ``TransformContext`` without
|
|
26
27
|
boti-data knowing about it.
|
|
27
28
|
"""
|
|
28
29
|
|
|
29
|
-
async def transform(
|
|
30
|
+
async def transform(
|
|
31
|
+
self, df: pd.DataFrame | dd.DataFrame, **kwargs: Any
|
|
32
|
+
) -> pd.DataFrame | dd.DataFrame: ...
|
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
from typing import Any
|
|
11
11
|
|
|
12
|
+
import dask.dataframe as dd
|
|
12
13
|
import pandas as pd
|
|
13
14
|
from boti.core.security import has_dunder_identifier
|
|
14
15
|
|
|
@@ -46,16 +47,30 @@ class TypeCaster:
|
|
|
46
47
|
``"int64"``, ``"float64"``, ``"bool"``, ``"string"``, etc.
|
|
47
48
|
|
|
48
49
|
Missing columns are silently skipped.
|
|
50
|
+
|
|
51
|
+
Dask frames
|
|
52
|
+
-----------
|
|
53
|
+
``dd.DataFrame.astype()`` has no ``errors=`` kwarg, and a dask
|
|
54
|
+
column's dtype must be uniform across every partition — so there is
|
|
55
|
+
no dask equivalent of pandas' per-column "cast what you can, leave
|
|
56
|
+
the rest" behaviour. Against a ``dd.DataFrame``, a cast that would
|
|
57
|
+
have been silently skipped under ``errors="ignore"`` instead raises
|
|
58
|
+
immediately. Only list columns you're confident are castable when
|
|
59
|
+
the input is a dask frame.
|
|
49
60
|
"""
|
|
50
61
|
|
|
51
62
|
def __init__(self, casts: dict[str, str]) -> None:
|
|
52
63
|
self._casts = dict(casts)
|
|
53
64
|
|
|
54
|
-
async def transform(
|
|
65
|
+
async def transform(
|
|
66
|
+
self, df: pd.DataFrame | dd.DataFrame, **kwargs: Any
|
|
67
|
+
) -> pd.DataFrame | dd.DataFrame:
|
|
55
68
|
applicable = {col: dt for col, dt in self._casts.items() if col in df.columns}
|
|
56
|
-
if applicable:
|
|
57
|
-
|
|
58
|
-
|
|
69
|
+
if not applicable:
|
|
70
|
+
return df
|
|
71
|
+
if isinstance(df, dd.DataFrame):
|
|
72
|
+
return df.astype(applicable)
|
|
73
|
+
return df.astype(applicable, errors="ignore")
|
|
59
74
|
|
|
60
75
|
|
|
61
76
|
class RowFilter:
|
|
@@ -79,7 +94,9 @@ class RowFilter:
|
|
|
79
94
|
def __init__(self, predicates: list[str]) -> None:
|
|
80
95
|
self._predicates = list(predicates)
|
|
81
96
|
|
|
82
|
-
async def transform(
|
|
97
|
+
async def transform(
|
|
98
|
+
self, df: pd.DataFrame | dd.DataFrame, **kwargs: Any
|
|
99
|
+
) -> pd.DataFrame | dd.DataFrame:
|
|
83
100
|
for expr in self._predicates:
|
|
84
101
|
_reject_dunder_expr(expr)
|
|
85
102
|
mask = df.eval(expr) # nosec CWE-94 -- dunder-chain rejected above; see class docstring's Security section
|
|
@@ -113,7 +130,9 @@ class DerivedColumn:
|
|
|
113
130
|
def __init__(self, columns: dict[str, str]) -> None:
|
|
114
131
|
self._columns = dict(columns)
|
|
115
132
|
|
|
116
|
-
async def transform(
|
|
133
|
+
async def transform(
|
|
134
|
+
self, df: pd.DataFrame | dd.DataFrame, **kwargs: Any
|
|
135
|
+
) -> pd.DataFrame | dd.DataFrame:
|
|
117
136
|
for name, expr in self._columns.items():
|
|
118
137
|
_reject_dunder_expr(expr)
|
|
119
138
|
df[name] = df.eval(expr) # nosec CWE-94 -- dunder-chain rejected above; see class docstring's Security section
|
|
@@ -141,7 +160,9 @@ class Deduplicator:
|
|
|
141
160
|
self._subset = subset
|
|
142
161
|
self._keep = keep
|
|
143
162
|
|
|
144
|
-
async def transform(
|
|
163
|
+
async def transform(
|
|
164
|
+
self, df: pd.DataFrame | dd.DataFrame, **kwargs: Any
|
|
165
|
+
) -> pd.DataFrame | dd.DataFrame:
|
|
145
166
|
before = len(df)
|
|
146
167
|
df = df.drop_duplicates(subset=self._subset, keep=self._keep).reset_index(
|
|
147
168
|
drop=True,
|