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.
Files changed (121) hide show
  1. {boti_data-1.3.7 → boti_data-1.3.9}/PKG-INFO +124 -1
  2. {boti_data-1.3.7 → boti_data-1.3.9}/README.md +123 -0
  3. {boti_data-1.3.7 → boti_data-1.3.9}/pyproject.toml +1 -1
  4. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/__init__.py +6 -0
  5. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/partitioned_execution.py +19 -16
  6. boti_data-1.3.9/src/boti_data/db/partitioned_fetch_gate.py +138 -0
  7. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/partitioned_loader.py +7 -1
  8. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/enrichment/protocol.py +7 -4
  9. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/enrichment/transformers.py +28 -7
  10. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/core.py +66 -19
  11. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/sql_partitioned_exec.py +11 -2
  12. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/parquet/schema_filters.py +81 -19
  13. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/pipelines/sinks_common.py +62 -0
  14. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/pipelines/sinks_parquet.py +1 -0
  15. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data.egg-info/PKG-INFO +124 -1
  16. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data.egg-info/SOURCES.txt +1 -0
  17. {boti_data-1.3.7 → boti_data-1.3.9}/setup.cfg +0 -0
  18. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/arrow_schema.py +0 -0
  19. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/connection_catalog.py +0 -0
  20. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/datacube/__init__.py +0 -0
  21. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/datacube/artifact.py +0 -0
  22. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/datacube/base.py +0 -0
  23. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/datacube/contract.py +0 -0
  24. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/datacube/resource.py +0 -0
  25. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/dataset/__init__.py +0 -0
  26. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/dataset/hybrid.py +0 -0
  27. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/dataset/hybrid_support.py +0 -0
  28. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/__init__.py +0 -0
  29. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/arrow_schema_mapper.py +0 -0
  30. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/engine_registry.py +0 -0
  31. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/partitioned_planner.py +0 -0
  32. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/partitioned_planner_async.py +0 -0
  33. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/partitioned_planning_math.py +0 -0
  34. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/partitioned_row_materialization.py +0 -0
  35. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/partitioned_statements.py +0 -0
  36. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/partitioned_types.py +0 -0
  37. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/sql_config.py +0 -0
  38. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/sql_engine.py +0 -0
  39. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/sql_manager.py +0 -0
  40. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/sql_model_builder.py +0 -0
  41. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/sql_model_module_injection.py +0 -0
  42. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/sql_model_registry.py +0 -0
  43. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/sql_model_registry_types.py +0 -0
  44. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/sql_readonly.py +0 -0
  45. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/sql_repository.py +0 -0
  46. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/sql_resource.py +0 -0
  47. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/sqlalchemy_async.py +0 -0
  48. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/db/vector_search.py +0 -0
  49. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/enrichment/__init__.py +0 -0
  50. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/enrichment/async_enricher.py +0 -0
  51. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/enrichment/composite.py +0 -0
  52. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/enrichment/specs.py +0 -0
  53. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/field_map.py +0 -0
  54. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/filters/__init__.py +0 -0
  55. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/filters/arrow_kernels.py +0 -0
  56. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/filters/arrow_kernels_comparison.py +0 -0
  57. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/filters/arrow_kernels_compile.py +0 -0
  58. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/filters/arrow_kernels_string.py +0 -0
  59. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/filters/backend_ops.py +0 -0
  60. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/filters/expressions.py +0 -0
  61. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/filters/handler.py +0 -0
  62. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/filters/parquet_compiler.py +0 -0
  63. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/filters/utils.py +0 -0
  64. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/filters/value_parsing.py +0 -0
  65. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/__init__.py +0 -0
  66. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/_backend_strategies.py +0 -0
  67. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/_factory.py +0 -0
  68. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/_gateway_init.py +0 -0
  69. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/_payloads.py +0 -0
  70. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/_series_filters.py +0 -0
  71. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/arrow_adapters.py +0 -0
  72. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/build_context.py +0 -0
  73. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/chunking.py +0 -0
  74. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/configured_load.py +0 -0
  75. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/core_describe.py +0 -0
  76. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/core_load.py +0 -0
  77. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/datacube_strategy.py +0 -0
  78. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/frame_convert.py +0 -0
  79. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/frame_strategies.py +0 -0
  80. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/incremental.py +0 -0
  81. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/load_execution.py +0 -0
  82. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/load_request.py +0 -0
  83. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/loaders.py +0 -0
  84. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/normalization.py +0 -0
  85. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/parquet_strategy.py +0 -0
  86. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/planning.py +0 -0
  87. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/policies.py +0 -0
  88. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/post_process.py +0 -0
  89. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/requests.py +0 -0
  90. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/return_type.py +0 -0
  91. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/select_cache.py +0 -0
  92. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/semi_join.py +0 -0
  93. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/sql_describe.py +0 -0
  94. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/sql_guard.py +0 -0
  95. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/sql_size_estimation.py +0 -0
  96. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/sql_strategy.py +0 -0
  97. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/gateway/sql_strategy_configured_async.py +0 -0
  98. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/helper.py +0 -0
  99. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/joins.py +0 -0
  100. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/parquet/__init__.py +0 -0
  101. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/parquet/config.py +0 -0
  102. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/parquet/discovery.py +0 -0
  103. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/parquet/file_info.py +0 -0
  104. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/parquet/filesystem.py +0 -0
  105. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/parquet/reader.py +0 -0
  106. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/parquet/reader_config.py +0 -0
  107. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/parquet/resource.py +0 -0
  108. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/pipelines/__init__.py +0 -0
  109. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/pipelines/base.py +0 -0
  110. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/pipelines/parquet_pipeline.py +0 -0
  111. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/pipelines/registry.py +0 -0
  112. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/pipelines/sinks.py +0 -0
  113. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/pipelines/sinks_text.py +0 -0
  114. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/py.typed +0 -0
  115. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/schema.py +0 -0
  116. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/watermark/__init__.py +0 -0
  117. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/watermark/incremental.py +0 -0
  118. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data/watermark/store.py +0 -0
  119. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data.egg-info/dependency_links.txt +0 -0
  120. {boti_data-1.3.7 → boti_data-1.3.9}/src/boti_data.egg-info/requires.txt +0 -0
  121. {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.7
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:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "boti-data"
3
- version = "1.3.7"
3
+ version = "1.3.9"
4
4
  description = "Data infrastructure for the Boti ecosystem"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -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
- _FETCH_GATES: dict[tuple[str, int], threading.BoundedSemaphore] = {}
31
- _FETCH_GATES_LOCK = threading.Lock()
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
- gate = _get_fetch_gate(gate_key, max_concurrent_fetches)
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
- gate = _get_fetch_gate(gate_key, max_concurrent_fetches)
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 SqlPartitionExecutor
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`` and arbitrary keyword
21
- arguments (used by higher layers to pass context) and must return
22
- a ``pd.DataFrame``.
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(self, df: pd.DataFrame, **kwargs: Any) -> pd.DataFrame: ...
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(self, df: pd.DataFrame, **kwargs: Any) -> pd.DataFrame:
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
- df = df.astype(applicable, errors="ignore")
58
- return df
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(self, df: pd.DataFrame, **kwargs: Any) -> pd.DataFrame:
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(self, df: pd.DataFrame, **kwargs: Any) -> pd.DataFrame:
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(self, df: pd.DataFrame, **kwargs: Any) -> pd.DataFrame:
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,