boti-data 1.3.8__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.8 → boti_data-1.3.9}/PKG-INFO +124 -1
- {boti_data-1.3.8 → boti_data-1.3.9}/README.md +123 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/pyproject.toml +1 -1
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/__init__.py +6 -0
- {boti_data-1.3.8 → 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.8 → boti_data-1.3.9}/src/boti_data/db/partitioned_loader.py +7 -1
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/core.py +66 -19
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/sql_partitioned_exec.py +11 -2
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data.egg-info/PKG-INFO +124 -1
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data.egg-info/SOURCES.txt +1 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/setup.cfg +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/arrow_schema.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/connection_catalog.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/datacube/__init__.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/datacube/artifact.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/datacube/base.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/datacube/contract.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/datacube/resource.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/dataset/__init__.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/dataset/hybrid.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/dataset/hybrid_support.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/db/__init__.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/db/arrow_schema_mapper.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/db/engine_registry.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/db/partitioned_planner.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/db/partitioned_planner_async.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/db/partitioned_planning_math.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/db/partitioned_row_materialization.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/db/partitioned_statements.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/db/partitioned_types.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/db/sql_config.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/db/sql_engine.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/db/sql_manager.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/db/sql_model_builder.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/db/sql_model_module_injection.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/db/sql_model_registry.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/db/sql_model_registry_types.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/db/sql_readonly.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/db/sql_repository.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/db/sql_resource.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/db/sqlalchemy_async.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/db/vector_search.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/enrichment/__init__.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/enrichment/async_enricher.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/enrichment/composite.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/enrichment/protocol.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/enrichment/specs.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/enrichment/transformers.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/field_map.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/filters/__init__.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/filters/arrow_kernels.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/filters/arrow_kernels_comparison.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/filters/arrow_kernels_compile.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/filters/arrow_kernels_string.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/filters/backend_ops.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/filters/expressions.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/filters/handler.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/filters/parquet_compiler.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/filters/utils.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/filters/value_parsing.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/__init__.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/_backend_strategies.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/_factory.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/_gateway_init.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/_payloads.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/_series_filters.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/arrow_adapters.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/build_context.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/chunking.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/configured_load.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/core_describe.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/core_load.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/datacube_strategy.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/frame_convert.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/frame_strategies.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/incremental.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/load_execution.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/load_request.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/loaders.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/normalization.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/parquet_strategy.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/planning.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/policies.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/post_process.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/requests.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/return_type.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/select_cache.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/semi_join.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/sql_describe.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/sql_guard.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/sql_size_estimation.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/sql_strategy.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/gateway/sql_strategy_configured_async.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/helper.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/joins.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/parquet/__init__.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/parquet/config.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/parquet/discovery.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/parquet/file_info.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/parquet/filesystem.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/parquet/reader.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/parquet/reader_config.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/parquet/resource.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/parquet/schema_filters.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/pipelines/__init__.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/pipelines/base.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/pipelines/parquet_pipeline.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/pipelines/registry.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/pipelines/sinks.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/pipelines/sinks_common.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/pipelines/sinks_parquet.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/pipelines/sinks_text.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/py.typed +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/schema.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/watermark/__init__.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/watermark/incremental.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data/watermark/store.py +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data.egg-info/dependency_links.txt +0 -0
- {boti_data-1.3.8 → boti_data-1.3.9}/src/boti_data.egg-info/requires.txt +0 -0
- {boti_data-1.3.8 → 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
|
|
|
@@ -35,6 +35,25 @@ from .requests import (
|
|
|
35
35
|
)
|
|
36
36
|
|
|
37
37
|
|
|
38
|
+
def _log_partial_close_failure(resource: Any, exc: Exception) -> None:
|
|
39
|
+
"""Report a close() that failed while unwinding a failed ``__init__``.
|
|
40
|
+
|
|
41
|
+
Reported rather than swallowed: this path only runs when a configuration
|
|
42
|
+
error is already propagating, and a close() that also fails means a real
|
|
43
|
+
handle stayed open. Silently discarding that leaves no trace of the leak.
|
|
44
|
+
The original configuration error still reaches the caller unchanged — this
|
|
45
|
+
only records the secondary failure.
|
|
46
|
+
"""
|
|
47
|
+
logger = getattr(resource, "logger", None)
|
|
48
|
+
warn = getattr(logger, "warning", None)
|
|
49
|
+
if not callable(warn):
|
|
50
|
+
return
|
|
51
|
+
warn( # nosec CWE-117 -- type name and exception repr only, no user-supplied string
|
|
52
|
+
f"Failed to close {type(resource).__name__} while unwinding a failed "
|
|
53
|
+
f"DataGateway initialisation; the resource may have leaked: {exc!r}"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
38
57
|
class DataGateway(PicklableLifecycleCoreMixin, LifecycleCore):
|
|
39
58
|
"""Dask-first gateway that delegates to existing backend resources.
|
|
40
59
|
|
|
@@ -91,25 +110,35 @@ class DataGateway(PicklableLifecycleCoreMixin, LifecycleCore):
|
|
|
91
110
|
allowed_filter_fields: set[str] | None = None,
|
|
92
111
|
require_datacube_request_validator: bool = False,
|
|
93
112
|
) -> None:
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
+
try:
|
|
114
|
+
build_gateway_state(
|
|
115
|
+
self,
|
|
116
|
+
config,
|
|
117
|
+
GatewayInitOptions(
|
|
118
|
+
field_map=field_map,
|
|
119
|
+
table=table,
|
|
120
|
+
sticky_filters=sticky_filters,
|
|
121
|
+
exclude=exclude,
|
|
122
|
+
df_params=df_params,
|
|
123
|
+
df_options=df_options,
|
|
124
|
+
fs=fs,
|
|
125
|
+
fs_factory=fs_factory,
|
|
126
|
+
raw_sql_policy=raw_sql_policy,
|
|
127
|
+
policies=policies,
|
|
128
|
+
strict_filter_validation=strict_filter_validation,
|
|
129
|
+
allowed_filter_fields=allowed_filter_fields,
|
|
130
|
+
require_datacube_request_validator=require_datacube_request_validator,
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
except BaseException:
|
|
134
|
+
# build_gateway_state() constructs the backend resource before it
|
|
135
|
+
# runs the validations that can reject the configuration (e.g.
|
|
136
|
+
# require_datacube_request_validator). A failure after that point
|
|
137
|
+
# leaves a fully-constructed resource owned by a gateway whose
|
|
138
|
+
# LifecycleCore.__init__ never ran, so nothing will ever close it —
|
|
139
|
+
# it survives until GC warns that it leaked. Close it here instead.
|
|
140
|
+
self._close_partial_state()
|
|
141
|
+
raise
|
|
113
142
|
# self.resource (set above) is what logger reads through to, so
|
|
114
143
|
# LifecycleCore's GC finalizer captures a real logger where available
|
|
115
144
|
# instead of defaulting to None.
|
|
@@ -208,6 +237,24 @@ class DataGateway(PicklableLifecycleCoreMixin, LifecycleCore):
|
|
|
208
237
|
await self._strategy.setup_async_context(self)
|
|
209
238
|
return self
|
|
210
239
|
|
|
240
|
+
def _close_partial_state(self) -> None:
|
|
241
|
+
"""Close whatever a failed ``__init__`` had already constructed.
|
|
242
|
+
|
|
243
|
+
Deliberately defensive: this runs while an exception is propagating out
|
|
244
|
+
of a half-built object, so attributes may be missing entirely and the
|
|
245
|
+
original error must reach the caller unchanged. Cleanup failures are
|
|
246
|
+
suppressed for that reason — losing a close is bad, but masking the
|
|
247
|
+
configuration error that caused it is worse.
|
|
248
|
+
"""
|
|
249
|
+
for attr in ("resource", "_async_sql_resource"):
|
|
250
|
+
candidate = getattr(self, attr, None)
|
|
251
|
+
close = getattr(candidate, "close", None)
|
|
252
|
+
if callable(close):
|
|
253
|
+
try:
|
|
254
|
+
close()
|
|
255
|
+
except Exception as exc:
|
|
256
|
+
_log_partial_close_failure(candidate, exc)
|
|
257
|
+
|
|
211
258
|
def _cleanup(self) -> None:
|
|
212
259
|
if self.resource is not None:
|
|
213
260
|
self.resource.close()
|