duckstring 0.2.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. duckstring-0.4.0/MANIFEST.in +8 -0
  2. {duckstring-0.2.0 → duckstring-0.4.0}/PKG-INFO +6 -5
  3. {duckstring-0.2.0 → duckstring-0.4.0}/README.md +1 -4
  4. {duckstring-0.2.0 → duckstring-0.4.0}/pyproject.toml +14 -1
  5. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/__init__.py +0 -2
  6. duckstring-0.4.0/src/duckstring/acc.py +13 -0
  7. duckstring-0.4.0/src/duckstring/agg.py +12 -0
  8. duckstring-0.4.0/src/duckstring/alerts/__init__.py +21 -0
  9. duckstring-0.4.0/src/duckstring/alerts/base.py +94 -0
  10. duckstring-0.4.0/src/duckstring/alerts/email.py +106 -0
  11. duckstring-0.4.0/src/duckstring/alerts/event.py +97 -0
  12. duckstring-0.4.0/src/duckstring/alerts/webhook.py +46 -0
  13. duckstring-0.4.0/src/duckstring/catchment/alert_worker.py +58 -0
  14. duckstring-0.4.0/src/duckstring/catchment/app.py +199 -0
  15. duckstring-0.4.0/src/duckstring/catchment/asgi.py +39 -0
  16. duckstring-0.4.0/src/duckstring/catchment/auth.py +203 -0
  17. duckstring-0.4.0/src/duckstring/catchment/data_lease.py +159 -0
  18. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/db.py +18 -0
  19. duckstring-0.4.0/src/duckstring/catchment/driver.py +2538 -0
  20. duckstring-0.4.0/src/duckstring/catchment/egress_worker.py +102 -0
  21. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/launcher.py +13 -4
  22. duckstring-0.4.0/src/duckstring/catchment/poller.py +249 -0
  23. duckstring-0.4.0/src/duckstring/catchment/registry.py +40 -0
  24. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/routes/__init__.py +10 -0
  25. duckstring-0.4.0/src/duckstring/catchment/routes/alerts.py +75 -0
  26. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/routes/catchment.py +44 -4
  27. duckstring-0.4.0/src/duckstring/catchment/routes/data.py +614 -0
  28. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/routes/deploy.py +104 -15
  29. duckstring-0.4.0/src/duckstring/catchment/routes/draw.py +144 -0
  30. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/routes/duck.py +3 -2
  31. duckstring-0.4.0/src/duckstring/catchment/routes/duct.py +100 -0
  32. duckstring-0.4.0/src/duckstring/catchment/routes/metrics.py +99 -0
  33. duckstring-0.4.0/src/duckstring/catchment/routes/orchestrate.py +497 -0
  34. duckstring-0.4.0/src/duckstring/catchment/routes/secrets.py +43 -0
  35. duckstring-0.4.0/src/duckstring/catchment/routes/view.py +99 -0
  36. duckstring-0.4.0/src/duckstring/catchment/schema/002_ducts.sql +41 -0
  37. duckstring-0.4.0/src/duckstring/catchment/schema/003_pull_m.sql +5 -0
  38. duckstring-0.4.0/src/duckstring/catchment/schema/004_identity.sql +15 -0
  39. duckstring-0.4.0/src/duckstring/catchment/schema/005_pond_version_schema.sql +12 -0
  40. duckstring-0.4.0/src/duckstring/catchment/schema/006_refresh.sql +7 -0
  41. duckstring-0.4.0/src/duckstring/catchment/schema/007_catchment_key.sql +9 -0
  42. duckstring-0.4.0/src/duckstring/catchment/schema/008_spout.sql +18 -0
  43. duckstring-0.4.0/src/duckstring/catchment/schema/009_spout_state.sql +10 -0
  44. duckstring-0.4.0/src/duckstring/catchment/schema/010_spout_wake.sql +6 -0
  45. duckstring-0.4.0/src/duckstring/catchment/schema/011_spout_window.sql +17 -0
  46. duckstring-0.4.0/src/duckstring/catchment/schema/012_spout_node.sql +20 -0
  47. duckstring-0.4.0/src/duckstring/catchment/schema/013_changed_f.sql +14 -0
  48. duckstring-0.4.0/src/duckstring/catchment/schema/014_alert.sql +38 -0
  49. duckstring-0.4.0/src/duckstring/catchment/schema/016_alert_scope_major.sql +15 -0
  50. duckstring-0.4.0/src/duckstring/catchment/secrets.py +72 -0
  51. duckstring-0.4.0/src/duckstring/catchment/state_sync.py +182 -0
  52. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/404.html +1 -1
  53. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/__next.__PAGE__.txt +2 -2
  54. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/__next._full.txt +3 -3
  55. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/__next._head.txt +1 -1
  56. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/__next._index.txt +2 -2
  57. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/__next._tree.txt +2 -2
  58. duckstring-0.4.0/src/duckstring/catchment/static/_next/static/chunks/0p30fzde51na5.css +1 -0
  59. duckstring-0.4.0/src/duckstring/catchment/static/_next/static/chunks/0qdnkaffkg.2-.js +2 -0
  60. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_not-found/__next._full.txt +2 -2
  61. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_not-found/__next._head.txt +1 -1
  62. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_not-found/__next._index.txt +2 -2
  63. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_not-found/__next._not-found.__PAGE__.txt +1 -1
  64. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_not-found/__next._not-found.txt +1 -1
  65. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_not-found/__next._tree.txt +2 -2
  66. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_not-found.html +1 -1
  67. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_not-found.txt +2 -2
  68. duckstring-0.4.0/src/duckstring/catchment/static/index.html +1 -0
  69. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/index.txt +3 -3
  70. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/cli/__init__.py +24 -2
  71. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/cli/_http.py +4 -0
  72. duckstring-0.4.0/src/duckstring/cli/alert.py +146 -0
  73. duckstring-0.4.0/src/duckstring/cli/bulk.py +131 -0
  74. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/cli/catchment.py +251 -41
  75. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/cli/config.py +19 -1
  76. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/cli/control.py +61 -0
  77. duckstring-0.4.0/src/duckstring/cli/data.py +266 -0
  78. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/cli/deploy.py +41 -2
  79. duckstring-0.4.0/src/duckstring/cli/duct.py +190 -0
  80. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/cli/pond.py +33 -11
  81. duckstring-0.4.0/src/duckstring/cli/secret.py +72 -0
  82. duckstring-0.4.0/src/duckstring/cli/spout.py +142 -0
  83. duckstring-0.4.0/src/duckstring/core.py +678 -0
  84. duckstring-0.4.0/src/duckstring/dataplane.py +563 -0
  85. duckstring-0.4.0/src/duckstring/demo/catalog/README.md +12 -0
  86. duckstring-0.4.0/src/duckstring/demo/catalog/pond.toml +4 -0
  87. duckstring-0.4.0/src/duckstring/demo/catalog/src/pond.py +61 -0
  88. duckstring-0.4.0/src/duckstring/demo/orders/README.md +11 -0
  89. duckstring-0.4.0/src/duckstring/demo/orders/pond.toml +4 -0
  90. duckstring-0.4.0/src/duckstring/demo/orders/src/pond.py +52 -0
  91. duckstring-0.4.0/src/duckstring/demo/priced/README.md +20 -0
  92. duckstring-0.4.0/src/duckstring/demo/priced/pond.toml +7 -0
  93. duckstring-0.4.0/src/duckstring/demo/priced/src/pond.py +23 -0
  94. duckstring-0.4.0/src/duckstring/demo/priced/src/puddles.py +59 -0
  95. duckstring-0.4.0/src/duckstring/demo/reports/.gitignore +8 -0
  96. duckstring-0.4.0/src/duckstring/demo/revenue/.gitignore +8 -0
  97. duckstring-0.4.0/src/duckstring/demo/revenue/README.md +12 -0
  98. duckstring-0.4.0/src/duckstring/demo/revenue/pond.toml +7 -0
  99. duckstring-0.4.0/src/duckstring/demo/revenue/src/pond.py +25 -0
  100. duckstring-0.4.0/src/duckstring/demo/revenue/src/puddles.py +35 -0
  101. duckstring-0.4.0/src/duckstring/demo/sales/.gitignore +8 -0
  102. duckstring-0.4.0/src/duckstring/demo/transactions/.gitignore +8 -0
  103. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/duck/__main__.py +30 -6
  104. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/duck/core.py +88 -10
  105. duckstring-0.4.0/src/duckstring/duck/executor.py +208 -0
  106. duckstring-0.4.0/src/duckstring/egress/__init__.py +19 -0
  107. duckstring-0.4.0/src/duckstring/egress/base.py +96 -0
  108. duckstring-0.4.0/src/duckstring/egress/credentials.py +93 -0
  109. duckstring-0.4.0/src/duckstring/egress/destination.py +64 -0
  110. duckstring-0.4.0/src/duckstring/egress/object_store.py +167 -0
  111. duckstring-0.4.0/src/duckstring/egress/postgres.py +170 -0
  112. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/engine/__init__.py +10 -0
  113. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/engine/catchment.py +275 -35
  114. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/engine/core.py +40 -0
  115. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/engine/worker.py +6 -3
  116. duckstring-0.4.0/src/duckstring/iceberg_catalog.py +227 -0
  117. duckstring-0.4.0/src/duckstring/iceberg_plane.py +286 -0
  118. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/keys.py +6 -0
  119. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/local/runner.py +58 -13
  120. duckstring-0.4.0/src/duckstring/objects.py +225 -0
  121. duckstring-0.4.0/src/duckstring/schema_contract.py +67 -0
  122. duckstring-0.4.0/src/duckstring/storage.py +543 -0
  123. duckstring-0.4.0/src/duckstring/trickle/__init__.py +71 -0
  124. duckstring-0.4.0/src/duckstring/trickle/acc.py +139 -0
  125. duckstring-0.4.0/src/duckstring/trickle/agg.py +209 -0
  126. duckstring-0.4.0/src/duckstring/trickle/builder.py +1091 -0
  127. duckstring-0.4.0/src/duckstring/trickle/context.py +53 -0
  128. duckstring-0.4.0/src/duckstring/trickle/io.py +2376 -0
  129. duckstring-0.4.0/src/duckstring/trickle_builder.py +13 -0
  130. duckstring-0.4.0/src/duckstring/trickle_io.py +15 -0
  131. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring.egg-info/PKG-INFO +6 -5
  132. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring.egg-info/SOURCES.txt +83 -27
  133. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring.egg-info/requires.txt +5 -0
  134. duckstring-0.2.0/src/duckstring/catchment/app.py +0 -110
  135. duckstring-0.2.0/src/duckstring/catchment/asgi.py +0 -30
  136. duckstring-0.2.0/src/duckstring/catchment/driver.py +0 -836
  137. duckstring-0.2.0/src/duckstring/catchment/registry.py +0 -27
  138. duckstring-0.2.0/src/duckstring/catchment/routes/data.py +0 -143
  139. duckstring-0.2.0/src/duckstring/catchment/routes/orchestrate.py +0 -209
  140. duckstring-0.2.0/src/duckstring/catchment/static/_next/static/chunks/0pgf-_4l3d1gl.css +0 -1
  141. duckstring-0.2.0/src/duckstring/catchment/static/_next/static/chunks/0qh0m742pxib~.js +0 -2
  142. duckstring-0.2.0/src/duckstring/catchment/static/index.html +0 -1
  143. duckstring-0.2.0/src/duckstring/cli/data.py +0 -133
  144. duckstring-0.2.0/src/duckstring/core.py +0 -368
  145. duckstring-0.2.0/src/duckstring/duck/executor.py +0 -142
  146. duckstring-0.2.0/tests/test_auth.py +0 -258
  147. duckstring-0.2.0/tests/test_catchment.py +0 -289
  148. duckstring-0.2.0/tests/test_catchment_app.py +0 -674
  149. duckstring-0.2.0/tests/test_config.py +0 -70
  150. duckstring-0.2.0/tests/test_core.py +0 -94
  151. duckstring-0.2.0/tests/test_data.py +0 -148
  152. duckstring-0.2.0/tests/test_deploy.py +0 -146
  153. duckstring-0.2.0/tests/test_download.py +0 -77
  154. duckstring-0.2.0/tests/test_duck.py +0 -175
  155. duckstring-0.2.0/tests/test_engine.py +0 -591
  156. duckstring-0.2.0/tests/test_engine_split.py +0 -228
  157. duckstring-0.2.0/tests/test_execute.py +0 -149
  158. duckstring-0.2.0/tests/test_multimajor.py +0 -188
  159. duckstring-0.2.0/tests/test_platform.py +0 -155
  160. duckstring-0.2.0/tests/test_pond.py +0 -156
  161. duckstring-0.2.0/tests/test_puddle.py +0 -392
  162. duckstring-0.2.0/tests/test_restart.py +0 -98
  163. duckstring-0.2.0/tests/test_runtime.py +0 -233
  164. duckstring-0.2.0/tests/test_static.py +0 -23
  165. duckstring-0.2.0/tests/test_status.py +0 -198
  166. duckstring-0.2.0/tests/test_status_api.py +0 -233
  167. duckstring-0.2.0/tests/test_window.py +0 -193
  168. {duckstring-0.2.0 → duckstring-0.4.0}/LICENSE +0 -0
  169. {duckstring-0.2.0 → duckstring-0.4.0}/setup.cfg +0 -0
  170. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/__main__.py +0 -0
  171. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/__init__.py +0 -0
  172. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/dag.py +0 -0
  173. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/schema/001_init.sql +0 -0
  174. {duckstring-0.2.0/src/duckstring/catchment/static/_next/static/uFzbxKIUibAOY83mw7FJ7 → duckstring-0.4.0/src/duckstring/catchment/static/_next/static/K01gZbd5BTz315upsZeJJ}/_buildManifest.js +0 -0
  175. {duckstring-0.2.0/src/duckstring/catchment/static/_next/static/uFzbxKIUibAOY83mw7FJ7 → duckstring-0.4.0/src/duckstring/catchment/static/_next/static/K01gZbd5BTz315upsZeJJ}/_clientMiddlewareManifest.js +0 -0
  176. {duckstring-0.2.0/src/duckstring/catchment/static/_next/static/uFzbxKIUibAOY83mw7FJ7 → duckstring-0.4.0/src/duckstring/catchment/static/_next/static/K01gZbd5BTz315upsZeJJ}/_ssgManifest.js +0 -0
  177. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_next/static/chunks/03~yq9q893hmn.js +0 -0
  178. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_next/static/chunks/07lhk_q6pmm3r.js +0 -0
  179. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_next/static/chunks/0dbhjjzl8qfwv.js +0 -0
  180. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_next/static/chunks/0jvmviuftg5e2.css +0 -0
  181. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_next/static/chunks/0kczw6usu-y-f.js +0 -0
  182. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_next/static/chunks/11kjahy2ntf0n.js +0 -0
  183. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_next/static/chunks/turbopack-03~mbvk_uplk_.js +0 -0
  184. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/favicon.svg +0 -0
  185. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/logo-mark.svg +0 -0
  186. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/logo.svg +0 -0
  187. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/cli/puddle.py +0 -0
  188. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/cli/status.py +0 -0
  189. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/cli/trigger.py +0 -0
  190. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/cli/window.py +0 -0
  191. {duckstring-0.2.0/src/duckstring/demo/products → duckstring-0.4.0/src/duckstring/demo/catalog}/.gitignore +0 -0
  192. {duckstring-0.2.0/src/duckstring/demo/reports → duckstring-0.4.0/src/duckstring/demo/orders}/.gitignore +0 -0
  193. {duckstring-0.2.0/src/duckstring/demo/sales → duckstring-0.4.0/src/duckstring/demo/priced}/.gitignore +0 -0
  194. {duckstring-0.2.0/src/duckstring/demo/transactions → duckstring-0.4.0/src/duckstring/demo/products}/.gitignore +0 -0
  195. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/demo/products/README.md +0 -0
  196. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/demo/products/pond.toml +0 -0
  197. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/demo/products/src/pond.py +0 -0
  198. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/demo/reports/README.md +0 -0
  199. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/demo/reports/pond.toml +0 -0
  200. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/demo/reports/src/pond.py +0 -0
  201. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/demo/sales/README.md +0 -0
  202. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/demo/sales/pond.toml +0 -0
  203. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/demo/sales/src/pond.py +0 -0
  204. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/demo/sales/src/puddles.py +0 -0
  205. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/demo/transactions/README.md +0 -0
  206. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/demo/transactions/pond.toml +0 -0
  207. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/demo/transactions/src/pond.py +0 -0
  208. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/duck/__init__.py +0 -0
  209. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/duck/client.py +0 -0
  210. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/engine/pond.py +0 -0
  211. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/local/__init__.py +0 -0
  212. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/local/hydrate.py +0 -0
  213. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/local/project.py +0 -0
  214. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/utils.py +0 -0
  215. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring.egg-info/dependency_links.txt +0 -0
  216. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring.egg-info/entry_points.txt +0 -0
  217. {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring.egg-info/top_level.txt +0 -0
@@ -0,0 +1,8 @@
1
+ # The sdist ships the installable package only — keep internal design notes and the test suite
2
+ # out of the PyPI source tarball (they're in the git repo for contributors).
3
+ prune tests
4
+ prune plans
5
+ prune experiment
6
+ prune docs
7
+ prune frontend
8
+ prune playground
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: duckstring
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Build data pipelines the way you build software: version each transform, declare its dependencies, and Duckstring resolves the execution DAG automatically.
5
5
  Author-email: Duckstring <dev@duckstring.com>
6
6
  License: Apache License
@@ -221,6 +221,10 @@ Requires-Dist: uvicorn[standard]>=0.29
221
221
  Requires-Dist: httpx>=0.25
222
222
  Requires-Dist: tomli>=2.0; python_version < "3.11"
223
223
  Requires-Dist: tomli-w>=1.0
224
+ Requires-Dist: pyiceberg>=0.7
225
+ Requires-Dist: pyarrow>=14
226
+ Requires-Dist: pytz
227
+ Provides-Extra: iceberg
224
228
  Provides-Extra: dev
225
229
  Requires-Dist: pytest>=8.0; extra == "dev"
226
230
  Requires-Dist: pytest-timeout>=0.5; extra == "dev"
@@ -244,6 +248,7 @@ The main elements:
244
248
  - **Catchment**: Control environment (FastAPI + UI + CLI)
245
249
  - **Pond**: Versioned container with declared upstream dependencies
246
250
  - **Ripple**: Unit operation within a Pond (e.g. a single transformation producing a table)
251
+ - **Trickle**: DBSP-based incremental engine operating within a Ripple
247
252
 
248
253
  Ponds are typed or referred to in context:
249
254
 
@@ -292,10 +297,6 @@ Ponds execute on demand signals sent to an Outlet, in two flavours — **push**
292
297
 
293
298
  A Tide keeps an Outlet no staler than a bound (`duckstring trigger tide reports 1d`); a Wave keeps it as fresh as the pipeline can supply. See [Triggers](https://docs.duckstring.com/guides/triggers) for the full semantics.
294
299
 
295
- ## Scope & maturity
296
-
297
- Duckstring targets single-node workloads (DuckDB under the hood) — pipelines in the tens of millions of rows, not big-data scale. Incremental transforms (the **Trickle** concept) are not yet implemented: transforms recompute their tables each run, with incremental state via self-reads.
298
-
299
300
  ## Going further
300
301
 
301
302
  Full documentation lives at **[docs.duckstring.com](https://docs.duckstring.com)**:
@@ -14,6 +14,7 @@ The main elements:
14
14
  - **Catchment**: Control environment (FastAPI + UI + CLI)
15
15
  - **Pond**: Versioned container with declared upstream dependencies
16
16
  - **Ripple**: Unit operation within a Pond (e.g. a single transformation producing a table)
17
+ - **Trickle**: DBSP-based incremental engine operating within a Ripple
17
18
 
18
19
  Ponds are typed or referred to in context:
19
20
 
@@ -62,10 +63,6 @@ Ponds execute on demand signals sent to an Outlet, in two flavours — **push**
62
63
 
63
64
  A Tide keeps an Outlet no staler than a bound (`duckstring trigger tide reports 1d`); a Wave keeps it as fresh as the pipeline can supply. See [Triggers](https://docs.duckstring.com/guides/triggers) for the full semantics.
64
65
 
65
- ## Scope & maturity
66
-
67
- Duckstring targets single-node workloads (DuckDB under the hood) — pipelines in the tens of millions of rows, not big-data scale. Incremental transforms (the **Trickle** concept) are not yet implemented: transforms recompute their tables each run, with incremental state via self-reads.
68
-
69
66
  ## Going further
70
67
 
71
68
  Full documentation lives at **[docs.duckstring.com](https://docs.duckstring.com)**:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "duckstring"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  description = "Build data pipelines the way you build software: version each transform, declare its dependencies, and Duckstring resolves the execution DAG automatically."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -19,9 +19,22 @@ dependencies = [
19
19
  "httpx>=0.25",
20
20
  "tomli>=2.0; python_version < '3.11'",
21
21
  "tomli-w>=1.0",
22
+ # The default data plane: an Apache Iceberg base layer (snapshots + schema metadata over the
23
+ # Parquet files we already write). Core deps because Iceberg is default-on; set
24
+ # DUCKSTRING_DATA_PLANE=parquet to opt out (the lighter fallback, also for offline Catchments that
25
+ # can't fetch DuckDB's iceberg extension). NB: we use our own file-backed catalog
26
+ # (duckstring.iceberg_catalog) rather than pyiceberg's SqlCatalog, so SQLAlchemy is NOT pulled in.
27
+ "pyiceberg>=0.7",
28
+ "pyarrow>=14",
29
+ # DuckDB requires pytz to convert TIMESTAMPTZ columns to Python datetimes on fetch
30
+ # (our _duckstring_f freshness column is TIMESTAMPTZ, read back in dataplane/trickle export).
31
+ # It is otherwise only present transitively (pandas), so a clean install lacks it.
32
+ "pytz",
22
33
  ]
23
34
 
24
35
  [project.optional-dependencies]
36
+ # Retained for back-compat: the Iceberg deps are in core now (Iceberg is the default plane).
37
+ iceberg = []
25
38
  dev = [
26
39
  "pytest>=8.0",
27
40
  "pytest-timeout>=0.5",
@@ -6,7 +6,6 @@ from .core import (
6
6
  Pond,
7
7
  Puddle,
8
8
  Ripple,
9
- Trickle,
10
9
  puddle,
11
10
  ripple,
12
11
  )
@@ -21,7 +20,6 @@ __all__ = [
21
20
  "Pond",
22
21
  "Puddle",
23
22
  "Ripple",
24
- "Trickle",
25
23
  "puddle",
26
24
  "ripple",
27
25
  "__version__",
@@ -0,0 +1,13 @@
1
+ """Backwards-compatible alias — scan (order-dependent) metric specs live in :mod:`duckstring.trickle.acc`.
2
+
3
+ Kept so ``from duckstring import acc`` works alongside ``from duckstring import agg``. See
4
+ :mod:`duckstring.trickle_io` for the rationale.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .trickle import acc as _acc
10
+
11
+
12
+ def __getattr__(name): # PEP 562: forward all names to the relocated module
13
+ return getattr(_acc, name)
@@ -0,0 +1,12 @@
1
+ """Backwards-compatible alias — aggregate metric specs now live in :mod:`duckstring.trickle.agg`.
2
+
3
+ Kept so ``from duckstring import agg`` continues to work. See :mod:`duckstring.trickle_io` for the rationale.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from .trickle import agg as _agg
9
+
10
+
11
+ def __getattr__(name): # PEP 562: forward all names to the relocated module
12
+ return getattr(_agg, name)
@@ -0,0 +1,21 @@
1
+ """Alerts — failure & freshness notifications to external channels. See plans/alerts.md.
2
+
3
+ The observability sibling of a Spout: operational config + a scheme-selected notifier seam +
4
+ ``${env:}``/``${secret:}`` credentials + an async delivery worker that never cascades a failure back into
5
+ the engine. Alerting *observes* the state the engine already computes; it adds no orchestration state.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .base import Notifier, NotifierError, get_notifier, parse_notifier_destination
11
+ from .event import KNOWN_EVENTS, AlertEvent, normalise_events
12
+
13
+ __all__ = [
14
+ "AlertEvent",
15
+ "KNOWN_EVENTS",
16
+ "Notifier",
17
+ "NotifierError",
18
+ "get_notifier",
19
+ "normalise_events",
20
+ "parse_notifier_destination",
21
+ ]
@@ -0,0 +1,94 @@
1
+ """The notifier seam (see plans/alerts.md) — mirrors :mod:`duckstring.egress.base`.
2
+
3
+ A small, scheme-selected interface the alert worker delivers an :class:`AlertEvent` through. The channel
4
+ destination is a URI whose scheme picks the notifier; ``get_notifier(destination)`` resolves it, exactly
5
+ like ``get_egress``. Credentials travel inside the URI as ``${env:NAME}``/``${secret:NAME}`` references and
6
+ are resolved only at send time (:mod:`duckstring.egress.credentials`) — never persisted or logged resolved.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Callable, Protocol, runtime_checkable
13
+ from urllib.parse import urlparse
14
+
15
+ from ..egress import credentials
16
+ from .event import AlertEvent
17
+
18
+ # Schemes a channel may target. http/https → a webhook (Slack-incoming-webhook compatible); mailto → SMTP.
19
+ WEBHOOK_SCHEMES = {"http", "https"}
20
+ EMAIL_SCHEMES = {"mailto"}
21
+ KNOWN_SCHEMES = WEBHOOK_SCHEMES | EMAIL_SCHEMES
22
+
23
+
24
+ class NotifierError(ValueError):
25
+ """An invalid channel destination, or a delivery/connectivity failure (sanitised — never a credential)."""
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Destination:
30
+ scheme: str
31
+ raw: str # the original URI, with ${...} references intact (resolved only at send time)
32
+
33
+
34
+ def parse_notifier_destination(uri: str) -> Destination:
35
+ """Validate a channel destination URI: a known scheme + well-formed ``${...}`` references. Does **not**
36
+ resolve credentials (so a channel can be created before its secrets are present). Raises NotifierError."""
37
+ if not uri or not uri.strip():
38
+ raise NotifierError("destination must not be empty")
39
+ try:
40
+ credentials.references(uri) # validates ${env:}/${secret:} syntax
41
+ except credentials.CredentialError as exc:
42
+ raise NotifierError(str(exc)) from exc
43
+ scheme = urlparse(uri).scheme.lower()
44
+ if not scheme:
45
+ raise NotifierError(f"destination {uri!r} has no scheme — expected e.g. https://…, mailto:…")
46
+ if scheme not in KNOWN_SCHEMES:
47
+ raise NotifierError(
48
+ f"unsupported alert destination scheme {scheme!r} — supported: {', '.join(sorted(KNOWN_SCHEMES))}"
49
+ )
50
+ return Destination(scheme=scheme, raw=uri)
51
+
52
+
53
+ @runtime_checkable
54
+ class Notifier(Protocol):
55
+ def send(self, event: AlertEvent) -> None:
56
+ """Deliver ``event`` to the destination. Raises :class:`NotifierError` (sanitised) on failure."""
57
+ ...
58
+
59
+ def test(self) -> None:
60
+ """Probe connectivity/credentials without delivering a real alert (the ``alert test`` command).
61
+ Returns on success; raises :class:`NotifierError` (sanitised) on failure."""
62
+ ...
63
+
64
+
65
+ _REGISTRY: dict[str, Callable[[Destination], Notifier]] = {}
66
+
67
+
68
+ def register(scheme: str, factory: Callable[[Destination], Notifier]) -> None:
69
+ _REGISTRY[scheme] = factory
70
+
71
+
72
+ def get_notifier(destination: str) -> Notifier:
73
+ """Resolve the notifier for a channel destination by its scheme. Raises :class:`NotifierError` for an
74
+ unknown scheme, or a known scheme whose notifier is not built."""
75
+ dest = parse_notifier_destination(destination)
76
+ factory = _REGISTRY.get(dest.scheme)
77
+ if factory is None:
78
+ raise NotifierError(
79
+ f"notifier for scheme {dest.scheme!r} is not implemented yet (built: "
80
+ f"{', '.join(sorted(_REGISTRY)) or 'none'})"
81
+ )
82
+ return factory(dest)
83
+
84
+
85
+ def _register_builtins() -> None:
86
+ from .email import EmailNotifier
87
+ from .webhook import WebhookNotifier
88
+
89
+ for driver in (WebhookNotifier, EmailNotifier):
90
+ for scheme in driver.SCHEMES:
91
+ register(scheme, driver)
92
+
93
+
94
+ _register_builtins()
@@ -0,0 +1,106 @@
1
+ """The email notifier (``mailto:``) — the simplest floor.
2
+
3
+ ``mailto:ops@x.com,dev@x.com?smtp=host:587&from=alerts@x.com&user=${env:SMTP_USER}&password=${secret:SMTP_PASS}&tls=1``
4
+
5
+ SMTP host/port/user/password/from come from the URI query, or the ``DUCKSTRING_SMTP_*`` environment as a
6
+ fallback (so a Catchment can carry one SMTP config for every mailto channel). Credentials are resolved from
7
+ ``${env:}``/``${secret:}`` only at send time. Uses the stdlib ``smtplib`` — no new dependency.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from email.message import EmailMessage
14
+ from urllib.parse import parse_qs, unquote, urlparse
15
+
16
+ from .base import Destination, NotifierError
17
+ from .event import AlertEvent
18
+
19
+ _TIMEOUT = 20.0
20
+
21
+
22
+ class EmailNotifier:
23
+ SCHEMES = ("mailto",)
24
+
25
+ def __init__(self, dest: Destination):
26
+ parsed = urlparse(dest.raw)
27
+ self.recipients = [r.strip() for r in unquote(parsed.path).split(",") if r.strip()]
28
+ if not self.recipients:
29
+ raise NotifierError("mailto: destination has no recipient — use mailto:you@example.com")
30
+ q = {k: v[0] for k, v in parse_qs(parsed.query).items()}
31
+ self._raw = q # references intact; resolved at send time
32
+ self.smtp = q.get("smtp") or os.environ.get("DUCKSTRING_SMTP_HOST", "")
33
+ self.sender = q.get("from") or os.environ.get("DUCKSTRING_SMTP_FROM", "duckstring@localhost")
34
+ tls = q.get("tls")
35
+ self.tls = (tls not in ("0", "false", "no")) if tls is not None else \
36
+ (os.environ.get("DUCKSTRING_SMTP_TLS", "1") not in ("0", "false", "no"))
37
+ if not self.smtp:
38
+ raise NotifierError(
39
+ "mailto: destination needs an SMTP server — add ?smtp=host:port or set DUCKSTRING_SMTP_HOST"
40
+ )
41
+
42
+ def _host_port(self) -> tuple[str, int]:
43
+ host, _, port = self.smtp.partition(":")
44
+ return host, int(port) if port else 587
45
+
46
+ def _credentials(self) -> tuple[str | None, str | None]:
47
+ from ..egress import credentials
48
+
49
+ user = self._raw.get("user") or os.environ.get("DUCKSTRING_SMTP_USER")
50
+ password = self._raw.get("password") or os.environ.get("DUCKSTRING_SMTP_PASSWORD")
51
+ # Resolve ${env:}/${secret:} refs at call time — never persisted/logged resolved.
52
+ user = credentials.resolve(user) if user else None
53
+ password = credentials.resolve(password) if password else None
54
+ return user, password
55
+
56
+ def _connect(self):
57
+ import smtplib
58
+
59
+ host, port = self._host_port()
60
+ server = smtplib.SMTP(host, port, timeout=_TIMEOUT)
61
+ try:
62
+ server.ehlo()
63
+ if self.tls:
64
+ server.starttls()
65
+ server.ehlo()
66
+ user, password = self._credentials()
67
+ if user and password:
68
+ server.login(user, password)
69
+ except Exception:
70
+ server.close()
71
+ raise
72
+ return server
73
+
74
+ def send(self, event: AlertEvent) -> None:
75
+ msg = EmailMessage()
76
+ msg["Subject"] = event.summary()
77
+ msg["From"] = self.sender
78
+ msg["To"] = ", ".join(self.recipients)
79
+ lines = [event.message]
80
+ if event.pond:
81
+ lines.append(f"\nPond: {event.pond}")
82
+ if event.f:
83
+ lines.append(f"Freshness: {event.f}")
84
+ if event.detail:
85
+ for k, v in event.detail.items():
86
+ lines.append(f"{k}: {v}")
87
+ msg.set_content("\n".join(lines))
88
+ try:
89
+ server = self._connect()
90
+ try:
91
+ server.send_message(msg)
92
+ finally:
93
+ server.quit()
94
+ except NotifierError:
95
+ raise
96
+ except Exception as exc: # noqa: BLE001 — sanitise: type only, never the credential/host detail
97
+ raise NotifierError(f"email delivery failed: {type(exc).__name__}") from None
98
+
99
+ def test(self) -> None:
100
+ try:
101
+ server = self._connect() # connect + STARTTLS + login, deliver nothing
102
+ server.quit()
103
+ except NotifierError:
104
+ raise
105
+ except Exception as exc: # noqa: BLE001
106
+ raise NotifierError(f"SMTP connection failed: {type(exc).__name__}") from None
@@ -0,0 +1,97 @@
1
+ """The alert event model + the event-kind vocabulary (see plans/alerts.md).
2
+
3
+ An :class:`AlertEvent` is the rendered, **sanitised** payload a notifier delivers. It reuses what
4
+ ``/api/runs`` surfaces (error message, freshness, pond) but never a raw traceback — a channel destination
5
+ can be third-party, and a traceback can leak paths/connection strings (the same concern behind the API's
6
+ ``_redact_tracebacks``). Keep it JSON-serialisable: it is stored verbatim in the ``alert_delivery`` outbox.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime, timezone
13
+
14
+ # The event vocabulary. `freshness` is tick-driven (the SLA sweep); the rest are transition-driven.
15
+ KNOWN_EVENTS = ("failure", "contract", "spout", "recovery", "freshness")
16
+
17
+ # Default severity per kind (a channel filters by kind, not severity — this is only for the payload/label).
18
+ SEVERITY = {
19
+ "failure": "error",
20
+ "contract": "error",
21
+ "spout": "error",
22
+ "freshness": "warning",
23
+ "recovery": "info",
24
+ }
25
+
26
+
27
+ def normalise_events(events: str | None) -> tuple[str, ...]:
28
+ """Parse a channel's ``events`` CSV (or ``all``/empty) into a validated tuple of known kinds.
29
+
30
+ ``all`` / empty → every kind. Raises :class:`ValueError` naming an unknown kind."""
31
+ if not events or events.strip().lower() == "all":
32
+ return KNOWN_EVENTS
33
+ out = []
34
+ for raw in events.split(","):
35
+ kind = raw.strip().lower()
36
+ if not kind:
37
+ continue
38
+ if kind not in KNOWN_EVENTS:
39
+ raise ValueError(f"unknown alert event {kind!r} — choose from {', '.join(KNOWN_EVENTS)} (or 'all')")
40
+ out.append(kind)
41
+ if not out:
42
+ return KNOWN_EVENTS
43
+ return tuple(dict.fromkeys(out)) # de-duplicated, order preserved
44
+
45
+
46
+ @dataclass
47
+ class AlertEvent:
48
+ """A rendered notification. ``detail`` carries kind-specific extras (e.g. the blocked-downstream blast
49
+ radius for a failure); never put a traceback in it."""
50
+
51
+ kind: str
52
+ pond: str | None
53
+ title: str
54
+ message: str
55
+ severity: str = ""
56
+ f: str | None = None
57
+ catchment: str | None = None
58
+ ts: str = ""
59
+ detail: dict = field(default_factory=dict)
60
+
61
+ def __post_init__(self) -> None:
62
+ if not self.severity:
63
+ self.severity = SEVERITY.get(self.kind, "info")
64
+ if not self.ts:
65
+ self.ts = datetime.now(timezone.utc).isoformat()
66
+
67
+ def summary(self) -> str:
68
+ """A one-line human summary (the webhook ``text`` / the email subject line)."""
69
+ where = f" [{self.catchment}]" if self.catchment else ""
70
+ return f"{self.severity.upper()}{where}: {self.title}"
71
+
72
+ def to_payload(self) -> dict:
73
+ return {
74
+ "kind": self.kind,
75
+ "severity": self.severity,
76
+ "pond": self.pond,
77
+ "f": self.f,
78
+ "title": self.title,
79
+ "message": self.message,
80
+ "catchment": self.catchment,
81
+ "ts": self.ts,
82
+ "detail": self.detail,
83
+ }
84
+
85
+ @classmethod
86
+ def from_payload(cls, payload: dict) -> "AlertEvent":
87
+ return cls(
88
+ kind=payload.get("kind", ""),
89
+ pond=payload.get("pond"),
90
+ title=payload.get("title", ""),
91
+ message=payload.get("message", ""),
92
+ severity=payload.get("severity", ""),
93
+ f=payload.get("f"),
94
+ catchment=payload.get("catchment"),
95
+ ts=payload.get("ts", ""),
96
+ detail=payload.get("detail") or {},
97
+ )
@@ -0,0 +1,46 @@
1
+ """The webhook notifier (``http``/``https``) — the highest-leverage channel.
2
+
3
+ POSTs a JSON body that is both a plain structured event **and** Slack-incoming-webhook compatible: a
4
+ top-level ``text`` summary (which Slack renders, and a generic receiver can read) plus the full structured
5
+ event. So one driver covers Slack, a generic webhook receiver, and (via a proxy) PagerDuty's Events API.
6
+ Any credential/token in the URL is a ``${env:}``/``${secret:}`` reference, resolved only at send time.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .base import Destination, NotifierError
12
+ from .event import AlertEvent
13
+
14
+ _TIMEOUT = 15.0
15
+
16
+
17
+ class WebhookNotifier:
18
+ SCHEMES = ("http", "https")
19
+
20
+ def __init__(self, dest: Destination):
21
+ self.dest = dest
22
+
23
+ def _post(self, event: AlertEvent) -> None:
24
+ import httpx
25
+
26
+ from ..egress import credentials
27
+
28
+ url = credentials.resolve(self.dest.raw) # resolve any ${env:}/${secret:} token — never logged
29
+ body = {"text": event.summary(), **event.to_payload()} # `text` for Slack; the rest for generic receivers
30
+ try:
31
+ resp = httpx.post(url, json=body, timeout=_TIMEOUT)
32
+ resp.raise_for_status()
33
+ except httpx.HTTPStatusError as exc:
34
+ # Sanitise: report status + reason, never the URL (it may carry a token in the path/query).
35
+ raise NotifierError(f"webhook returned {exc.response.status_code}") from None
36
+ except httpx.HTTPError as exc:
37
+ raise NotifierError(f"webhook delivery failed: {type(exc).__name__}") from None
38
+
39
+ def send(self, event: AlertEvent) -> None:
40
+ self._post(event)
41
+
42
+ def test(self) -> None:
43
+ self._post(AlertEvent(
44
+ kind="recovery", pond=None, title="Duckstring alert channel test",
45
+ message="This is a test notification — your alert channel is configured correctly.",
46
+ ))
@@ -0,0 +1,58 @@
1
+ """The alert worker — delivers queued notifications to their channels. See plans/alerts.md.
2
+
3
+ One async task in the Catchment process (outbound I/O, the exact shape of the egress worker): each pass it
4
+ drains the pending ``alert_delivery`` rows the engine enqueued (:meth:`Driver.take_alert_deliveries`),
5
+ resolves each channel's notifier by scheme, ``send``s it in a threadpool with a per-send timeout, and marks
6
+ the row sent (:meth:`Driver.mark_delivery_sent`) or bumps attempts / parks it failed at the cap
7
+ (:meth:`Driver.mark_delivery_failed`).
8
+
9
+ **A send failure never propagates into the engine** — it is recorded on the delivery row (auditable via
10
+ ``alert log``) and retried on the next tick until it succeeds or hits ``MAX_ATTEMPTS``. A permanently-broken
11
+ channel therefore stops retrying but leaves a visible failed row, never a Pond failure.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+
18
+ from fastapi.concurrency import run_in_threadpool
19
+
20
+ _RECONCILE_INTERVAL = 5.0 # self-healing tick — retries pending deliveries a previous send left behind
21
+ _PER_SEND_TIMEOUT = 30.0 # ceiling on one delivery, so a slow channel can't starve the others
22
+ MAX_ATTEMPTS = 6 # after this many failed sends, park the delivery 'failed' (stop retrying)
23
+
24
+
25
+ def _deliver(destination: str, payload: dict) -> None:
26
+ """Send one notification (blocking — runs in the thread pool). Raises on any failure."""
27
+ from ..alerts import AlertEvent, get_notifier
28
+
29
+ notifier = get_notifier(destination) # resolves the scheme's driver (+ validates the URI)
30
+ notifier.send(AlertEvent.from_payload(payload))
31
+
32
+
33
+ async def _drain(driver) -> None:
34
+ for row in driver.take_alert_deliveries():
35
+ try:
36
+ await asyncio.wait_for(
37
+ run_in_threadpool(_deliver, row["destination"], row["payload"]),
38
+ timeout=_PER_SEND_TIMEOUT,
39
+ )
40
+ except Exception as exc: # noqa: BLE001 — any delivery error is recorded, never raised into the engine
41
+ driver.mark_delivery_failed(row["id"], f"{type(exc).__name__}: {exc}", MAX_ATTEMPTS)
42
+ else:
43
+ driver.mark_delivery_sent(row["id"])
44
+
45
+
46
+ async def run_alert_worker(driver, wake: asyncio.Event) -> None:
47
+ """Drain queued alert deliveries on each wake (a delivery was enqueued) or the reconcile tick.
48
+ Cancelled on shutdown."""
49
+ while True:
50
+ try:
51
+ await asyncio.wait_for(wake.wait(), timeout=_RECONCILE_INTERVAL)
52
+ except asyncio.TimeoutError:
53
+ pass # periodic reconcile — retry anything still pending
54
+ wake.clear()
55
+ try:
56
+ await _drain(driver)
57
+ except Exception as exc: # keep the loop alive
58
+ print(f"[catchment] alert worker error: {exc}", flush=True)