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.
- mostlyright/data_harness/__init__.py +158 -0
- mostlyright/data_harness/acquisition/__init__.py +55 -0
- mostlyright/data_harness/acquisition/http.py +2773 -0
- mostlyright/data_harness/acquisition/parsing.py +809 -0
- mostlyright/data_harness/acquisition/ranges.py +495 -0
- mostlyright/data_harness/acquisition/result_download.py +360 -0
- mostlyright/data_harness/acquisition/retention_admission.py +248 -0
- mostlyright/data_harness/acquisition/sandbox.py +4888 -0
- mostlyright/data_harness/acquisition/url_policy.py +530 -0
- mostlyright/data_harness/agent_runtime.py +2743 -0
- mostlyright/data_harness/assets/logo-ink.svg +31 -0
- mostlyright/data_harness/backends/__init__.py +28 -0
- mostlyright/data_harness/backends/pandas_backend.py +350 -0
- mostlyright/data_harness/backends/polars_backend.py +366 -0
- mostlyright/data_harness/backends/protocol.py +124 -0
- mostlyright/data_harness/backends/reference.py +83 -0
- mostlyright/data_harness/backends/registry.py +55 -0
- mostlyright/data_harness/backends/restrictions.py +126 -0
- mostlyright/data_harness/canonical.py +333 -0
- mostlyright/data_harness/catalog_job.py +625 -0
- mostlyright/data_harness/cli.py +5398 -0
- mostlyright/data_harness/contracts.py +53 -0
- mostlyright/data_harness/coordinator.py +1307 -0
- mostlyright/data_harness/deploy.py +924 -0
- mostlyright/data_harness/deploy_target.py +312 -0
- mostlyright/data_harness/deployment_evidence.py +1067 -0
- mostlyright/data_harness/event_presentation.py +576 -0
- mostlyright/data_harness/events.py +2152 -0
- mostlyright/data_harness/fast_delimited.py +239 -0
- mostlyright/data_harness/fleet.py +237 -0
- mostlyright/data_harness/formats.py +236 -0
- mostlyright/data_harness/governors.py +1163 -0
- mostlyright/data_harness/hosted_bootstrap.py +972 -0
- mostlyright/data_harness/hosted_crawler.py +1115 -0
- mostlyright/data_harness/hosted_crawler_container_smoke.py +351 -0
- mostlyright/data_harness/hosted_crawler_fetch.py +423 -0
- mostlyright/data_harness/hosted_crawler_job.py +1277 -0
- mostlyright/data_harness/hosted_crawler_protocol.py +676 -0
- mostlyright/data_harness/hosted_dataset.py +1500 -0
- mostlyright/data_harness/hosted_deploy.py +3037 -0
- mostlyright/data_harness/hosted_handoff.py +62 -0
- mostlyright/data_harness/hosted_ingestion_contract.py +504 -0
- mostlyright/data_harness/hosted_ingestion_job.py +356 -0
- mostlyright/data_harness/hosted_ingestion_job_smoke.py +40 -0
- mostlyright/data_harness/hosted_session_container_smoke.py +194 -0
- mostlyright/data_harness/hosted_session_worker.py +3554 -0
- mostlyright/data_harness/hosted_session_worker_job_smoke.py +46 -0
- mostlyright/data_harness/hosted_worker.py +6784 -0
- mostlyright/data_harness/ingestion/__init__.py +56 -0
- mostlyright/data_harness/ingestion/contracts.py +461 -0
- mostlyright/data_harness/ingestion/faults.py +42 -0
- mostlyright/data_harness/ingestion/gcs_store.py +1162 -0
- mostlyright/data_harness/ingestion/spool.py +130 -0
- mostlyright/data_harness/ingestion/store.py +885 -0
- mostlyright/data_harness/key_seam.py +434 -0
- mostlyright/data_harness/linux_process_boundary.py +262 -0
- mostlyright/data_harness/local_contracts.py +2880 -0
- mostlyright/data_harness/local_search/__init__.py +5 -0
- mostlyright/data_harness/local_search/build_index.py +1087 -0
- mostlyright/data_harness/local_search/contracts.py +920 -0
- mostlyright/data_harness/local_search/query_trace.py +266 -0
- mostlyright/data_harness/local_search/retrieval.py +700 -0
- mostlyright/data_harness/local_search/sealed.py +474 -0
- mostlyright/data_harness/local_search/service.py +784 -0
- mostlyright/data_harness/nbrender/CONTRACT.md +212 -0
- mostlyright/data_harness/nbrender/__init__.py +12 -0
- mostlyright/data_harness/nbrender/chrome.py +359 -0
- mostlyright/data_harness/nbrender/code_body.py +266 -0
- mostlyright/data_harness/nbrender/document.py +407 -0
- mostlyright/data_harness/nbrender/frame.py +275 -0
- mostlyright/data_harness/nbrender/interactive.py +337 -0
- mostlyright/data_harness/nbrender/markdown_body.py +477 -0
- mostlyright/data_harness/nbrender/mr_components.py +134 -0
- mostlyright/data_harness/nbrender/outputs_data.py +595 -0
- mostlyright/data_harness/nbrender/outputs_rich.py +906 -0
- mostlyright/data_harness/nbrender/outputs_source.py +260 -0
- mostlyright/data_harness/nbrender/outputs_stage.py +176 -0
- mostlyright/data_harness/nbrender/outputs_text.py +400 -0
- mostlyright/data_harness/nbrender/parse.py +394 -0
- mostlyright/data_harness/nbrender/status.py +40 -0
- mostlyright/data_harness/nbrender/tokens.py +1295 -0
- mostlyright/data_harness/notebook.py +1710 -0
- mostlyright/data_harness/offline.py +2049 -0
- mostlyright/data_harness/operation_registry.py +1007 -0
- mostlyright/data_harness/operator_setup.py +239 -0
- mostlyright/data_harness/pipeline.py +6428 -0
- mostlyright/data_harness/plan_graph.py +2026 -0
- mostlyright/data_harness/preparation/__init__.py +104 -0
- mostlyright/data_harness/preparation/contracts.py +1017 -0
- mostlyright/data_harness/preparation/engine.py +221 -0
- mostlyright/data_harness/preparation/errors.py +14 -0
- mostlyright/data_harness/preparation/gates.py +751 -0
- mostlyright/data_harness/preparation/joins.py +574 -0
- mostlyright/data_harness/preparation/profile.py +384 -0
- mostlyright/data_harness/preparation/table.py +217 -0
- mostlyright/data_harness/preparation/transforms.py +568 -0
- mostlyright/data_harness/progress_events.py +534 -0
- mostlyright/data_harness/readers/__init__.py +46 -0
- mostlyright/data_harness/readers/containers.py +963 -0
- mostlyright/data_harness/readers/contracts.py +542 -0
- mostlyright/data_harness/readers/delimited.py +257 -0
- mostlyright/data_harness/readers/grib2/__init__.py +33 -0
- mostlyright/data_harness/readers/grib2/admission.py +722 -0
- mostlyright/data_harness/readers/grib2/decode.py +1009 -0
- mostlyright/data_harness/readers/grib2/geometry.py +1133 -0
- mostlyright/data_harness/readers/grib2/portable_math.py +501 -0
- mostlyright/data_harness/readers/json_tabular.py +485 -0
- mostlyright/data_harness/readers/registry.py +514 -0
- mostlyright/data_harness/readers/samples/README.md +110 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/cities.csv.gz +0 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/cities.csv.gz +0 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/cities.tar +0 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/cities.tar +0 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/cities.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/cities.csv +3 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/expected.json +23 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/cities.tsv +5 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/expected.json +25 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/expected.json +30 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/observations.csv +5 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/expected.json +44 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/stations.json +1 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/expected.json +48 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/observations.ndjson +4 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/cities.xlsx +0 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/expected.json +24 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/expected.json +27 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/expected.json +27 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/README.md +20 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/expected.json +55 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/gfs-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/hrrr-categorical-rain.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples.py +582 -0
- mostlyright/data_harness/readers/spreadsheet.py +803 -0
- mostlyright/data_harness/readers/tabular.py +510 -0
- mostlyright/data_harness/recipe.py +5321 -0
- mostlyright/data_harness/repair/__init__.py +78 -0
- mostlyright/data_harness/repair/adapters.py +274 -0
- mostlyright/data_harness/repair/contracts.py +872 -0
- mostlyright/data_harness/repair/coordinator.py +1099 -0
- mostlyright/data_harness/repair/errors.py +16 -0
- mostlyright/data_harness/review.py +2533 -0
- mostlyright/data_harness/rowset.py +283 -0
- mostlyright/data_harness/serving.py +1975 -0
- mostlyright/data_harness/serving_edge.py +590 -0
- mostlyright/data_harness/serving_http.py +1031 -0
- mostlyright/data_harness/session_probes.py +759 -0
- mostlyright/data_harness/signing.py +101 -0
- mostlyright/data_harness/source_discovery.py +898 -0
- mostlyright/data_harness/sources/__init__.py +209 -0
- mostlyright/data_harness/sources/_adapter_steps.py +213 -0
- mostlyright/data_harness/sources/adapters.py +1214 -0
- mostlyright/data_harness/sources/cadence.py +1428 -0
- mostlyright/data_harness/sources/cadence_emission.py +453 -0
- mostlyright/data_harness/sources/cadence_history.py +546 -0
- mostlyright/data_harness/sources/catalog/__init__.py +17 -0
- mostlyright/data_harness/sources/catalog/admission.py +477 -0
- mostlyright/data_harness/sources/catalog/authoring.py +1701 -0
- mostlyright/data_harness/sources/catalog/authoring_policy.py +701 -0
- mostlyright/data_harness/sources/catalog/authoring_shards.py +1217 -0
- mostlyright/data_harness/sources/catalog/bounded_io.py +231 -0
- mostlyright/data_harness/sources/catalog/channel.py +523 -0
- mostlyright/data_harness/sources/catalog/channel_client.py +296 -0
- mostlyright/data_harness/sources/catalog/contracts.py +825 -0
- mostlyright/data_harness/sources/catalog/coverage.py +137 -0
- mostlyright/data_harness/sources/catalog/delta.py +1340 -0
- mostlyright/data_harness/sources/catalog/embedding.py +532 -0
- mostlyright/data_harness/sources/catalog/entry_v2.py +1182 -0
- mostlyright/data_harness/sources/catalog/fill.py +3889 -0
- mostlyright/data_harness/sources/catalog/fill_partitions.py +459 -0
- mostlyright/data_harness/sources/catalog/fill_staging.py +1105 -0
- mostlyright/data_harness/sources/catalog/gating.py +374 -0
- mostlyright/data_harness/sources/catalog/generation_receipt.py +1607 -0
- mostlyright/data_harness/sources/catalog/harvest/__init__.py +7 -0
- mostlyright/data_harness/sources/catalog/harvest/ckan.py +384 -0
- mostlyright/data_harness/sources/catalog/harvest/datagov_v4.py +798 -0
- mostlyright/data_harness/sources/catalog/harvest/protocol.py +964 -0
- mostlyright/data_harness/sources/catalog/harvest/sdmx.py +445 -0
- mostlyright/data_harness/sources/catalog/harvest/stac.py +384 -0
- mostlyright/data_harness/sources/catalog/health.py +447 -0
- mostlyright/data_harness/sources/catalog/hosted_catalog.py +105 -0
- mostlyright/data_harness/sources/catalog/identity_history.py +1549 -0
- mostlyright/data_harness/sources/catalog/neural.py +1618 -0
- mostlyright/data_harness/sources/catalog/packed_catalog.py +2345 -0
- mostlyright/data_harness/sources/catalog/packed_retrieval.py +1517 -0
- mostlyright/data_harness/sources/catalog/packed_writer.py +2802 -0
- mostlyright/data_harness/sources/catalog/query_trace.py +1037 -0
- mostlyright/data_harness/sources/catalog/recommend.py +171 -0
- mostlyright/data_harness/sources/catalog/retrieval.py +230 -0
- mostlyright/data_harness/sources/catalog/retrieval_manifest.py +995 -0
- mostlyright/data_harness/sources/catalog/rights_decisions.py +254 -0
- mostlyright/data_harness/sources/catalog/sealed.py +560 -0
- mostlyright/data_harness/sources/catalog/search.py +230 -0
- mostlyright/data_harness/sources/catalog/streaming_delta.py +1097 -0
- mostlyright/data_harness/sources/catalog/update.py +891 -0
- mostlyright/data_harness/sources/collections.py +815 -0
- mostlyright/data_harness/sources/contracts.py +2223 -0
- mostlyright/data_harness/sources/deletion.py +761 -0
- mostlyright/data_harness/sources/fitness.py +162 -0
- mostlyright/data_harness/sources/governance.py +163 -0
- mostlyright/data_harness/sources/hosted.py +173 -0
- mostlyright/data_harness/sources/integration.py +218 -0
- mostlyright/data_harness/sources/range_reader.py +418 -0
- mostlyright/data_harness/sources/registry.py +514 -0
- mostlyright/data_harness/sources/rights_rule.py +59 -0
- mostlyright/data_harness/sources/source_cadence_vectors.v1.json +1 -0
- mostlyright/data_harness/sources/sports.py +521 -0
- mostlyright/data_harness/sources/stream.py +524 -0
- mostlyright/data_harness/sources/stream_connector.py +418 -0
- mostlyright/data_harness/sources/stream_recorder.py +1404 -0
- mostlyright/data_harness/studio_boundary.py +2019 -0
- mostlyright/data_harness/thin/__init__.py +37 -0
- mostlyright/data_harness/thin/acquire.py +1137 -0
- mostlyright/data_harness/thin/acquire_cancel.py +579 -0
- mostlyright/data_harness/thin/approvals.py +617 -0
- mostlyright/data_harness/thin/commands.py +406 -0
- mostlyright/data_harness/thin/download.py +194 -0
- mostlyright/data_harness/thin/narrative.py +589 -0
- mostlyright/data_harness/thin/parity.py +1070 -0
- mostlyright/data_harness/thin/propose.py +2759 -0
- mostlyright/data_harness/thin/research.py +1663 -0
- mostlyright/data_harness/thin/router.py +924 -0
- mostlyright/data_harness/thin/runs.py +519 -0
- mostlyright/data_harness/thin/session.py +281 -0
- mostlyright/data_harness/thin/stream.py +501 -0
- mostlyright/data_harness/thin/transport.py +187 -0
- mostlyright/data_harness/thin/vocabulary.py +368 -0
- mostlyright/data_harness/thin/workers.py +164 -0
- mostlyright/data_harness/ucum/TABLE-PIN.json +40 -0
- mostlyright/data_harness/ucum/ucum-subset.v1.json +632 -0
- mostlyright/data_harness/unit_flow.py +927 -0
- mostlyright/data_harness/units.py +572 -0
- mostlyright/data_harness/ux/__init__.py +9 -0
- mostlyright/data_harness/ux/approve.py +485 -0
- mostlyright/data_harness/ux/author_yaml.py +597 -0
- mostlyright/data_harness/ux/cloud_auth.py +447 -0
- mostlyright/data_harness/ux/commands/__init__.py +260 -0
- mostlyright/data_harness/ux/commands/approve.py +136 -0
- mostlyright/data_harness/ux/commands/auth.py +744 -0
- mostlyright/data_harness/ux/commands/author.py +79 -0
- mostlyright/data_harness/ux/commands/catalog_author.py +403 -0
- mostlyright/data_harness/ux/commands/catalog_fill.py +523 -0
- mostlyright/data_harness/ux/commands/catalog_harvest.py +545 -0
- mostlyright/data_harness/ux/commands/catalog_publish.py +1838 -0
- mostlyright/data_harness/ux/commands/catalog_search.py +71 -0
- mostlyright/data_harness/ux/commands/catalog_update.py +437 -0
- mostlyright/data_harness/ux/commands/deploy.py +134 -0
- mostlyright/data_harness/ux/commands/deploy_dataset.py +98 -0
- mostlyright/data_harness/ux/commands/deploy_plan.py +105 -0
- mostlyright/data_harness/ux/commands/deploy_status.py +104 -0
- mostlyright/data_harness/ux/commands/diff.py +74 -0
- mostlyright/data_harness/ux/commands/index.py +84 -0
- mostlyright/data_harness/ux/commands/inventory.py +47 -0
- mostlyright/data_harness/ux/commands/list_builds.py +143 -0
- mostlyright/data_harness/ux/commands/login.py +63 -0
- mostlyright/data_harness/ux/commands/peek.py +236 -0
- mostlyright/data_harness/ux/commands/plan_check.py +90 -0
- mostlyright/data_harness/ux/commands/preflight.py +97 -0
- mostlyright/data_harness/ux/commands/record.py +107 -0
- mostlyright/data_harness/ux/commands/review_setup.py +47 -0
- mostlyright/data_harness/ux/commands/search.py +440 -0
- mostlyright/data_harness/ux/commands/show.py +61 -0
- mostlyright/data_harness/ux/commands/whoami.py +37 -0
- mostlyright/data_harness/ux/credential_native.py +551 -0
- mostlyright/data_harness/ux/credential_store.py +1055 -0
- mostlyright/data_harness/ux/credentials.py +631 -0
- mostlyright/data_harness/ux/diffing.py +444 -0
- mostlyright/data_harness/ux/headline.py +671 -0
- mostlyright/data_harness/ux/hosted_acquisition.py +974 -0
- mostlyright/data_harness/ux/hosted_run_status.py +619 -0
- mostlyright/data_harness/ux/inventory.py +427 -0
- mostlyright/data_harness/ux/local_review.py +375 -0
- mostlyright/data_harness/ux/login.py +691 -0
- mostlyright/data_harness/ux/path_kind.py +147 -0
- mostlyright/data_harness/ux/peek.py +1000 -0
- mostlyright/data_harness/ux/plain_file.py +178 -0
- mostlyright/data_harness/ux/plan_check.py +311 -0
- mostlyright/data_harness/ux/preflight.py +918 -0
- mostlyright/data_harness/ux/readers.py +1124 -0
- mostlyright/data_harness/ux/remediation.py +2195 -0
- mostlyright/data_harness/ux/render.py +657 -0
- mostlyright/data_harness/ux/workload.py +1077 -0
- mostlyright/data_harness/viewer.py +3713 -0
- mostlyright/data_harness/visual_run/__init__.py +83 -0
- mostlyright/data_harness/visual_run/authoring.py +235 -0
- mostlyright/data_harness/visual_run/contracts.py +673 -0
- mostlyright/data_harness/visual_run/materialize.py +486 -0
- mostlyright/data_harness/visual_run/observations.py +874 -0
- mostlyright/data_harness/visual_run/query.py +259 -0
- mostlyright/data_harness/visual_run/reducer.py +280 -0
- mostlyright/data_harness/visual_run/sdk.py +892 -0
- mostlyright/data_harness/visual_run/store.py +584 -0
- mostlyright/data_harness/visual_run/transport.py +239 -0
- mostlyright/data_harness/watch.py +2999 -0
- mostlyright_data-0.9.0.dist-info/METADATA +607 -0
- mostlyright_data-0.9.0.dist-info/RECORD +314 -0
- mostlyright_data-0.9.0.dist-info/WHEEL +4 -0
- 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
|
+
]
|