mostlyright-data 0.9.0__py3-none-any.whl

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 (314) hide show
  1. mostlyright/data_harness/__init__.py +158 -0
  2. mostlyright/data_harness/acquisition/__init__.py +55 -0
  3. mostlyright/data_harness/acquisition/http.py +2773 -0
  4. mostlyright/data_harness/acquisition/parsing.py +809 -0
  5. mostlyright/data_harness/acquisition/ranges.py +495 -0
  6. mostlyright/data_harness/acquisition/result_download.py +360 -0
  7. mostlyright/data_harness/acquisition/retention_admission.py +248 -0
  8. mostlyright/data_harness/acquisition/sandbox.py +4888 -0
  9. mostlyright/data_harness/acquisition/url_policy.py +530 -0
  10. mostlyright/data_harness/agent_runtime.py +2743 -0
  11. mostlyright/data_harness/assets/logo-ink.svg +31 -0
  12. mostlyright/data_harness/backends/__init__.py +28 -0
  13. mostlyright/data_harness/backends/pandas_backend.py +350 -0
  14. mostlyright/data_harness/backends/polars_backend.py +366 -0
  15. mostlyright/data_harness/backends/protocol.py +124 -0
  16. mostlyright/data_harness/backends/reference.py +83 -0
  17. mostlyright/data_harness/backends/registry.py +55 -0
  18. mostlyright/data_harness/backends/restrictions.py +126 -0
  19. mostlyright/data_harness/canonical.py +333 -0
  20. mostlyright/data_harness/catalog_job.py +625 -0
  21. mostlyright/data_harness/cli.py +5398 -0
  22. mostlyright/data_harness/contracts.py +53 -0
  23. mostlyright/data_harness/coordinator.py +1307 -0
  24. mostlyright/data_harness/deploy.py +924 -0
  25. mostlyright/data_harness/deploy_target.py +312 -0
  26. mostlyright/data_harness/deployment_evidence.py +1067 -0
  27. mostlyright/data_harness/event_presentation.py +576 -0
  28. mostlyright/data_harness/events.py +2152 -0
  29. mostlyright/data_harness/fast_delimited.py +239 -0
  30. mostlyright/data_harness/fleet.py +237 -0
  31. mostlyright/data_harness/formats.py +236 -0
  32. mostlyright/data_harness/governors.py +1163 -0
  33. mostlyright/data_harness/hosted_bootstrap.py +972 -0
  34. mostlyright/data_harness/hosted_crawler.py +1115 -0
  35. mostlyright/data_harness/hosted_crawler_container_smoke.py +351 -0
  36. mostlyright/data_harness/hosted_crawler_fetch.py +423 -0
  37. mostlyright/data_harness/hosted_crawler_job.py +1277 -0
  38. mostlyright/data_harness/hosted_crawler_protocol.py +676 -0
  39. mostlyright/data_harness/hosted_dataset.py +1500 -0
  40. mostlyright/data_harness/hosted_deploy.py +3037 -0
  41. mostlyright/data_harness/hosted_handoff.py +62 -0
  42. mostlyright/data_harness/hosted_ingestion_contract.py +504 -0
  43. mostlyright/data_harness/hosted_ingestion_job.py +356 -0
  44. mostlyright/data_harness/hosted_ingestion_job_smoke.py +40 -0
  45. mostlyright/data_harness/hosted_session_container_smoke.py +194 -0
  46. mostlyright/data_harness/hosted_session_worker.py +3554 -0
  47. mostlyright/data_harness/hosted_session_worker_job_smoke.py +46 -0
  48. mostlyright/data_harness/hosted_worker.py +6784 -0
  49. mostlyright/data_harness/ingestion/__init__.py +56 -0
  50. mostlyright/data_harness/ingestion/contracts.py +461 -0
  51. mostlyright/data_harness/ingestion/faults.py +42 -0
  52. mostlyright/data_harness/ingestion/gcs_store.py +1162 -0
  53. mostlyright/data_harness/ingestion/spool.py +130 -0
  54. mostlyright/data_harness/ingestion/store.py +885 -0
  55. mostlyright/data_harness/key_seam.py +434 -0
  56. mostlyright/data_harness/linux_process_boundary.py +262 -0
  57. mostlyright/data_harness/local_contracts.py +2880 -0
  58. mostlyright/data_harness/local_search/__init__.py +5 -0
  59. mostlyright/data_harness/local_search/build_index.py +1087 -0
  60. mostlyright/data_harness/local_search/contracts.py +920 -0
  61. mostlyright/data_harness/local_search/query_trace.py +266 -0
  62. mostlyright/data_harness/local_search/retrieval.py +700 -0
  63. mostlyright/data_harness/local_search/sealed.py +474 -0
  64. mostlyright/data_harness/local_search/service.py +784 -0
  65. mostlyright/data_harness/nbrender/CONTRACT.md +212 -0
  66. mostlyright/data_harness/nbrender/__init__.py +12 -0
  67. mostlyright/data_harness/nbrender/chrome.py +359 -0
  68. mostlyright/data_harness/nbrender/code_body.py +266 -0
  69. mostlyright/data_harness/nbrender/document.py +407 -0
  70. mostlyright/data_harness/nbrender/frame.py +275 -0
  71. mostlyright/data_harness/nbrender/interactive.py +337 -0
  72. mostlyright/data_harness/nbrender/markdown_body.py +477 -0
  73. mostlyright/data_harness/nbrender/mr_components.py +134 -0
  74. mostlyright/data_harness/nbrender/outputs_data.py +595 -0
  75. mostlyright/data_harness/nbrender/outputs_rich.py +906 -0
  76. mostlyright/data_harness/nbrender/outputs_source.py +260 -0
  77. mostlyright/data_harness/nbrender/outputs_stage.py +176 -0
  78. mostlyright/data_harness/nbrender/outputs_text.py +400 -0
  79. mostlyright/data_harness/nbrender/parse.py +394 -0
  80. mostlyright/data_harness/nbrender/status.py +40 -0
  81. mostlyright/data_harness/nbrender/tokens.py +1295 -0
  82. mostlyright/data_harness/notebook.py +1710 -0
  83. mostlyright/data_harness/offline.py +2049 -0
  84. mostlyright/data_harness/operation_registry.py +1007 -0
  85. mostlyright/data_harness/operator_setup.py +239 -0
  86. mostlyright/data_harness/pipeline.py +6428 -0
  87. mostlyright/data_harness/plan_graph.py +2026 -0
  88. mostlyright/data_harness/preparation/__init__.py +104 -0
  89. mostlyright/data_harness/preparation/contracts.py +1017 -0
  90. mostlyright/data_harness/preparation/engine.py +221 -0
  91. mostlyright/data_harness/preparation/errors.py +14 -0
  92. mostlyright/data_harness/preparation/gates.py +751 -0
  93. mostlyright/data_harness/preparation/joins.py +574 -0
  94. mostlyright/data_harness/preparation/profile.py +384 -0
  95. mostlyright/data_harness/preparation/table.py +217 -0
  96. mostlyright/data_harness/preparation/transforms.py +568 -0
  97. mostlyright/data_harness/progress_events.py +534 -0
  98. mostlyright/data_harness/readers/__init__.py +46 -0
  99. mostlyright/data_harness/readers/containers.py +963 -0
  100. mostlyright/data_harness/readers/contracts.py +542 -0
  101. mostlyright/data_harness/readers/delimited.py +257 -0
  102. mostlyright/data_harness/readers/grib2/__init__.py +33 -0
  103. mostlyright/data_harness/readers/grib2/admission.py +722 -0
  104. mostlyright/data_harness/readers/grib2/decode.py +1009 -0
  105. mostlyright/data_harness/readers/grib2/geometry.py +1133 -0
  106. mostlyright/data_harness/readers/grib2/portable_math.py +501 -0
  107. mostlyright/data_harness/readers/json_tabular.py +485 -0
  108. mostlyright/data_harness/readers/registry.py +514 -0
  109. mostlyright/data_harness/readers/samples/README.md +110 -0
  110. mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/cities.csv.gz +0 -0
  111. mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/expected.json +24 -0
  112. mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/cities.csv.gz +0 -0
  113. mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/expected.json +24 -0
  114. mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/cities.tar +0 -0
  115. mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/expected.json +24 -0
  116. mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/cities.tar +0 -0
  117. mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/expected.json +24 -0
  118. mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/cities.zip +0 -0
  119. mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/expected.json +25 -0
  120. mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
  121. mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/expected.json +25 -0
  122. mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
  123. mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/expected.json +25 -0
  124. mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/cities.csv +3 -0
  125. mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/expected.json +23 -0
  126. mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/cities.tsv +5 -0
  127. mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/expected.json +25 -0
  128. mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/expected.json +30 -0
  129. mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/observations.csv +5 -0
  130. mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/expected.json +44 -0
  131. mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/stations.json +1 -0
  132. mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/expected.json +48 -0
  133. mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/observations.ndjson +4 -0
  134. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/cities.xlsx +0 -0
  135. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/expected.json +24 -0
  136. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/expected.json +27 -0
  137. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
  138. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/expected.json +27 -0
  139. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
  140. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/README.md +20 -0
  141. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/expected.json +55 -0
  142. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/gfs-2m-temperature.grib2 +0 -0
  143. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/expected.json +54 -0
  144. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
  145. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/expected.json +54 -0
  146. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/hrrr-categorical-rain.grib2 +0 -0
  147. mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/expected.json +54 -0
  148. mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
  149. mostlyright/data_harness/readers/samples.py +582 -0
  150. mostlyright/data_harness/readers/spreadsheet.py +803 -0
  151. mostlyright/data_harness/readers/tabular.py +510 -0
  152. mostlyright/data_harness/recipe.py +5321 -0
  153. mostlyright/data_harness/repair/__init__.py +78 -0
  154. mostlyright/data_harness/repair/adapters.py +274 -0
  155. mostlyright/data_harness/repair/contracts.py +872 -0
  156. mostlyright/data_harness/repair/coordinator.py +1099 -0
  157. mostlyright/data_harness/repair/errors.py +16 -0
  158. mostlyright/data_harness/review.py +2533 -0
  159. mostlyright/data_harness/rowset.py +283 -0
  160. mostlyright/data_harness/serving.py +1975 -0
  161. mostlyright/data_harness/serving_edge.py +590 -0
  162. mostlyright/data_harness/serving_http.py +1031 -0
  163. mostlyright/data_harness/session_probes.py +759 -0
  164. mostlyright/data_harness/signing.py +101 -0
  165. mostlyright/data_harness/source_discovery.py +898 -0
  166. mostlyright/data_harness/sources/__init__.py +209 -0
  167. mostlyright/data_harness/sources/_adapter_steps.py +213 -0
  168. mostlyright/data_harness/sources/adapters.py +1214 -0
  169. mostlyright/data_harness/sources/cadence.py +1428 -0
  170. mostlyright/data_harness/sources/cadence_emission.py +453 -0
  171. mostlyright/data_harness/sources/cadence_history.py +546 -0
  172. mostlyright/data_harness/sources/catalog/__init__.py +17 -0
  173. mostlyright/data_harness/sources/catalog/admission.py +477 -0
  174. mostlyright/data_harness/sources/catalog/authoring.py +1701 -0
  175. mostlyright/data_harness/sources/catalog/authoring_policy.py +701 -0
  176. mostlyright/data_harness/sources/catalog/authoring_shards.py +1217 -0
  177. mostlyright/data_harness/sources/catalog/bounded_io.py +231 -0
  178. mostlyright/data_harness/sources/catalog/channel.py +523 -0
  179. mostlyright/data_harness/sources/catalog/channel_client.py +296 -0
  180. mostlyright/data_harness/sources/catalog/contracts.py +825 -0
  181. mostlyright/data_harness/sources/catalog/coverage.py +137 -0
  182. mostlyright/data_harness/sources/catalog/delta.py +1340 -0
  183. mostlyright/data_harness/sources/catalog/embedding.py +532 -0
  184. mostlyright/data_harness/sources/catalog/entry_v2.py +1182 -0
  185. mostlyright/data_harness/sources/catalog/fill.py +3889 -0
  186. mostlyright/data_harness/sources/catalog/fill_partitions.py +459 -0
  187. mostlyright/data_harness/sources/catalog/fill_staging.py +1105 -0
  188. mostlyright/data_harness/sources/catalog/gating.py +374 -0
  189. mostlyright/data_harness/sources/catalog/generation_receipt.py +1607 -0
  190. mostlyright/data_harness/sources/catalog/harvest/__init__.py +7 -0
  191. mostlyright/data_harness/sources/catalog/harvest/ckan.py +384 -0
  192. mostlyright/data_harness/sources/catalog/harvest/datagov_v4.py +798 -0
  193. mostlyright/data_harness/sources/catalog/harvest/protocol.py +964 -0
  194. mostlyright/data_harness/sources/catalog/harvest/sdmx.py +445 -0
  195. mostlyright/data_harness/sources/catalog/harvest/stac.py +384 -0
  196. mostlyright/data_harness/sources/catalog/health.py +447 -0
  197. mostlyright/data_harness/sources/catalog/hosted_catalog.py +105 -0
  198. mostlyright/data_harness/sources/catalog/identity_history.py +1549 -0
  199. mostlyright/data_harness/sources/catalog/neural.py +1618 -0
  200. mostlyright/data_harness/sources/catalog/packed_catalog.py +2345 -0
  201. mostlyright/data_harness/sources/catalog/packed_retrieval.py +1517 -0
  202. mostlyright/data_harness/sources/catalog/packed_writer.py +2802 -0
  203. mostlyright/data_harness/sources/catalog/query_trace.py +1037 -0
  204. mostlyright/data_harness/sources/catalog/recommend.py +171 -0
  205. mostlyright/data_harness/sources/catalog/retrieval.py +230 -0
  206. mostlyright/data_harness/sources/catalog/retrieval_manifest.py +995 -0
  207. mostlyright/data_harness/sources/catalog/rights_decisions.py +254 -0
  208. mostlyright/data_harness/sources/catalog/sealed.py +560 -0
  209. mostlyright/data_harness/sources/catalog/search.py +230 -0
  210. mostlyright/data_harness/sources/catalog/streaming_delta.py +1097 -0
  211. mostlyright/data_harness/sources/catalog/update.py +891 -0
  212. mostlyright/data_harness/sources/collections.py +815 -0
  213. mostlyright/data_harness/sources/contracts.py +2223 -0
  214. mostlyright/data_harness/sources/deletion.py +761 -0
  215. mostlyright/data_harness/sources/fitness.py +162 -0
  216. mostlyright/data_harness/sources/governance.py +163 -0
  217. mostlyright/data_harness/sources/hosted.py +173 -0
  218. mostlyright/data_harness/sources/integration.py +218 -0
  219. mostlyright/data_harness/sources/range_reader.py +418 -0
  220. mostlyright/data_harness/sources/registry.py +514 -0
  221. mostlyright/data_harness/sources/rights_rule.py +59 -0
  222. mostlyright/data_harness/sources/source_cadence_vectors.v1.json +1 -0
  223. mostlyright/data_harness/sources/sports.py +521 -0
  224. mostlyright/data_harness/sources/stream.py +524 -0
  225. mostlyright/data_harness/sources/stream_connector.py +418 -0
  226. mostlyright/data_harness/sources/stream_recorder.py +1404 -0
  227. mostlyright/data_harness/studio_boundary.py +2019 -0
  228. mostlyright/data_harness/thin/__init__.py +37 -0
  229. mostlyright/data_harness/thin/acquire.py +1137 -0
  230. mostlyright/data_harness/thin/acquire_cancel.py +579 -0
  231. mostlyright/data_harness/thin/approvals.py +617 -0
  232. mostlyright/data_harness/thin/commands.py +406 -0
  233. mostlyright/data_harness/thin/download.py +194 -0
  234. mostlyright/data_harness/thin/narrative.py +589 -0
  235. mostlyright/data_harness/thin/parity.py +1070 -0
  236. mostlyright/data_harness/thin/propose.py +2759 -0
  237. mostlyright/data_harness/thin/research.py +1663 -0
  238. mostlyright/data_harness/thin/router.py +924 -0
  239. mostlyright/data_harness/thin/runs.py +519 -0
  240. mostlyright/data_harness/thin/session.py +281 -0
  241. mostlyright/data_harness/thin/stream.py +501 -0
  242. mostlyright/data_harness/thin/transport.py +187 -0
  243. mostlyright/data_harness/thin/vocabulary.py +368 -0
  244. mostlyright/data_harness/thin/workers.py +164 -0
  245. mostlyright/data_harness/ucum/TABLE-PIN.json +40 -0
  246. mostlyright/data_harness/ucum/ucum-subset.v1.json +632 -0
  247. mostlyright/data_harness/unit_flow.py +927 -0
  248. mostlyright/data_harness/units.py +572 -0
  249. mostlyright/data_harness/ux/__init__.py +9 -0
  250. mostlyright/data_harness/ux/approve.py +485 -0
  251. mostlyright/data_harness/ux/author_yaml.py +597 -0
  252. mostlyright/data_harness/ux/cloud_auth.py +447 -0
  253. mostlyright/data_harness/ux/commands/__init__.py +260 -0
  254. mostlyright/data_harness/ux/commands/approve.py +136 -0
  255. mostlyright/data_harness/ux/commands/auth.py +744 -0
  256. mostlyright/data_harness/ux/commands/author.py +79 -0
  257. mostlyright/data_harness/ux/commands/catalog_author.py +403 -0
  258. mostlyright/data_harness/ux/commands/catalog_fill.py +523 -0
  259. mostlyright/data_harness/ux/commands/catalog_harvest.py +545 -0
  260. mostlyright/data_harness/ux/commands/catalog_publish.py +1838 -0
  261. mostlyright/data_harness/ux/commands/catalog_search.py +71 -0
  262. mostlyright/data_harness/ux/commands/catalog_update.py +437 -0
  263. mostlyright/data_harness/ux/commands/deploy.py +134 -0
  264. mostlyright/data_harness/ux/commands/deploy_dataset.py +98 -0
  265. mostlyright/data_harness/ux/commands/deploy_plan.py +105 -0
  266. mostlyright/data_harness/ux/commands/deploy_status.py +104 -0
  267. mostlyright/data_harness/ux/commands/diff.py +74 -0
  268. mostlyright/data_harness/ux/commands/index.py +84 -0
  269. mostlyright/data_harness/ux/commands/inventory.py +47 -0
  270. mostlyright/data_harness/ux/commands/list_builds.py +143 -0
  271. mostlyright/data_harness/ux/commands/login.py +63 -0
  272. mostlyright/data_harness/ux/commands/peek.py +236 -0
  273. mostlyright/data_harness/ux/commands/plan_check.py +90 -0
  274. mostlyright/data_harness/ux/commands/preflight.py +97 -0
  275. mostlyright/data_harness/ux/commands/record.py +107 -0
  276. mostlyright/data_harness/ux/commands/review_setup.py +47 -0
  277. mostlyright/data_harness/ux/commands/search.py +440 -0
  278. mostlyright/data_harness/ux/commands/show.py +61 -0
  279. mostlyright/data_harness/ux/commands/whoami.py +37 -0
  280. mostlyright/data_harness/ux/credential_native.py +551 -0
  281. mostlyright/data_harness/ux/credential_store.py +1055 -0
  282. mostlyright/data_harness/ux/credentials.py +631 -0
  283. mostlyright/data_harness/ux/diffing.py +444 -0
  284. mostlyright/data_harness/ux/headline.py +671 -0
  285. mostlyright/data_harness/ux/hosted_acquisition.py +974 -0
  286. mostlyright/data_harness/ux/hosted_run_status.py +619 -0
  287. mostlyright/data_harness/ux/inventory.py +427 -0
  288. mostlyright/data_harness/ux/local_review.py +375 -0
  289. mostlyright/data_harness/ux/login.py +691 -0
  290. mostlyright/data_harness/ux/path_kind.py +147 -0
  291. mostlyright/data_harness/ux/peek.py +1000 -0
  292. mostlyright/data_harness/ux/plain_file.py +178 -0
  293. mostlyright/data_harness/ux/plan_check.py +311 -0
  294. mostlyright/data_harness/ux/preflight.py +918 -0
  295. mostlyright/data_harness/ux/readers.py +1124 -0
  296. mostlyright/data_harness/ux/remediation.py +2195 -0
  297. mostlyright/data_harness/ux/render.py +657 -0
  298. mostlyright/data_harness/ux/workload.py +1077 -0
  299. mostlyright/data_harness/viewer.py +3713 -0
  300. mostlyright/data_harness/visual_run/__init__.py +83 -0
  301. mostlyright/data_harness/visual_run/authoring.py +235 -0
  302. mostlyright/data_harness/visual_run/contracts.py +673 -0
  303. mostlyright/data_harness/visual_run/materialize.py +486 -0
  304. mostlyright/data_harness/visual_run/observations.py +874 -0
  305. mostlyright/data_harness/visual_run/query.py +259 -0
  306. mostlyright/data_harness/visual_run/reducer.py +280 -0
  307. mostlyright/data_harness/visual_run/sdk.py +892 -0
  308. mostlyright/data_harness/visual_run/store.py +584 -0
  309. mostlyright/data_harness/visual_run/transport.py +239 -0
  310. mostlyright/data_harness/watch.py +2999 -0
  311. mostlyright_data-0.9.0.dist-info/METADATA +607 -0
  312. mostlyright_data-0.9.0.dist-info/RECORD +314 -0
  313. mostlyright_data-0.9.0.dist-info/WHEEL +4 -0
  314. mostlyright_data-0.9.0.dist-info/entry_points.txt +12 -0
@@ -0,0 +1,1163 @@
1
+ """The caps that bound what one tenant can spend, and the window they are counted over.
2
+
3
+ Cloud Run jobs are per-execution and scale to zero, so there is no autoscaler to build here.
4
+ Scale is free; caps are the work. What needs building is the thing that stops a single tenant
5
+ from spending without bound — a monthly run allowance, a limit on how many runs may be in flight at
6
+ once, and a monthly spend ceiling.
7
+
8
+ A cap breach is a normal, expected, quiet outcome, not a defect. It is the product working:
9
+ :class:`CapExceeded` names the cap that was reached and the numbers behind it, in the same idiom
10
+ as "no new run is not an error". Nothing here logs, retries, or escalates.
11
+
12
+ Two couplings in this module are invisible from either side alone:
13
+
14
+ * :func:`utc_calendar_month_bounds` must agree with ``mostlyright-cloud/dashboard/lib/usage.ts``,
15
+ which counts usage over the current UTC calendar month.
16
+ * :class:`UsageRecord` carries an opaque 16-hex tenant coordinate, never a credential. Its shape is
17
+ cloud's ``hashKeyId`` shape — the first 8 bytes of a SHA-256 digest, hex-encoded — and the shape
18
+ is all this module knows: **what the coordinate stands for is the caller's choice, and this
19
+ module cannot tell one choice from another.** On the hosted admission path, the only production
20
+ caller, the subject is the job's ``workspace_id`` (``hosted_bootstrap._tenant_coordinate``), NOT
21
+ an API key, so these counters are not cloud's per-key billing ledger and do not join against
22
+ ``usage_events.key_id`` row for row. A caller declares its subject through
23
+ :class:`CapLedger`'s ``subject`` argument, which is what a refusal a customer reads then names.
24
+
25
+ Both are stated in prose on the definitions below, because nothing in either language checks them.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import fcntl
31
+ import os
32
+ import re
33
+ import tempfile
34
+ import threading
35
+ from collections.abc import Iterator, Mapping
36
+ from contextlib import AbstractContextManager, contextmanager
37
+ from dataclasses import dataclass, replace
38
+ from datetime import UTC, datetime
39
+ from pathlib import Path
40
+ from types import TracebackType
41
+ from typing import Any, Protocol
42
+
43
+ from mostlyright.data_harness.canonical import (
44
+ CanonicalJSONError,
45
+ canonical_json_bytes,
46
+ parse_canonical_json,
47
+ )
48
+
49
+ # Policy maxima: the most any caller may ever ask for. These are ceilings, NOT defaults — the
50
+ # defaults below sit well under them, and a caller may tighten further but never widen past these.
51
+ # They exist so that a mistaken or hostile configuration cannot raise a cap to infinity: the
52
+ # ceiling is enforced in ``CapPolicy.__post_init__``, where no caller can reach around it.
53
+ MAX_RUNS_PER_MONTH = 10_000
54
+ MAX_CONCURRENT_RUNS = 16
55
+ MAX_MONTHLY_COST_UNITS = 1_000_000
56
+
57
+ #: Conservative starting caps. A new tenant gets these until someone deliberately chooses
58
+ #: otherwise.
59
+ DEFAULT_RUNS_PER_MONTH = 100
60
+ DEFAULT_CONCURRENT_RUNS = 2
61
+ DEFAULT_MONTHLY_COST_UNITS = 10_000
62
+
63
+ #: One cost unit is one whole cent of billable spend. Integer units only: money is never counted
64
+ #: in floats, because two runs that each cost 0.1 must sum to exactly 0.2 or the ledger lies.
65
+ COST_UNIT_DESCRIPTION = "one cent of billable spend"
66
+
67
+ #: The only shape a tenant coordinate may take here: cloud's ``hashKeyId`` shape, the first 8 bytes
68
+ #: of a SHA-256 digest, hex-encoded — 16 lowercase hex characters. Enforcing the shape is what makes
69
+ #: it impossible to pass a raw credential where an identifier belongs: no credential this product
70
+ #: mints is 16 lowercase hex characters, so a mistaken raw key is refused before it can be recorded.
71
+ #: What the shape CANNOT do is say what was hashed. A workspace hash and an API key id are the same
72
+ #: 16 characters here, which is why the subject is declared and never inferred — see
73
+ #: :data:`CAP_SUBJECTS`.
74
+ KEY_ID_PATTERN = re.compile(r"\A[0-9a-f]{16}\Z")
75
+
76
+ #: The only accepted persisted cap-store schema. Other schema labels are refused.
77
+ CAP_STORE_SCHEMA = "mostlyright-cap-store.v1"
78
+
79
+ #: The sidecar a file-backed store takes its writer lock on, appended to the store's own name. It
80
+ #: is a separate file because the store file's inode is replaced on every write and a lock on a
81
+ #: replaced inode excludes nobody.
82
+ LOCK_FILE_SUFFIX = ".lock"
83
+
84
+ #: What a refusal may call the thing whose caps were counted. A ledger counts by an opaque
85
+ #: coordinate (:data:`KEY_ID_PATTERN`) and cannot recover what was hashed into it, so the caller
86
+ #: declares the subject and the sentence a customer reads names the right thing. ``"workspace"``
87
+ #: leads because the hosted admission path is the only production caller and files a workspace
88
+ #: coordinate; ``"key"`` is here for a caller that files genuine API key ids, and none does today.
89
+ CAP_SUBJECTS = ("workspace", "key")
90
+
91
+ #: What a refusal may call the work item that was counted. The subject above says WHOSE work was
92
+ #: counted; this says WHAT was counted, and neither can be inferred from a 16-hex coordinate — the
93
+ #: counters have exactly the same shape either way. ``"run"`` leads and is the default, because the
94
+ #: build-side admission path is what these counters were written for and every existing refusal is
95
+ #: worded for it. ``"read"`` exists because the serving surface counts data reads, and a customer
96
+ #: refused a read who is told they have used their monthly RUN allowance is being sent to a
97
+ #: different product surface to look for a number that will not be there. That is the same failure
98
+ #: :data:`CAP_SUBJECTS` exists to prevent, one noun over. Both nouns end up in a sentence a customer
99
+ #: reads, so both are bound by ``docs/VOCABULARY.md`` the way every other customer-facing term is:
100
+ #: this is a closed set for the same reason that file is a table and not a suggestion.
101
+ CAP_WORK_UNITS = ("run", "read")
102
+
103
+ #: The refusal sentences, per work unit, keyed by the ``cap_name`` each one belongs to. Both
104
+ #: wordings live here side by side, in one place, so that neither can be edited without the other
105
+ #: being on screen — the alternative, re-wording at the calling edge, is the second wording of one
106
+ #: fact that ``deploy.plan_go_live`` refuses to create. The ``cap_name`` keys are machine handles
107
+ #: that callers branch on and are the same for every work unit; only the prose differs.
108
+ _REFUSAL_SENTENCES: Mapping[str, Mapping[str, str]] = {
109
+ "run": {
110
+ "runs_per_month": (
111
+ "this {subject} has used its monthly run allowance: "
112
+ "{observed} runs this month against a limit of {limit}"
113
+ ),
114
+ "concurrent_runs": (
115
+ "this {subject} is already running as much at once as it is allowed: "
116
+ "{observed} runs at once against a limit of {limit}"
117
+ ),
118
+ "monthly_cost_units": (
119
+ "this run would pass the monthly spend limit for this {subject}: "
120
+ "{observed} units this month against a limit of {limit}"
121
+ ),
122
+ },
123
+ "read": {
124
+ "runs_per_month": (
125
+ "this {subject} has used its monthly read allowance: "
126
+ "{observed} reads this month against a limit of {limit}"
127
+ ),
128
+ "concurrent_runs": (
129
+ "this {subject} is already serving as much at once as it is allowed: "
130
+ "{observed} reads at once against a limit of {limit}"
131
+ ),
132
+ "monthly_cost_units": (
133
+ "this read would pass the monthly spend limit for this {subject}: "
134
+ "{observed} units this month against a limit of {limit}"
135
+ ),
136
+ },
137
+ }
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class CapPolicy:
142
+ """Trusted governor caps; callers may tighten but never raise policy maxima.
143
+
144
+ Same shape and same promise as :class:`pipeline.ResourceLimits`: a frozen dataclass whose
145
+ ``__post_init__`` refuses anything outside ``1..ceiling``, naming the field it refused. The
146
+ type check is ``type(value) is not int`` rather than ``isinstance`` on purpose — ``bool`` is a
147
+ subclass of ``int``, and ``max_concurrent_runs=True`` is a configuration mistake, not a cap.
148
+
149
+ ``max_runs_per_month`` and ``max_monthly_cost_units`` are counted from the durable store, so
150
+ they bind across processes wherever one is configured. ``max_concurrent_runs`` does not, and
151
+ the reason is structural: in-flight slots are held in memory and are deliberately never
152
+ persisted (:class:`CapStore`), so a ledger can only see what its own process holds. Under a
153
+ topology where each run is its own process — a Cloud Run job execution, which is where this
154
+ product's admission gate runs — every ledger sees one run in flight, and a limit of at least
155
+ one can never be reached. Treat this field as a per-process limit, not a platform-wide limit.
156
+ """
157
+
158
+ max_runs_per_month: int = DEFAULT_RUNS_PER_MONTH
159
+ max_concurrent_runs: int = DEFAULT_CONCURRENT_RUNS
160
+ max_monthly_cost_units: int = DEFAULT_MONTHLY_COST_UNITS
161
+
162
+ def __post_init__(self) -> None:
163
+ values = {
164
+ "max_runs_per_month": (self.max_runs_per_month, MAX_RUNS_PER_MONTH),
165
+ "max_concurrent_runs": (self.max_concurrent_runs, MAX_CONCURRENT_RUNS),
166
+ "max_monthly_cost_units": (self.max_monthly_cost_units, MAX_MONTHLY_COST_UNITS),
167
+ }
168
+ for name, (value, maximum) in values.items():
169
+ if type(value) is not int or value < 1 or value > maximum:
170
+ raise ValueError(f"{name} must be between 1 and {maximum}")
171
+
172
+ def tighten(
173
+ self,
174
+ *,
175
+ max_runs_per_month: int | None = None,
176
+ max_concurrent_runs: int | None = None,
177
+ max_monthly_cost_units: int | None = None,
178
+ ) -> CapPolicy:
179
+ """Return a policy no wider than this one; refuse any override that widens a field.
180
+
181
+ Tightening is the only direction a caller may move a cap. An override equal to the current
182
+ value is accepted (it is not a widening); an override above it raises, naming the field and
183
+ the value it would have exceeded.
184
+ """
185
+
186
+ overrides = {
187
+ "max_runs_per_month": max_runs_per_month,
188
+ "max_concurrent_runs": max_concurrent_runs,
189
+ "max_monthly_cost_units": max_monthly_cost_units,
190
+ }
191
+ chosen: dict[str, int] = {}
192
+ for name, value in overrides.items():
193
+ if value is None:
194
+ continue
195
+ current: int = getattr(self, name)
196
+ if type(value) is not int or value < 1:
197
+ raise ValueError(f"{name} must be an integer of at least 1")
198
+ if value > current:
199
+ raise ValueError(f"{name} may be tightened but never raised above {current}")
200
+ chosen[name] = value
201
+ return replace(self, **chosen)
202
+
203
+
204
+ def utc_calendar_month_bounds(now: datetime) -> tuple[datetime, datetime]:
205
+ """Return the first instant of ``now``'s UTC month and the first instant of the next one.
206
+
207
+ The coupling this function exists to hold: ``mostlyright-cloud/dashboard/lib/usage.ts`` counts
208
+ a key's usage over the current UTC calendar month — never a rolling 30-day window, never the
209
+ server's local zone — so that the same organization gets the same answer queried from anywhere.
210
+ This function must produce that same window. If it drifts, nothing raises on either side: the
211
+ harness and the dashboard simply report different numbers for the same key, and the first
212
+ person to notice is a customer disputing a bill. The other half of this contract lives in
213
+ ``mostlyright-cloud/dashboard/lib/usage.ts``; change neither without changing both.
214
+
215
+ The returned bounds are half-open: ``start <= instant < end``. ``now`` must be timezone-aware,
216
+ so that a local time can never be silently read as UTC and shift the window by up to a day.
217
+ """
218
+
219
+ if not isinstance(now, datetime):
220
+ raise ValueError("a usage window is computed from a datetime")
221
+ if now.tzinfo is None or now.tzinfo.utcoffset(now) is None:
222
+ raise ValueError("a usage window needs a timezone-aware datetime, not a local time")
223
+
224
+ instant = now.astimezone(UTC)
225
+ start = datetime(instant.year, instant.month, 1, tzinfo=UTC)
226
+ if instant.month == 12:
227
+ end = datetime(instant.year + 1, 1, 1, tzinfo=UTC)
228
+ else:
229
+ end = datetime(instant.year, instant.month + 1, 1, tzinfo=UTC)
230
+ return start, end
231
+
232
+
233
+ class CapExceeded(RuntimeError):
234
+ """A cap was reached, so the run was not started. This is the product working.
235
+
236
+ A cap breach is an expected operating condition. Report it without retrying or paging.
237
+
238
+ The message is plain language, safe to show the person holding the key. It names the cap that
239
+ was reached and the two numbers behind it, and nothing else: no key, no internal identifier.
240
+ """
241
+
242
+ def __init__(self, cap_name: str, *, observed: int, limit: int, message: str) -> None:
243
+ super().__init__(message)
244
+ self.cap_name = cap_name
245
+ self.observed = observed
246
+ self.limit = limit
247
+
248
+
249
+ class CapLedgerError(RuntimeError):
250
+ """A reservation was used wrongly — committed twice, released twice, or left open.
251
+
252
+ Unlike :class:`CapExceeded`, this one IS a defect: it means calling code lost track of a slot
253
+ it was holding. It is raised loudly so that the leak is found in a test rather than in a month
254
+ of quietly shrinking capacity.
255
+ """
256
+
257
+
258
+ class CapStoreError(RuntimeError):
259
+ """The durable counters could not be trusted, so nothing may be decided from them.
260
+
261
+ A store that cannot be read is not an empty store. Treating an unreadable, truncated, or
262
+ non-canonical file as "no usage yet" would hand every key a fresh monthly allowance at the
263
+ moment the file broke, which is the failure a cap exists to prevent. Callers on an admission
264
+ path must turn this into a refusal, never into an admission.
265
+ """
266
+
267
+
268
+ def _checked_key_id(value: Any) -> str:
269
+ if not isinstance(value, str) or KEY_ID_PATTERN.match(value) is None:
270
+ raise ValueError(
271
+ "a key id must be 16 lowercase hex characters, the value cloud's hashKeyId returns, "
272
+ "and never a raw key"
273
+ )
274
+ return value
275
+
276
+
277
+ def _checked_units(value: Any, name: str) -> int:
278
+ if type(value) is not int or value < 0:
279
+ raise ValueError(f"{name} must be a whole number of cost units, zero or more")
280
+ return value
281
+
282
+
283
+ @dataclass(frozen=True)
284
+ class UsageRecord:
285
+ """What one tenant used in one window: a coordinate, never a credential.
286
+
287
+ ``key_id`` is a 16-lowercase-hex tenant coordinate, and the field name is historical rather
288
+ than descriptive — it is kept because it is inside the persisted :data:`CAP_STORE_SCHEMA` and
289
+ renaming it would orphan every store already written. Its derivation is the one
290
+ :func:`key_seam.hash_key_id` uses — the first 8 bytes of a SHA-256 digest, hex-encoded, which
291
+ is also the shape cloud stores in ``usage_events.key_id`` — but **the shape is not the
292
+ subject.** What was hashed is the caller's choice and nothing here can recover it: the shape
293
+ check in :data:`KEY_ID_PATTERN` accepts a workspace hash and an API key id equally, which is
294
+ exactly why the subject is stated in prose and declared by :class:`CapLedger`'s ``subject``.
295
+
296
+ On the hosted admission path — the only production writer — the subject is the job's
297
+ ``workspace_id`` (``hosted_bootstrap._tenant_coordinate``), so the records in a production cap
298
+ store are per WORKSPACE. They are a spend control, not the billing attribution record, and they
299
+ do not line up with cloud's ``usage_events.key_id`` rows, which are per API key. Comparing a
300
+ number here with the cloud dashboard's per-key usage page holds only for a caller that files
301
+ genuine key ids, and no caller in this repository does.
302
+
303
+ A raw credential must never be stored, logged, or serialized here. ``key_seam`` is deliberately
304
+ NOT imported for this: the two modules stay independent, and :data:`KEY_ID_PATTERN` refuses a
305
+ raw key outright because no credential this product mints is 16 lowercase hex characters.
306
+
307
+ ``window_start`` is the first instant of the record's UTC calendar month, from
308
+ :func:`utc_calendar_month_bounds`, which is the same window
309
+ ``mostlyright-cloud/dashboard/lib/usage.ts`` counts over. The window matches; whether the
310
+ subjects match is the paragraph above.
311
+
312
+ ``runs`` and ``cost_units`` mean different things in the two places this record appears, and
313
+ the difference is stated rather than left to be inferred. Returned by :meth:`CapLedger.usage`
314
+ they are COMMITTED: work that finished. Read from or written to a :class:`CapStore` they are
315
+ CHARGED: every run admitted this month, including runs still in flight, because that is the
316
+ number an admission decision has to be made against.
317
+ """
318
+
319
+ key_id: str
320
+ window_start: datetime
321
+ runs: int
322
+ cost_units: int
323
+
324
+ def __post_init__(self) -> None:
325
+ _checked_key_id(self.key_id)
326
+ if not isinstance(self.window_start, datetime) or self.window_start.tzinfo is None:
327
+ raise ValueError("window_start must be a timezone-aware datetime")
328
+ _checked_units(self.runs, "runs")
329
+ _checked_units(self.cost_units, "cost_units")
330
+
331
+ def to_dict(self) -> dict[str, Any]:
332
+ """Return the record as plain data. Contains the key id; never contains a key."""
333
+
334
+ return {
335
+ "key_id": self.key_id,
336
+ "window_start": self.window_start.astimezone(UTC).isoformat(),
337
+ "runs": self.runs,
338
+ "cost_units": self.cost_units,
339
+ }
340
+
341
+
342
+ class CapStore(Protocol):
343
+ """Durable backing for a ledger's charged counters, keyed by key id.
344
+
345
+ What crosses this seam is a key's CHARGED total for the month: every run that has been admitted,
346
+ whether or not it has finished. What does not cross it is the concurrency slot — the fact that
347
+ some run is in flight right now. The two are separated on purpose, because they fail in
348
+ opposite directions:
349
+
350
+ * A monthly charge that outlives the process that made it is CONSERVATIVE. An execution that
351
+ dies between admission and its final accounting has still consumed a run, and leaving that
352
+ run counted is the safe reading. The charge is also self-limiting: it expires with the month.
353
+ * A concurrency slot that outlives its process is UNSAFE in the other direction. Nothing would
354
+ be left to give it back, so a crashed run would consume capacity forever.
355
+
356
+ So charges persist and slots do not, and the honest consequence of the second half is stated on
357
+ :attr:`CapPolicy.max_concurrent_runs` and in :class:`CapLedger`: concurrency is bounded inside
358
+ one process, never across processes.
359
+
360
+ Charging at ADMISSION rather than at completion is what makes a monthly allowance mean anything
361
+ when executions overlap. A ledger that decided from the store and only wrote when the run
362
+ finished would admit every execution that started before the first one finished: each reads the
363
+ same under-limit number, each is admitted, and the file afterwards records more runs than the
364
+ limit allows. Reserve the worst case, admit only if it fits, correct the number afterwards.
365
+
366
+ :meth:`load` must raise :class:`CapStoreError` rather than return empty counters whenever the
367
+ stored state cannot be trusted.
368
+
369
+ :meth:`lock` is not optional and not an optimization. A ledger's commit is a read-modify-write
370
+ over shared state, and the deployment this store exists for — every execution of one Cloud Run
371
+ job pointed at one shared path — runs that sequence from several processes at once. Without a
372
+ lock that excludes other WRITERS, not merely other threads, two executions each read the same
373
+ counters, each add their own run, and the second write erases the first: the run allowance
374
+ silently stops counting, which is the one thing this module exists to do. The lock must be held
375
+ across the whole load-mutate-save sequence, so it is a context manager rather than a pair of
376
+ calls.
377
+ """
378
+
379
+ def load(self) -> dict[str, UsageRecord]: ...
380
+
381
+ def save(self, records: Mapping[str, UsageRecord]) -> None: ...
382
+
383
+ def lock(self) -> AbstractContextManager[None]: ...
384
+
385
+
386
+ class FileCapStore:
387
+ """A cap store backed by one file, written whole and replaced atomically.
388
+
389
+ The file is serialized with :func:`canonical.canonical_json_bytes` and read back with
390
+ :func:`canonical.parse_canonical_json`, so a file whose bytes are not the canonical
391
+ representation of their own content — a hand-edit, a partial write from a different writer, a
392
+ re-serialization by another tool — is refused rather than acted on.
393
+
394
+ Writing goes to a sibling temporary file in the same directory and then :func:`os.replace`,
395
+ which is atomic within a filesystem: a concurrent reader observes either the whole previous
396
+ state or the whole new one, never a half-written ledger. The temporary file is a sibling, not a
397
+ file in the system temporary directory, because a rename across filesystems is not atomic.
398
+
399
+ An atomic write is not enough on its own, and the difference is the whole reason :meth:`lock`
400
+ exists. :func:`os.replace` makes one write indivisible; it does nothing for a read-modify-write
401
+ performed by two processes at once, where both read the same counters and the second write
402
+ replaces rather than extends the first. :meth:`lock` takes an exclusive :func:`fcntl.flock` on a
403
+ SIDECAR file — ``<store>.lock`` — and the sidecar is load-bearing: this store replaces its own
404
+ file on every write, so a lock taken on the store file itself would be held on an inode that the
405
+ next writer no longer opens. The sidecar is created once and never replaced.
406
+
407
+ **What the storage has to be.** Both mechanisms are POSIX filesystem behaviours, so the shared
408
+ volume this store is pointed at has to be a POSIX filesystem that honours them — a Filestore or
409
+ other NFS volume with locking, or a persistent disk. A Cloud Storage bucket mounted with gcsfuse
410
+ is NOT such a volume: it implements neither an atomic rename nor advisory locking, so both the
411
+ ``os.replace`` reasoning above and the exclusion below are void there. That is a deployment
412
+ requirement, stated here because nothing in this file can detect the difference at runtime.
413
+
414
+ :meth:`save` on its own takes no lock: it is the whole-file write half of a sequence whose
415
+ caller (:class:`CapLedger`) holds the lock across the load and the mutation too. Calling it
416
+ directly is a deliberate act — seeding an empty store before any execution is pointed at it —
417
+ and there is nothing to exclude at that moment.
418
+
419
+ A path that does not exist is refused unless the caller constructs the store with
420
+ ``create_if_missing=True``, which is how a new store is deliberately seeded::
421
+
422
+ FileCapStore(path, create_if_missing=True).save({})
423
+
424
+ The default is the strict one on purpose. On an admission path, "the counter file is gone" and
425
+ "this tenant has used nothing this month" must not be the same answer: a store that resets
426
+ itself when its file disappears gives an unbounded allowance to whoever can delete it.
427
+ """
428
+
429
+ def __init__(self, path: str | os.PathLike[str], *, create_if_missing: bool = False) -> None:
430
+ self._path = Path(path)
431
+ self._create_if_missing = bool(create_if_missing)
432
+
433
+ @property
434
+ def path(self) -> Path:
435
+ return self._path
436
+
437
+ @property
438
+ def lock_path(self) -> Path:
439
+ """The sidecar this store's writer lock is taken on. Never replaced, never read."""
440
+
441
+ return self._path.with_name(self._path.name + LOCK_FILE_SUFFIX)
442
+
443
+ @contextmanager
444
+ def lock(self) -> Iterator[None]:
445
+ """Hold this store against every other writer for one read-modify-write.
446
+
447
+ Exclusion is by :func:`fcntl.flock` on :attr:`lock_path`, which excludes other PROCESSES —
448
+ the thing a :class:`threading.Lock` cannot do and the thing a shared cap store needs, since
449
+ every execution of a Cloud Run job is its own process. The lock is advisory, so it binds
450
+ only writers that take it; every writer inside this product does, through
451
+ :class:`CapLedger`.
452
+
453
+ The wait is unbounded on purpose. The critical section is one small read, one arithmetic
454
+ step and one write, and a timeout here would have to choose between refusing a run that is
455
+ within its allowance and admitting one that is not. A holder that dies releases the lock:
456
+ the kernel drops it when the descriptor closes.
457
+ """
458
+
459
+ try:
460
+ descriptor = os.open(self.lock_path, os.O_RDWR | os.O_CREAT | os.O_CLOEXEC, 0o600)
461
+ except OSError as error:
462
+ raise CapStoreError("the cap store could not be locked for writing") from error
463
+ try:
464
+ try:
465
+ fcntl.flock(descriptor, fcntl.LOCK_EX)
466
+ except OSError as error:
467
+ raise CapStoreError("the cap store could not be locked for writing") from error
468
+ try:
469
+ yield
470
+ finally:
471
+ fcntl.flock(descriptor, fcntl.LOCK_UN)
472
+ finally:
473
+ os.close(descriptor)
474
+
475
+ def load(self) -> dict[str, UsageRecord]:
476
+ """Return the persisted counters, or refuse if they cannot be trusted."""
477
+
478
+ try:
479
+ raw = self._path.read_bytes()
480
+ except FileNotFoundError as error:
481
+ if self._create_if_missing:
482
+ return {}
483
+ raise CapStoreError(
484
+ "the cap store file does not exist, so no allowance can be counted from it"
485
+ ) from error
486
+ except OSError as error:
487
+ raise CapStoreError("the cap store file could not be read") from error
488
+
489
+ try:
490
+ value = parse_canonical_json(raw)
491
+ except CanonicalJSONError as error:
492
+ raise CapStoreError("the cap store file is not canonical JSON") from error
493
+ return self._records(value)
494
+
495
+ def save(self, records: Mapping[str, UsageRecord]) -> None:
496
+ """Replace the stored counters with ``records`` in one atomic step."""
497
+
498
+ payload = {
499
+ "schema_version": CAP_STORE_SCHEMA,
500
+ "keys": {
501
+ _checked_key_id(key): self._checked_record(key, record).to_dict()
502
+ for key, record in records.items()
503
+ },
504
+ }
505
+ raw = canonical_json_bytes(payload)
506
+ directory = self._path.parent
507
+ try:
508
+ descriptor, temporary = tempfile.mkstemp(
509
+ dir=str(directory),
510
+ prefix=f".{self._path.name}.",
511
+ suffix=".partial",
512
+ )
513
+ except OSError as error:
514
+ raise CapStoreError("the cap store file could not be written") from error
515
+ try:
516
+ with os.fdopen(descriptor, "wb") as handle:
517
+ handle.write(raw)
518
+ handle.flush()
519
+ os.fsync(handle.fileno())
520
+ os.replace(temporary, self._path)
521
+ except OSError as error:
522
+ Path(temporary).unlink(missing_ok=True)
523
+ raise CapStoreError("the cap store file could not be written") from error
524
+
525
+ def _records(self, value: Any) -> dict[str, UsageRecord]:
526
+ if not isinstance(value, dict) or set(value) != {"schema_version", "keys"}:
527
+ raise CapStoreError("the cap store file is not a cap store")
528
+ if value["schema_version"] != CAP_STORE_SCHEMA:
529
+ raise CapStoreError("the cap store file was written by a different store version")
530
+ keys = value["keys"]
531
+ if not isinstance(keys, dict):
532
+ raise CapStoreError("the cap store file does not hold per-key counters")
533
+
534
+ records: dict[str, UsageRecord] = {}
535
+ for key, item in keys.items():
536
+ if not isinstance(item, dict) or set(item) != {
537
+ "key_id",
538
+ "window_start",
539
+ "runs",
540
+ "cost_units",
541
+ }:
542
+ raise CapStoreError("a cap store entry does not have the exact counter fields")
543
+ try:
544
+ record = UsageRecord(
545
+ key_id=item["key_id"],
546
+ window_start=_stored_window_start(item["window_start"]),
547
+ runs=item["runs"],
548
+ cost_units=item["cost_units"],
549
+ )
550
+ except ValueError as error:
551
+ raise CapStoreError("a cap store entry is not a usable usage record") from error
552
+ records[key] = self._checked_record(key, record)
553
+ return records
554
+
555
+ @staticmethod
556
+ def _checked_record(key: Any, record: Any) -> UsageRecord:
557
+ if not isinstance(record, UsageRecord) or record.key_id != key:
558
+ raise CapStoreError("a cap store entry is filed under a different key id than it names")
559
+ return record
560
+
561
+
562
+ def _stored_window_start(value: Any) -> datetime:
563
+ if not isinstance(value, str):
564
+ raise CapStoreError("a stored window start must be text")
565
+ try:
566
+ parsed = datetime.fromisoformat(value)
567
+ except ValueError as error:
568
+ raise CapStoreError("a stored window start is not an ISO 8601 instant") from error
569
+ if parsed.tzinfo is None or parsed.tzinfo.utcoffset(parsed) is None:
570
+ raise CapStoreError("a stored window start must carry a timezone")
571
+ start, _ = utc_calendar_month_bounds(parsed)
572
+ if parsed.astimezone(UTC) != start:
573
+ raise CapStoreError("a stored window start is not the first instant of a UTC month")
574
+ return start
575
+
576
+
577
+ class Reservation:
578
+ """A held slot: one run's claim on a key's allowance, until it is committed or released.
579
+
580
+ Use it as a context manager. Leaving the block with an exception releases the slot, so a run
581
+ that raises can never leak capacity; leaving it without committing is a defect and raises
582
+ :class:`CapLedgerError` after releasing the slot, so the mistake is loud but not expensive.
583
+ """
584
+
585
+ __slots__ = (
586
+ "_ledger",
587
+ "_settled",
588
+ "estimated_cost_units",
589
+ "key_id",
590
+ "reservation_id",
591
+ "window_start",
592
+ )
593
+
594
+ def __init__(
595
+ self,
596
+ ledger: CapLedger,
597
+ *,
598
+ reservation_id: int,
599
+ key_id: str,
600
+ window_start: datetime,
601
+ estimated_cost_units: int,
602
+ ) -> None:
603
+ self._ledger = ledger
604
+ self.reservation_id = reservation_id
605
+ self.key_id = key_id
606
+ self.window_start = window_start
607
+ self.estimated_cost_units = estimated_cost_units
608
+ self._settled = False
609
+
610
+ @property
611
+ def settled(self) -> bool:
612
+ """True once this reservation has been committed or released."""
613
+
614
+ return self._settled
615
+
616
+ def commit(self, *, actual_cost_units: int) -> None:
617
+ """Record the run and what it actually cost, then give the slot back."""
618
+
619
+ self._ledger.commit(self, actual_cost_units=actual_cost_units)
620
+
621
+ def release(self) -> None:
622
+ """Give the slot back without recording a run."""
623
+
624
+ self._ledger.release(self)
625
+
626
+ def __enter__(self) -> Reservation:
627
+ return self
628
+
629
+ def __exit__(
630
+ self,
631
+ exc_type: type[BaseException] | None,
632
+ exc: BaseException | None,
633
+ traceback: TracebackType | None,
634
+ ) -> bool:
635
+ if self._settled:
636
+ return False
637
+ self._ledger.release(self)
638
+ if exc_type is None:
639
+ raise CapLedgerError(
640
+ "a reservation must be committed before its block ends; "
641
+ "the slot has been released and no run was recorded"
642
+ )
643
+ return False
644
+
645
+
646
+ @dataclass
647
+ class _KeyWindow:
648
+ """One key's charged counters for one UTC month, plus the runs it has in flight now.
649
+
650
+ ``runs`` and ``cost_units`` count what has been ADMITTED, not what has finished: a run is
651
+ charged the moment its reservation is granted (see :class:`CapLedger`). ``reservations`` holds
652
+ the subset of that charge belonging to runs this process still has open.
653
+ """
654
+
655
+ window_start: datetime
656
+ runs: int = 0
657
+ cost_units: int = 0
658
+ reservations: dict[int, Reservation] = None # type: ignore[assignment]
659
+
660
+ def __post_init__(self) -> None:
661
+ if self.reservations is None:
662
+ self.reservations = {}
663
+
664
+
665
+ class CapLedger:
666
+ """Concurrency-safe reservation and final accounting for one tenant's caps.
667
+
668
+ Modeled on :class:`agent_runtime.BudgetLedger`: reserve first against the worst case, do the
669
+ work, then account for what actually happened. Every counter read and every counter write
670
+ happens under one lock, so two threads racing to take the last slot cannot both win.
671
+
672
+ Counters are per coordinate and per UTC calendar month. When a reserve arrives whose ``now``
673
+ falls in a later month than the stored window, the monthly counters roll over rather than
674
+ accumulating across months. Runs already in flight are carried across that roll, because
675
+ concurrency is an instantaneous limit and not a monthly allowance — a run that started in
676
+ August still occupies a slot at one second past midnight in September. The roll only ever goes
677
+ forward: a ``now`` that falls in an EARLIER month than the window already adopted is decided and
678
+ charged against that adopted window, because zeroing a month whose runs are already committed
679
+ would hand its allowance out twice (:meth:`_window_locked`).
680
+
681
+ Counters live in memory unless a ``store`` is supplied. With one, every decision and every
682
+ mutation is a read-modify-write performed under the store's own inter-process lock: the
683
+ persisted counters are re-read inside that lock, the decision or the mutation is made on what
684
+ was just read, and the result is written back before the lock is dropped. That is what makes
685
+ the monthly allowance hold across the executions of one Cloud Run job pointed at one shared
686
+ path. A ledger that read its counters once at construction and wrote the whole file back from
687
+ that snapshot would lose every commit another execution made in between — including commits for
688
+ OTHER tenants, whose counters live in the same file — so the snapshot is deliberately never the
689
+ thing written from.
690
+
691
+ Reading under the lock is necessary and is not sufficient, and the difference is where an
692
+ allowance is actually won or lost. The run is charged at ADMISSION — inside the same lock that
693
+ read the counters the admission was decided from — not when it finishes. Deciding under the
694
+ lock but writing at completion still admits every execution that starts before the first one
695
+ ends: each takes the lock in turn, each reads the same under-limit number because no charge has
696
+ landed yet, and each is admitted. The overshoot is exactly the number of executions running at
697
+ once, silently, with the file afterwards recording more runs than the limit allows.
698
+
699
+ So ``runs`` and ``cost_units`` on a window, and in the store, are CHARGED totals: every run
700
+ admitted this month, finished or not. :meth:`commit` moves no run count — the run was already
701
+ counted — and only replaces the held estimate with the real spend. :meth:`release` refunds the
702
+ charge, which is what keeps a fault between admission and exec from spending a month's
703
+ allowance. :meth:`usage` reports committed work, subtracting this ledger's open reservations.
704
+
705
+ A process that dies between admission and either outcome leaves its run charged. That is the
706
+ intended direction: the run was admitted, and a charge that outlives its process expires with
707
+ the month rather than lasting forever the way a leaked concurrency slot would.
708
+
709
+ Without a store, this ledger counts only what this process did: that bounds concurrency within
710
+ a process but cannot enforce a monthly allowance across processes, and a caller relying on it
711
+ has to say so plainly rather than imply an enforcement that is not there.
712
+
713
+ Concurrency is the cap this class cannot make durable, and the reason is structural rather than
714
+ unfinished: in-flight reservations are deliberately not persisted (:class:`CapStore`), because a
715
+ process that dies holding a slot would otherwise consume it forever. ``max_concurrent_runs``
716
+ therefore bounds what one PROCESS holds at once. In a topology where each run is its own
717
+ process, it does not bind across requests.
718
+
719
+ **What a coordinate stands for, and why a refusal has to be told.** The ``key_id`` argument on
720
+ every method here is a 16-lowercase-hex coordinate whose SHAPE is checked and whose SUBJECT is
721
+ not: a workspace hash and an API key id are indistinguishable to :data:`KEY_ID_PATTERN`. The
722
+ hosted admission path files a WORKSPACE — ``hosted_bootstrap._tenant_coordinate`` hashes the
723
+ validated job's ``workspace_id``, and the customer API key never reaches that process at all —
724
+ so the argument's name is historical and does not describe what production puts in it. Because
725
+ a :class:`CapExceeded` message is read by a customer, it must name what was actually counted,
726
+ and this class cannot work that out from the coordinate. The caller declares it with
727
+ ``subject``, one of :data:`CAP_SUBJECTS`, and every refusal below says "this ``subject``".
728
+ Pointing a refused customer at a per-key usage page for counters that are per workspace is how
729
+ a refusal turns into a misdiagnosis.
730
+
731
+ **What was counted, and what this noun does not fix.** ``work_unit``, one of
732
+ :data:`CAP_WORK_UNITS`, is the same declaration one noun over: it says whether the counted work
733
+ item was a build run or a data read, so a refused read is refused in the words a read deserves.
734
+ It changes the WORDING and never the NUMBER. The ceiling is still
735
+ ``MAX_RUNS_PER_MONTH = 10_000`` work items per calendar month per coordinate, whatever the work
736
+ item is called, and for a production query API 10,000 reads per month is low. Raising it is not
737
+ a wording decision and is not taken here: the same constant bounds the build-side allowance, so
738
+ raising it weakens that cap too. Separate read counters require a :data:`CAP_STORE_SCHEMA`
739
+ migration. The current limit is 10,000 reads per key per month.
740
+ """
741
+
742
+ def __init__(
743
+ self,
744
+ policy: CapPolicy | None = None,
745
+ *,
746
+ store: CapStore | None = None,
747
+ subject: str = "workspace",
748
+ work_unit: str = "run",
749
+ ) -> None:
750
+ if policy is None:
751
+ policy = CapPolicy()
752
+ if not isinstance(policy, CapPolicy):
753
+ raise ValueError("a cap ledger needs a CapPolicy")
754
+ if subject not in CAP_SUBJECTS:
755
+ raise ValueError(
756
+ "a cap ledger's subject must name what its coordinates stand for, one of "
757
+ + ", ".join(CAP_SUBJECTS)
758
+ )
759
+ if work_unit not in CAP_WORK_UNITS:
760
+ raise ValueError(
761
+ "a cap ledger's work unit must name what its counters count, one of "
762
+ + ", ".join(CAP_WORK_UNITS)
763
+ )
764
+ if store is not None and not callable(getattr(store, "lock", None)):
765
+ # Refused here rather than degraded to "no locking": a store that cannot exclude other
766
+ # writers cannot hold an allowance, and the failure of one that silently does not is
767
+ # invisible until a tenant's counters are already gone.
768
+ raise ValueError("a cap store must offer an inter-process lock")
769
+ self._policy = policy
770
+ self._subject = subject
771
+ self._work_unit = work_unit
772
+ self._sentences = _REFUSAL_SENTENCES[work_unit]
773
+ self._lock = threading.Lock()
774
+ self._next_id = 1
775
+ self._windows: dict[str, _KeyWindow] = {}
776
+ self._reservations: dict[int, Reservation] = {}
777
+ self._store = store
778
+ if store is not None:
779
+ # A load failure propagates as CapStoreError. Constructing a ledger over counters that
780
+ # cannot be read has to fail here, where the caller can still refuse the run, rather
781
+ # than succeed with empty counters and admit everything.
782
+ with store.lock():
783
+ persisted = store.load()
784
+ for key, record in persisted.items():
785
+ self._windows[key] = _KeyWindow(
786
+ window_start=record.window_start,
787
+ runs=record.runs,
788
+ cost_units=record.cost_units,
789
+ )
790
+
791
+ @property
792
+ def policy(self) -> CapPolicy:
793
+ return self._policy
794
+
795
+ @property
796
+ def subject(self) -> str:
797
+ """What this ledger's coordinates stand for, and what its refusals name."""
798
+
799
+ return self._subject
800
+
801
+ @property
802
+ def work_unit(self) -> str:
803
+ """What this ledger's counters count, and the noun its refusals use for it."""
804
+
805
+ return self._work_unit
806
+
807
+ def reserve(
808
+ self,
809
+ key_id: str,
810
+ *,
811
+ now: datetime,
812
+ estimated_cost_units: int = 0,
813
+ ) -> Reservation:
814
+ """Hold a slot for one run, or refuse with the cap that stopped it named.
815
+
816
+ The estimate is what the run may cost at worst; it is held against the monthly spend
817
+ limit until :meth:`commit` replaces it with the real number.
818
+
819
+ With a store, the persisted counters are re-read inside the store's writer lock before any
820
+ cap is evaluated, and this run's charge is written back before that lock is dropped. Both
821
+ halves are needed: the read makes the decision current, and the write makes it binding on
822
+ the next execution to ask. See :class:`CapLedger` for why deciding under the lock and
823
+ writing at completion would still admit every execution that overlaps the first.
824
+ """
825
+
826
+ key = _checked_key_id(key_id)
827
+ estimate = _checked_units(estimated_cost_units, "estimated_cost_units")
828
+ window_start, _ = utc_calendar_month_bounds(now)
829
+
830
+ with self._store_lock(), self._lock:
831
+ self._refresh_charged_locked()
832
+ window = self._window_locked(key, window_start)
833
+ in_flight = len(window.reservations)
834
+
835
+ # ``window.runs`` and ``window.cost_units`` are CHARGED totals: every run admitted this
836
+ # month, this ledger's open reservations included, and — with a store — every other
837
+ # process's too, because the charge is written at admission. So this run is the only
838
+ # thing added here. Adding the in-flight count again would charge this process's own
839
+ # open runs twice.
840
+ runs_observed = window.runs + 1
841
+ if runs_observed > self._policy.max_runs_per_month:
842
+ raise CapExceeded(
843
+ "runs_per_month",
844
+ observed=runs_observed,
845
+ limit=self._policy.max_runs_per_month,
846
+ message=self._sentences["runs_per_month"].format(
847
+ subject=self._subject,
848
+ observed=runs_observed,
849
+ limit=self._policy.max_runs_per_month,
850
+ ),
851
+ )
852
+
853
+ concurrent_observed = in_flight + 1
854
+ if concurrent_observed > self._policy.max_concurrent_runs:
855
+ raise CapExceeded(
856
+ "concurrent_runs",
857
+ observed=concurrent_observed,
858
+ limit=self._policy.max_concurrent_runs,
859
+ message=self._sentences["concurrent_runs"].format(
860
+ subject=self._subject,
861
+ observed=concurrent_observed,
862
+ limit=self._policy.max_concurrent_runs,
863
+ ),
864
+ )
865
+
866
+ cost_observed = window.cost_units + estimate
867
+ if cost_observed > self._policy.max_monthly_cost_units:
868
+ raise CapExceeded(
869
+ "monthly_cost_units",
870
+ observed=cost_observed,
871
+ limit=self._policy.max_monthly_cost_units,
872
+ message=self._sentences["monthly_cost_units"].format(
873
+ subject=self._subject,
874
+ observed=cost_observed,
875
+ limit=self._policy.max_monthly_cost_units,
876
+ ),
877
+ )
878
+
879
+ # The charge lands HERE, inside the same store lock that read the counters this
880
+ # decision was made from, and before any caller can act on the admission. That is the
881
+ # whole of what makes the allowance hold when executions overlap: the next process to
882
+ # take this lock reads a number that already includes this run, so it cannot be handed
883
+ # the same slot. Written at commit instead, the charge would arrive after every
884
+ # concurrent execution had already been admitted against the number it replaced.
885
+ window.runs += 1
886
+ window.cost_units += estimate
887
+ try:
888
+ self._persist_locked()
889
+ except BaseException:
890
+ # An admission that could not be recorded is not an admission. The charge is backed
891
+ # out and the refusal reaches the caller, who fails closed on it.
892
+ window.runs -= 1
893
+ window.cost_units -= estimate
894
+ raise
895
+
896
+ reservation = Reservation(
897
+ self,
898
+ reservation_id=self._next_id,
899
+ key_id=key,
900
+ # The window the charge LANDED in, which is the window above and not necessarily
901
+ # the one this ``now`` names: a reading behind the month the store already holds is
902
+ # decided and charged against that later window (see :meth:`_window_locked`).
903
+ # :meth:`commit` and :meth:`release` reach for the charge by this field, so a
904
+ # reservation that named the caller's month instead would refund nothing and would
905
+ # count its run a second time.
906
+ window_start=window.window_start,
907
+ estimated_cost_units=estimate,
908
+ )
909
+ self._next_id += 1
910
+ window.reservations[reservation.reservation_id] = reservation
911
+ self._reservations[reservation.reservation_id] = reservation
912
+ return reservation
913
+
914
+ def commit(self, reservation: Reservation, *, actual_cost_units: int) -> None:
915
+ """Record one finished run at what it actually cost and give its slot back.
916
+
917
+ The monthly totals move on committed cost, not on the estimate: an estimate that turned
918
+ out generous returns to the allowance the moment the run finishes.
919
+
920
+ If the month rolled over while the run was in flight, the run is recorded against the
921
+ window the ledger is now in. Counting it against a month that has already been reported
922
+ would let a long run land in a closed window; counting it now can only be conservative.
923
+
924
+ With a store, the whole sequence — re-read, add this run, write back — happens inside the
925
+ store's writer lock, so this run is added to what other executions have already committed
926
+ instead of replacing it.
927
+ """
928
+
929
+ actual = _checked_units(actual_cost_units, "actual_cost_units")
930
+ with self._store_lock(), self._lock:
931
+ self._refresh_charged_locked()
932
+ self._settle_locked(reservation)
933
+ window = self._windows[reservation.key_id]
934
+ if window.window_start == reservation.window_start:
935
+ # The run itself was charged at admission, so nothing is added to the run count
936
+ # here. Only the difference between what was held and what was really spent moves,
937
+ # which is how a generous estimate returns to the allowance the moment it is known.
938
+ window.cost_units = max(
939
+ 0, window.cost_units - reservation.estimated_cost_units + actual
940
+ )
941
+ else:
942
+ # The month rolled while the run was in flight. Its admission charge belongs to a
943
+ # window that is now closed and must not be reached back into, so the run is
944
+ # recorded in the window this ledger is in. That can only be conservative.
945
+ window.runs += 1
946
+ window.cost_units += actual
947
+ self._persist_locked()
948
+
949
+ def release(self, reservation: Reservation) -> None:
950
+ """Give a slot back without recording a run. The refused or failed run costs nothing.
951
+
952
+ This DOES write the store, because :meth:`reserve` charged the run there. A release is the
953
+ refund of that charge: the run count and the held estimate go back, so a run that was
954
+ admitted and then never happened does not spend a month's allowance. Without the refund the
955
+ allowance would ratchet down on every fault between admission and exec.
956
+
957
+ A refund only happens where the charge is still reachable — the same key, the same window.
958
+ If the month rolled while the run was in flight, the charge sits in a window that is closed
959
+ and is left alone rather than deducted from the new month's allowance.
960
+ """
961
+
962
+ with self._store_lock(), self._lock:
963
+ self._refresh_charged_locked()
964
+ self._settle_locked(reservation)
965
+ window = self._windows[reservation.key_id]
966
+ if window.window_start == reservation.window_start:
967
+ window.runs = max(0, window.runs - 1)
968
+ window.cost_units = max(0, window.cost_units - reservation.estimated_cost_units)
969
+ self._persist_locked()
970
+
971
+ def usage(self, key_id: str, *, now: datetime) -> UsageRecord:
972
+ """Return what this coordinate has committed in the UTC month containing ``now``.
973
+
974
+ A key nobody has used yet is an empty record, not an error: there is nothing exceptional
975
+ about a key that has done no work.
976
+
977
+ This reports committed work, while cap counters are charged
978
+ totals that also include runs still in flight. The difference is this ledger's own open
979
+ reservations, and they are subtracted here: a run that has been admitted but has not
980
+ finished is not a run this coordinate has used. Charges held by other processes cannot
981
+ be told apart from committed work in a shared file and are therefore included — the
982
+ conservative direction, and the only one available.
983
+ """
984
+
985
+ key = _checked_key_id(key_id)
986
+ window_start, _ = utc_calendar_month_bounds(now)
987
+ with self._store_lock(), self._lock:
988
+ self._refresh_charged_locked()
989
+ window = self._windows.get(key)
990
+ if window is None or window.window_start != window_start:
991
+ return UsageRecord(key_id=key, window_start=window_start, runs=0, cost_units=0)
992
+ held_runs = len(window.reservations)
993
+ held_cost = sum(held.estimated_cost_units for held in window.reservations.values())
994
+ return UsageRecord(
995
+ key_id=key,
996
+ window_start=window_start,
997
+ runs=max(0, window.runs - held_runs),
998
+ cost_units=max(0, window.cost_units - held_cost),
999
+ )
1000
+
1001
+ def runs_in_flight(self, key_id: str, *, now: datetime) -> int:
1002
+ """How many runs this coordinate is holding slots for right now."""
1003
+
1004
+ key = _checked_key_id(key_id)
1005
+ utc_calendar_month_bounds(now)
1006
+ with self._lock:
1007
+ window = self._windows.get(key)
1008
+ return 0 if window is None else len(window.reservations)
1009
+
1010
+ def _window_locked(self, key_id: str, window_start: datetime) -> _KeyWindow:
1011
+ """Return the window this decision is made in, rolling the month forward and never back.
1012
+
1013
+ The roll is one-directional on purpose, and the reason is the same one that makes
1014
+ :meth:`_refresh_charged_locked` skip a stored window older than this ledger's. Every
1015
+ execution of the job reads its own clock, and those clocks are independent. Around a UTC
1016
+ month boundary one of them reads the month that has just ended while the store already holds
1017
+ the new one, and a roll to that older reading would zero the counters, admit the run against
1018
+ an allowance that is already spent, and then write the zeroed window over the file — where
1019
+ it is what every other execution reads. The month's committed runs would be gone, silently,
1020
+ and its allowance handed out again.
1021
+
1022
+ So a reading older than the window already adopted decides against that adopted window
1023
+ instead: nothing is zeroed, nothing older is persisted, and the run is charged where the
1024
+ month's other runs are. A reading in a later month rolls the window, carrying the
1025
+ in-flight reservations across, because concurrency is an instantaneous limit and not a
1026
+ monthly allowance.
1027
+ """
1028
+
1029
+ window = self._windows.get(key_id)
1030
+ if window is None:
1031
+ window = _KeyWindow(window_start=window_start)
1032
+ self._windows[key_id] = window
1033
+ return window
1034
+ if window_start < window.window_start:
1035
+ return window
1036
+ if window.window_start != window_start:
1037
+ rolled = _KeyWindow(window_start=window_start)
1038
+ rolled.reservations = window.reservations
1039
+ self._windows[key_id] = rolled
1040
+ return rolled
1041
+ return window
1042
+
1043
+ @contextmanager
1044
+ def _store_lock(self) -> Iterator[None]:
1045
+ """Hold the store against other writers, or do nothing when there is no store.
1046
+
1047
+ Always entered BEFORE this ledger's own :class:`threading.Lock`, in every method that takes
1048
+ both. One order, everywhere, is what keeps two threads of the same process from each
1049
+ holding one lock and waiting for the other.
1050
+ """
1051
+
1052
+ if self._store is None:
1053
+ yield
1054
+ return
1055
+ with self._store.lock():
1056
+ yield
1057
+
1058
+ def _refresh_charged_locked(self) -> None:
1059
+ """Adopt the persisted charged counters, keeping this process's in-flight slots.
1060
+
1061
+ Called inside the store's writer lock, immediately before a cap is evaluated or a counter is
1062
+ moved, so that both act on what is on disk now rather than on what was there when this
1063
+ ledger was constructed. Only the counters are adopted; ``reservations`` belongs to this
1064
+ process and is left exactly as it is. The adopted numbers already include the charges this
1065
+ ledger made for its own open reservations, which is why nothing adds the in-flight count
1066
+ back on top of them.
1067
+
1068
+ Two cases are decided here rather than left implicit:
1069
+
1070
+ * A stored window OLDER than the one this ledger has already rolled into is ignored. Its
1071
+ counters belong to a month that is closed, and carrying them forward would spend a new
1072
+ month's allowance on last month's runs.
1073
+ * A key this ledger knows and the store does not keeps its in-memory counters. The
1074
+ conservative direction is the only safe one on an admission path: treating a key that
1075
+ vanished from the file as a key that has used nothing would hand out a fresh allowance to
1076
+ whoever can edit the file.
1077
+ """
1078
+
1079
+ if self._store is None:
1080
+ return
1081
+ for key, record in self._store.load().items():
1082
+ window = self._windows.get(key)
1083
+ if window is None:
1084
+ self._windows[key] = _KeyWindow(
1085
+ window_start=record.window_start,
1086
+ runs=record.runs,
1087
+ cost_units=record.cost_units,
1088
+ )
1089
+ continue
1090
+ if record.window_start < window.window_start:
1091
+ continue
1092
+ window.window_start = record.window_start
1093
+ window.runs = record.runs
1094
+ window.cost_units = record.cost_units
1095
+
1096
+ def _persist_locked(self) -> None:
1097
+ """Write the committed counters back, inside the lock that produced them.
1098
+
1099
+ Persisting under the same lock is what makes the file a snapshot of a real state rather
1100
+ than of a state that never existed: no other thread can move a counter between the last
1101
+ mutation and the write.
1102
+
1103
+ The whole map is written, which is safe only because :meth:`_refresh_charged_locked` has
1104
+ just replaced it with what the file holds, under the store's writer lock. Written from a
1105
+ stale map instead, this same call would delete other tenants' committed runs.
1106
+
1107
+ A key whose charged counters are both zero is left out rather than written as a row of
1108
+ zeroes. Absent and zero say the same thing to every reader of this file, and not writing
1109
+ the row keeps a store from growing a permanent entry for every key that was ever refunded.
1110
+ """
1111
+
1112
+ if self._store is None:
1113
+ return
1114
+ self._store.save(
1115
+ {
1116
+ key: UsageRecord(
1117
+ key_id=key,
1118
+ window_start=window.window_start,
1119
+ runs=window.runs,
1120
+ cost_units=window.cost_units,
1121
+ )
1122
+ for key, window in self._windows.items()
1123
+ if window.runs or window.cost_units
1124
+ }
1125
+ )
1126
+
1127
+ def _settle_locked(self, reservation: Reservation) -> None:
1128
+ if not isinstance(reservation, Reservation):
1129
+ raise CapLedgerError("a cap ledger settles reservations it issued, nothing else")
1130
+ held = self._reservations.get(reservation.reservation_id)
1131
+ if held is not reservation or reservation.settled:
1132
+ raise CapLedgerError("this reservation is not open on this ledger")
1133
+ del self._reservations[reservation.reservation_id]
1134
+ window = self._windows.get(reservation.key_id)
1135
+ if window is not None:
1136
+ window.reservations.pop(reservation.reservation_id, None)
1137
+ reservation._settled = True
1138
+
1139
+
1140
+ __all__ = [
1141
+ "CAP_STORE_SCHEMA",
1142
+ "CAP_SUBJECTS",
1143
+ "CAP_WORK_UNITS",
1144
+ "COST_UNIT_DESCRIPTION",
1145
+ "DEFAULT_CONCURRENT_RUNS",
1146
+ "DEFAULT_MONTHLY_COST_UNITS",
1147
+ "DEFAULT_RUNS_PER_MONTH",
1148
+ "KEY_ID_PATTERN",
1149
+ "LOCK_FILE_SUFFIX",
1150
+ "MAX_CONCURRENT_RUNS",
1151
+ "MAX_MONTHLY_COST_UNITS",
1152
+ "MAX_RUNS_PER_MONTH",
1153
+ "CapExceeded",
1154
+ "CapLedger",
1155
+ "CapLedgerError",
1156
+ "CapPolicy",
1157
+ "CapStore",
1158
+ "CapStoreError",
1159
+ "FileCapStore",
1160
+ "Reservation",
1161
+ "UsageRecord",
1162
+ "utc_calendar_month_bounds",
1163
+ ]