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,1162 @@
|
|
|
1
|
+
"""Generation-fenced Google Cloud Storage implementation of :mod:`ingestion.store`.
|
|
2
|
+
|
|
3
|
+
The ingestion contracts deliberately do not mention a filesystem. This implementation keeps
|
|
4
|
+
that promise: manifests use Cloud Storage's object generation as their CAS token and chunks are
|
|
5
|
+
create-only objects named by their SHA-256. The one local concern is a bounded private staging
|
|
6
|
+
directory, needed to learn a stream's digest before naming its content-addressed object. Bytes
|
|
7
|
+
are never accumulated into one Python object.
|
|
8
|
+
|
|
9
|
+
``GoogleCloudStorageJSONTransport`` uses only the standard library and asks a workload identity
|
|
10
|
+
for a fresh access token for every request. Callers can instead supply a transport implementing
|
|
11
|
+
``GCSObjectTransport``; this is how emulators and the deterministic fake in the tests exercise
|
|
12
|
+
lost acknowledgements and concurrent writers. Neither store form accepts, persists, or logs a
|
|
13
|
+
long-lived plaintext capability.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import fcntl
|
|
19
|
+
import hashlib
|
|
20
|
+
import http.client
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import stat
|
|
24
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
28
|
+
from urllib.parse import quote, urlencode
|
|
29
|
+
|
|
30
|
+
from mostlyright.data_harness.canonical import (
|
|
31
|
+
CanonicalJSONError,
|
|
32
|
+
canonical_json_bytes,
|
|
33
|
+
parse_canonical_json,
|
|
34
|
+
parse_json,
|
|
35
|
+
)
|
|
36
|
+
from mostlyright.data_harness.ingestion.contracts import (
|
|
37
|
+
ChunkRef,
|
|
38
|
+
IngestionConflict,
|
|
39
|
+
IngestionCorrupt,
|
|
40
|
+
IngestionError,
|
|
41
|
+
IngestionManifestV1,
|
|
42
|
+
)
|
|
43
|
+
from mostlyright.data_harness.ingestion.spool import SpooledChunk
|
|
44
|
+
from mostlyright.data_harness.ingestion.store import (
|
|
45
|
+
DEFAULT_MAX_SPOOL_BYTES,
|
|
46
|
+
DEFAULT_MAX_SPOOL_TOTAL_BYTES,
|
|
47
|
+
DEFAULT_STALE_SPOOL_AGE_SECONDS,
|
|
48
|
+
MAX_MANIFEST_BYTES,
|
|
49
|
+
LocalIngestionStore,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if TYPE_CHECKING:
|
|
53
|
+
from mostlyright.data_harness.hosted_ingestion_contract import (
|
|
54
|
+
IngestionDeliveryBindingV1,
|
|
55
|
+
IngestionManifestReferenceV1,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
GCS_STORAGE_HOST = "storage.googleapis.com"
|
|
59
|
+
GCS_METADATA_HOST = "169.254.169.254"
|
|
60
|
+
GCS_METADATA_TOKEN_PATH = "/computeMetadata/v1/instance/service-accounts/default/token"
|
|
61
|
+
GCS_STORAGE_SCOPE = "https://www.googleapis.com/auth/devstorage.read_write"
|
|
62
|
+
GCS_READ_CHUNK_BYTES = 1024 * 1024
|
|
63
|
+
GCS_METADATA_BYTES = 64 * 1024
|
|
64
|
+
GCS_TOKEN_BYTES = 16 * 1024
|
|
65
|
+
|
|
66
|
+
_BUCKET = re.compile(r"[a-z0-9][a-z0-9._-]{1,61}[a-z0-9]\Z")
|
|
67
|
+
_PREFIX_SEGMENT = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z")
|
|
68
|
+
_GENERATION = re.compile(r"[1-9][0-9]*\Z")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class GCSStorageError(RuntimeError):
|
|
72
|
+
"""A transport failure whose text is safe to report but deliberately nonspecific."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class GCSObjectNotFound(GCSStorageError):
|
|
76
|
+
"""The named object does not exist."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class GCSPreconditionFailed(GCSStorageError):
|
|
80
|
+
"""A create/CAS/read-generation precondition did not hold."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class GCSObjectVersion:
|
|
85
|
+
"""A GCS object identity suitable for an exact subsequent read or CAS."""
|
|
86
|
+
|
|
87
|
+
generation: str
|
|
88
|
+
byte_length: int
|
|
89
|
+
|
|
90
|
+
def __post_init__(self) -> None:
|
|
91
|
+
if _GENERATION.fullmatch(self.generation) is None:
|
|
92
|
+
raise ValueError("generation must be one positive decimal string")
|
|
93
|
+
if (
|
|
94
|
+
isinstance(self.byte_length, bool)
|
|
95
|
+
or not isinstance(self.byte_length, int)
|
|
96
|
+
or self.byte_length < 0
|
|
97
|
+
):
|
|
98
|
+
raise ValueError("byte_length must be one non-negative integer")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class GCSObjectRead:
|
|
103
|
+
"""One generation-pinned body stream; consuming it never requires object-sized memory."""
|
|
104
|
+
|
|
105
|
+
version: GCSObjectVersion
|
|
106
|
+
parts: Iterable[bytes]
|
|
107
|
+
closer: Callable[[], None] = lambda: None
|
|
108
|
+
|
|
109
|
+
def close(self) -> None:
|
|
110
|
+
"""Release an unread transport response; this operation is idempotent."""
|
|
111
|
+
|
|
112
|
+
self.closer()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@runtime_checkable
|
|
116
|
+
class GCSObjectTransport(Protocol):
|
|
117
|
+
"""The small create/CAS/get subset the store needs from Cloud Storage.
|
|
118
|
+
|
|
119
|
+
``if_generation_match`` is either ``"0"`` for create-only, a generation returned by
|
|
120
|
+
:meth:`get`, or ``None`` for an unconstrained metadata lookup. Implementations must consume
|
|
121
|
+
``parts`` at most once and must not return success before the server has made the full object
|
|
122
|
+
durable. This intentionally small protocol makes the production HTTP transport replaceable
|
|
123
|
+
by a GCS emulator without making a bearer token part of the store API.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def put(
|
|
127
|
+
self,
|
|
128
|
+
*,
|
|
129
|
+
bucket: str,
|
|
130
|
+
name: str,
|
|
131
|
+
parts: Iterable[bytes],
|
|
132
|
+
byte_length: int,
|
|
133
|
+
if_generation_match: str,
|
|
134
|
+
) -> GCSObjectVersion:
|
|
135
|
+
"""Create or CAS-replace one object, or raise a typed transport error."""
|
|
136
|
+
|
|
137
|
+
def get(
|
|
138
|
+
self,
|
|
139
|
+
*,
|
|
140
|
+
bucket: str,
|
|
141
|
+
name: str,
|
|
142
|
+
if_generation_match: str | None = None,
|
|
143
|
+
) -> GCSObjectRead:
|
|
144
|
+
"""Return an optionally generation-pinned stream, or raise a typed transport error."""
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def workload_identity_access_token() -> str:
|
|
148
|
+
"""Read one short-lived storage token directly from the Cloud Run metadata service.
|
|
149
|
+
|
|
150
|
+
The result exists only in the call stack of a subsequent HTTP request. It is deliberately
|
|
151
|
+
neither cached nor accepted as a constructor argument, so a persisted ingestion checkpoint
|
|
152
|
+
cannot accidentally become a bearer-capability store.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
query = urlencode({"scopes": GCS_STORAGE_SCOPE})
|
|
156
|
+
connection = http.client.HTTPConnection(GCS_METADATA_HOST, timeout=10.0)
|
|
157
|
+
try:
|
|
158
|
+
connection.request(
|
|
159
|
+
"GET",
|
|
160
|
+
GCS_METADATA_TOKEN_PATH + "?" + query,
|
|
161
|
+
headers={"Metadata-Flavor": "Google"},
|
|
162
|
+
)
|
|
163
|
+
response = connection.getresponse()
|
|
164
|
+
raw = _read_bounded_response(response, GCS_TOKEN_BYTES)
|
|
165
|
+
if response.status != 200:
|
|
166
|
+
raise GCSStorageError("workload identity did not return an access token")
|
|
167
|
+
except (OSError, http.client.HTTPException) as error:
|
|
168
|
+
raise GCSStorageError("workload identity could not be reached") from error
|
|
169
|
+
finally:
|
|
170
|
+
connection.close()
|
|
171
|
+
try:
|
|
172
|
+
document = parse_json(raw)
|
|
173
|
+
except CanonicalJSONError as error:
|
|
174
|
+
raise GCSStorageError("workload identity returned invalid token metadata") from error
|
|
175
|
+
token = document.get("access_token") if isinstance(document, dict) else None
|
|
176
|
+
if (
|
|
177
|
+
not isinstance(token, str)
|
|
178
|
+
or not token
|
|
179
|
+
or len(token.encode("utf-8")) > 8192
|
|
180
|
+
or any(ord(character) < 0x21 or ord(character) > 0x7E for character in token)
|
|
181
|
+
):
|
|
182
|
+
raise GCSStorageError("workload identity returned no usable access token")
|
|
183
|
+
return token
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass
|
|
187
|
+
class GoogleCloudStorageJSONTransport:
|
|
188
|
+
"""Streaming JSON-API transport with no proxy endpoint or credential configuration surface."""
|
|
189
|
+
|
|
190
|
+
access_token_provider: Callable[[], str] = workload_identity_access_token
|
|
191
|
+
timeout_seconds: float = 60.0
|
|
192
|
+
|
|
193
|
+
def __post_init__(self) -> None:
|
|
194
|
+
if not callable(self.access_token_provider):
|
|
195
|
+
raise ValueError("access_token_provider must be callable")
|
|
196
|
+
if (
|
|
197
|
+
isinstance(self.timeout_seconds, bool)
|
|
198
|
+
or not isinstance(self.timeout_seconds, (int, float))
|
|
199
|
+
or self.timeout_seconds <= 0
|
|
200
|
+
or self.timeout_seconds > 600
|
|
201
|
+
):
|
|
202
|
+
raise ValueError("timeout_seconds must be between zero and 600")
|
|
203
|
+
|
|
204
|
+
def put(
|
|
205
|
+
self,
|
|
206
|
+
*,
|
|
207
|
+
bucket: str,
|
|
208
|
+
name: str,
|
|
209
|
+
parts: Iterable[bytes],
|
|
210
|
+
byte_length: int,
|
|
211
|
+
if_generation_match: str,
|
|
212
|
+
) -> GCSObjectVersion:
|
|
213
|
+
_require_object_request(bucket, name, byte_length, if_generation_match)
|
|
214
|
+
query = urlencode(
|
|
215
|
+
{
|
|
216
|
+
"uploadType": "media",
|
|
217
|
+
"name": name,
|
|
218
|
+
"ifGenerationMatch": if_generation_match,
|
|
219
|
+
}
|
|
220
|
+
)
|
|
221
|
+
path = f"/upload/storage/v1/b/{quote(bucket, safe='')}/o?{query}"
|
|
222
|
+
connection = self._connection()
|
|
223
|
+
try:
|
|
224
|
+
connection.putrequest("POST", path)
|
|
225
|
+
connection.putheader("Authorization", self._authorization())
|
|
226
|
+
connection.putheader("Content-Type", "application/octet-stream")
|
|
227
|
+
connection.putheader("Content-Length", str(byte_length))
|
|
228
|
+
connection.endheaders()
|
|
229
|
+
remaining = byte_length
|
|
230
|
+
for part in parts:
|
|
231
|
+
view = _part_view(part)
|
|
232
|
+
if view.nbytes > remaining:
|
|
233
|
+
raise GCSStorageError("object upload source exceeded its declared byte length")
|
|
234
|
+
connection.send(view)
|
|
235
|
+
remaining -= view.nbytes
|
|
236
|
+
if remaining:
|
|
237
|
+
raise GCSStorageError("object upload source ended before its declared byte length")
|
|
238
|
+
response = connection.getresponse()
|
|
239
|
+
raw = _read_bounded_response(response, GCS_METADATA_BYTES)
|
|
240
|
+
_raise_for_status(response.status, name, successful_statuses=(200, 201))
|
|
241
|
+
return _parse_object_version(raw, name, expected_size=byte_length)
|
|
242
|
+
except GCSStorageError:
|
|
243
|
+
raise
|
|
244
|
+
except (OSError, http.client.HTTPException) as error:
|
|
245
|
+
raise GCSStorageError("Cloud Storage object upload failed") from error
|
|
246
|
+
finally:
|
|
247
|
+
connection.close()
|
|
248
|
+
|
|
249
|
+
def get(
|
|
250
|
+
self,
|
|
251
|
+
*,
|
|
252
|
+
bucket: str,
|
|
253
|
+
name: str,
|
|
254
|
+
if_generation_match: str | None = None,
|
|
255
|
+
) -> GCSObjectRead:
|
|
256
|
+
_require_get_request(bucket, name, if_generation_match)
|
|
257
|
+
metadata = self._metadata(bucket=bucket, name=name)
|
|
258
|
+
if if_generation_match is not None and metadata.generation != if_generation_match:
|
|
259
|
+
raise GCSPreconditionFailed("Cloud Storage object generation differs")
|
|
260
|
+
query = urlencode({"alt": "media", "ifGenerationMatch": metadata.generation})
|
|
261
|
+
path = f"/storage/v1/b/{quote(bucket, safe='')}/o/{quote(name, safe='')}?{query}"
|
|
262
|
+
connection = self._connection()
|
|
263
|
+
try:
|
|
264
|
+
connection.putrequest("GET", path)
|
|
265
|
+
connection.putheader("Authorization", self._authorization())
|
|
266
|
+
connection.endheaders()
|
|
267
|
+
response = connection.getresponse()
|
|
268
|
+
if response.status != 200:
|
|
269
|
+
_read_bounded_response(response, GCS_METADATA_BYTES)
|
|
270
|
+
_raise_for_status(response.status, name)
|
|
271
|
+
raise AssertionError("unreachable")
|
|
272
|
+
content_length = response.getheader("Content-Length")
|
|
273
|
+
if content_length is not None and content_length != str(metadata.byte_length):
|
|
274
|
+
response.close()
|
|
275
|
+
raise GCSStorageError("Cloud Storage media response length differs from metadata")
|
|
276
|
+
body = _HTTPResponseParts(response, connection, metadata.byte_length)
|
|
277
|
+
return GCSObjectRead(version=metadata, parts=body, closer=body.close)
|
|
278
|
+
except GCSStorageError:
|
|
279
|
+
connection.close()
|
|
280
|
+
raise
|
|
281
|
+
except (OSError, http.client.HTTPException) as error:
|
|
282
|
+
connection.close()
|
|
283
|
+
raise GCSStorageError("Cloud Storage object read failed") from error
|
|
284
|
+
|
|
285
|
+
def _metadata(self, *, bucket: str, name: str) -> GCSObjectVersion:
|
|
286
|
+
path = f"/storage/v1/b/{quote(bucket, safe='')}/o/{quote(name, safe='')}"
|
|
287
|
+
connection = self._connection()
|
|
288
|
+
try:
|
|
289
|
+
connection.request("GET", path, headers={"Authorization": self._authorization()})
|
|
290
|
+
response = connection.getresponse()
|
|
291
|
+
raw = _read_bounded_response(response, GCS_METADATA_BYTES)
|
|
292
|
+
_raise_for_status(response.status, name)
|
|
293
|
+
return _parse_object_version(raw, name)
|
|
294
|
+
except GCSStorageError:
|
|
295
|
+
raise
|
|
296
|
+
except (OSError, http.client.HTTPException) as error:
|
|
297
|
+
raise GCSStorageError("Cloud Storage object metadata read failed") from error
|
|
298
|
+
finally:
|
|
299
|
+
connection.close()
|
|
300
|
+
|
|
301
|
+
def _connection(self) -> http.client.HTTPSConnection:
|
|
302
|
+
return http.client.HTTPSConnection(GCS_STORAGE_HOST, timeout=float(self.timeout_seconds))
|
|
303
|
+
|
|
304
|
+
def _authorization(self) -> str:
|
|
305
|
+
token = self.access_token_provider()
|
|
306
|
+
if (
|
|
307
|
+
not isinstance(token, str)
|
|
308
|
+
or not token
|
|
309
|
+
or len(token.encode("utf-8")) > 8192
|
|
310
|
+
or any(ord(character) < 0x21 or ord(character) > 0x7E for character in token)
|
|
311
|
+
):
|
|
312
|
+
raise GCSStorageError("access-token provider returned no usable token")
|
|
313
|
+
return "Bearer " + token
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@dataclass(frozen=True)
|
|
317
|
+
class _VersionedManifest:
|
|
318
|
+
manifest: IngestionManifestV1
|
|
319
|
+
generation: str
|
|
320
|
+
raw_sha256: str
|
|
321
|
+
byte_length: int
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@dataclass
|
|
325
|
+
class GCSIngestionStore:
|
|
326
|
+
"""A bounded, generation-fenced Cloud Storage ingestion store.
|
|
327
|
+
|
|
328
|
+
``staging_root`` must be a private local directory. It is used only for bounded temporary
|
|
329
|
+
chunk spools: all durable manifests and immutable content-addressed chunks reside in GCS.
|
|
330
|
+
The backend's only remote authority is the configured bucket/prefix plus the workload's
|
|
331
|
+
ambient identity, not a serializable upload URL or bearer secret.
|
|
332
|
+
"""
|
|
333
|
+
|
|
334
|
+
bucket: str
|
|
335
|
+
prefix: str
|
|
336
|
+
staging_root: Path
|
|
337
|
+
manifest_prefix: str | None = None
|
|
338
|
+
chunk_prefix: str | None = None
|
|
339
|
+
delivery_binding: IngestionDeliveryBindingV1 | None = None
|
|
340
|
+
transport: GCSObjectTransport | None = None
|
|
341
|
+
maximum_spool_bytes: int = DEFAULT_MAX_SPOOL_BYTES
|
|
342
|
+
maximum_spool_total_bytes: int = DEFAULT_MAX_SPOOL_TOTAL_BYTES
|
|
343
|
+
stale_spool_age_seconds: int = DEFAULT_STALE_SPOOL_AGE_SECONDS
|
|
344
|
+
|
|
345
|
+
def __post_init__(self) -> None:
|
|
346
|
+
# Import lazily: hosted delivery contracts themselves import the core ingestion contract,
|
|
347
|
+
# while this package's public initializer also exposes GCSIngestionStore.
|
|
348
|
+
from mostlyright.data_harness.hosted_ingestion_contract import IngestionDeliveryBindingV1
|
|
349
|
+
|
|
350
|
+
if not isinstance(self.bucket, str) or _BUCKET.fullmatch(self.bucket) is None:
|
|
351
|
+
raise IngestionError("INGESTION_GCS_BUCKET", "gcs.bucket", "is not a valid bucket name")
|
|
352
|
+
self.prefix = _normalize_prefix(self.prefix)
|
|
353
|
+
self.manifest_prefix = _normalize_prefix(self.manifest_prefix or self.prefix)
|
|
354
|
+
self.chunk_prefix = _normalize_prefix(self.chunk_prefix or self.prefix)
|
|
355
|
+
if self.delivery_binding is not None and not isinstance(
|
|
356
|
+
self.delivery_binding, IngestionDeliveryBindingV1
|
|
357
|
+
):
|
|
358
|
+
raise IngestionError(
|
|
359
|
+
"INGESTION_DELIVERY_BINDING",
|
|
360
|
+
"gcs.delivery_binding",
|
|
361
|
+
"must be IngestionDeliveryBindingV1",
|
|
362
|
+
)
|
|
363
|
+
if (
|
|
364
|
+
self.delivery_binding is not None
|
|
365
|
+
and self.manifest_prefix.rsplit("/", 1)[-1] != self.delivery_binding.delivery_id
|
|
366
|
+
):
|
|
367
|
+
raise IngestionError(
|
|
368
|
+
"INGESTION_DELIVERY_NAMESPACE",
|
|
369
|
+
"gcs.manifest_prefix",
|
|
370
|
+
"must end in delivery_binding.delivery_id",
|
|
371
|
+
)
|
|
372
|
+
self.staging_root = Path(self.staging_root)
|
|
373
|
+
if self.transport is None:
|
|
374
|
+
self.transport = GoogleCloudStorageJSONTransport()
|
|
375
|
+
if not isinstance(self.transport, GCSObjectTransport):
|
|
376
|
+
raise IngestionError(
|
|
377
|
+
"INGESTION_GCS_TRANSPORT", "gcs.transport", "does not implement GCSObjectTransport"
|
|
378
|
+
)
|
|
379
|
+
# Reuse the carefully reviewed private directory, lock, aggregate quota, and stale-spool
|
|
380
|
+
# discipline from the local store. Its runs/chunks directories remain empty here.
|
|
381
|
+
self._staging = LocalIngestionStore(
|
|
382
|
+
self.staging_root,
|
|
383
|
+
maximum_spool_bytes=self.maximum_spool_bytes,
|
|
384
|
+
maximum_spool_total_bytes=self.maximum_spool_total_bytes,
|
|
385
|
+
maximum_store_bytes=self.maximum_spool_total_bytes,
|
|
386
|
+
stale_spool_age_seconds=self.stale_spool_age_seconds,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
def create(self, manifest: IngestionManifestV1) -> IngestionManifestV1:
|
|
390
|
+
if (
|
|
391
|
+
not isinstance(manifest, IngestionManifestV1)
|
|
392
|
+
or manifest.state != "open"
|
|
393
|
+
or manifest.chunks
|
|
394
|
+
or manifest.previous_manifest_sha256 is not None
|
|
395
|
+
):
|
|
396
|
+
raise IngestionConflict(
|
|
397
|
+
"INGESTION_CREATE_STATE",
|
|
398
|
+
"ingestion_manifest",
|
|
399
|
+
"only an initial open manifest may be created",
|
|
400
|
+
)
|
|
401
|
+
raw = self._manifest_raw(manifest)
|
|
402
|
+
try:
|
|
403
|
+
self._put(self._manifest_name(manifest.run_id), [raw], len(raw), "0")
|
|
404
|
+
except GCSPreconditionFailed:
|
|
405
|
+
existing = self.load(manifest.run_id)
|
|
406
|
+
if existing is not None and existing.digest == manifest.digest:
|
|
407
|
+
return existing
|
|
408
|
+
raise IngestionConflict(
|
|
409
|
+
"INGESTION_RUN_EXISTS",
|
|
410
|
+
"ingestion_manifest.run_id",
|
|
411
|
+
"already has a different manifest",
|
|
412
|
+
) from None
|
|
413
|
+
except GCSStorageError as error:
|
|
414
|
+
# A connection can fail after GCS committed a create. Resolve only the exact replay;
|
|
415
|
+
# otherwise surface an I/O failure rather than guessing the durable outcome.
|
|
416
|
+
existing = self.load(manifest.run_id)
|
|
417
|
+
if existing is not None and existing.digest == manifest.digest:
|
|
418
|
+
return existing
|
|
419
|
+
raise _storage_refusal("ingestion_manifest", error) from error
|
|
420
|
+
return manifest
|
|
421
|
+
|
|
422
|
+
def load(self, run_id: str) -> IngestionManifestV1 | None:
|
|
423
|
+
self._validate_run_id(run_id)
|
|
424
|
+
current = self._load_versioned(run_id)
|
|
425
|
+
return None if current is None else current.manifest
|
|
426
|
+
|
|
427
|
+
def current_manifest_reference(self, *, run_id: str) -> IngestionManifestReferenceV1:
|
|
428
|
+
"""Return the exact raw document coordinate after validating its delivery envelope."""
|
|
429
|
+
|
|
430
|
+
self._validate_run_id(run_id)
|
|
431
|
+
current = self._require_versioned(run_id)
|
|
432
|
+
from mostlyright.data_harness.hosted_ingestion_contract import IngestionManifestReferenceV1
|
|
433
|
+
|
|
434
|
+
return IngestionManifestReferenceV1(
|
|
435
|
+
object_name=self._manifest_name(run_id),
|
|
436
|
+
generation=current.generation,
|
|
437
|
+
manifest_sha256=current.raw_sha256,
|
|
438
|
+
byte_length=current.byte_length,
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
def load_manifest_reference(
|
|
442
|
+
self,
|
|
443
|
+
*,
|
|
444
|
+
reference: IngestionManifestReferenceV1,
|
|
445
|
+
manifest_prefix: str,
|
|
446
|
+
run_id: str,
|
|
447
|
+
) -> IngestionManifestV1:
|
|
448
|
+
"""Load one preceding delivery manifest only when every reference coordinate holds.
|
|
449
|
+
|
|
450
|
+
``manifest_prefix`` comes from Studio's frozen predecessor object, rather than being
|
|
451
|
+
inferred from a foreign object name. The read is generation-pinned and verifies the raw
|
|
452
|
+
canonical envelope digest before exposing its core manifest.
|
|
453
|
+
"""
|
|
454
|
+
|
|
455
|
+
from mostlyright.data_harness.hosted_ingestion_contract import IngestionManifestReferenceV1
|
|
456
|
+
|
|
457
|
+
if not isinstance(reference, IngestionManifestReferenceV1):
|
|
458
|
+
raise IngestionError(
|
|
459
|
+
"INGESTION_MANIFEST_REF", "manifest_ref", "must be IngestionManifestReferenceV1"
|
|
460
|
+
)
|
|
461
|
+
self._validate_run_id(run_id)
|
|
462
|
+
reference.require_for_prefix(prefix=manifest_prefix, run_id=run_id)
|
|
463
|
+
try:
|
|
464
|
+
stored = self.transport.get(
|
|
465
|
+
bucket=self.bucket,
|
|
466
|
+
name=reference.object_name,
|
|
467
|
+
if_generation_match=reference.generation,
|
|
468
|
+
)
|
|
469
|
+
except GCSObjectNotFound as error:
|
|
470
|
+
raise IngestionCorrupt(
|
|
471
|
+
"INGESTION_MANIFEST_REF_MISSING",
|
|
472
|
+
"manifest_ref.object_name",
|
|
473
|
+
"referenced delivery manifest is absent",
|
|
474
|
+
) from error
|
|
475
|
+
except GCSPreconditionFailed as error:
|
|
476
|
+
raise IngestionCorrupt(
|
|
477
|
+
"INGESTION_MANIFEST_REF_GENERATION",
|
|
478
|
+
"manifest_ref.generation",
|
|
479
|
+
"referenced delivery manifest changed",
|
|
480
|
+
) from error
|
|
481
|
+
except GCSStorageError as error:
|
|
482
|
+
raise _storage_refusal("manifest_ref", error) from error
|
|
483
|
+
try:
|
|
484
|
+
if stored.version.byte_length != reference.byte_length:
|
|
485
|
+
raise IngestionCorrupt(
|
|
486
|
+
"INGESTION_MANIFEST_REF_LENGTH",
|
|
487
|
+
"manifest_ref.byte_length",
|
|
488
|
+
"does not match object metadata",
|
|
489
|
+
)
|
|
490
|
+
raw = _collect_bounded(stored.parts, maximum=MAX_MANIFEST_BYTES, field="manifest_ref")
|
|
491
|
+
finally:
|
|
492
|
+
stored.close()
|
|
493
|
+
if len(raw) != reference.byte_length:
|
|
494
|
+
raise IngestionCorrupt(
|
|
495
|
+
"INGESTION_MANIFEST_REF_LENGTH",
|
|
496
|
+
"manifest_ref.byte_length",
|
|
497
|
+
"does not match object bytes",
|
|
498
|
+
)
|
|
499
|
+
if hashlib.sha256(raw).hexdigest() != reference.manifest_sha256:
|
|
500
|
+
raise IngestionCorrupt(
|
|
501
|
+
"INGESTION_MANIFEST_REF_DIGEST",
|
|
502
|
+
"manifest_ref.manifest_sha256",
|
|
503
|
+
"does not match canonical manifest bytes",
|
|
504
|
+
)
|
|
505
|
+
try:
|
|
506
|
+
from mostlyright.data_harness.hosted_ingestion_contract import (
|
|
507
|
+
IngestionDeliveryManifestV1,
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
envelope = IngestionDeliveryManifestV1.from_dict(parse_canonical_json(raw))
|
|
511
|
+
except (CanonicalJSONError, IngestionError) as error:
|
|
512
|
+
raise IngestionCorrupt(
|
|
513
|
+
"INGESTION_MANIFEST_REF_CORRUPT",
|
|
514
|
+
"manifest_ref",
|
|
515
|
+
"does not name a valid delivery manifest",
|
|
516
|
+
) from error
|
|
517
|
+
if envelope.manifest.run_id != run_id:
|
|
518
|
+
raise IngestionCorrupt(
|
|
519
|
+
"INGESTION_MANIFEST_REF_RUN",
|
|
520
|
+
"manifest_ref.object_name",
|
|
521
|
+
"does not bind the requested run",
|
|
522
|
+
)
|
|
523
|
+
if manifest_prefix.rsplit("/", 1)[-1] != envelope.delivery.delivery_id:
|
|
524
|
+
raise IngestionCorrupt(
|
|
525
|
+
"INGESTION_MANIFEST_REF_DELIVERY",
|
|
526
|
+
"manifest_ref.object_name",
|
|
527
|
+
"does not name the envelope's delivery namespace",
|
|
528
|
+
)
|
|
529
|
+
return envelope.manifest
|
|
530
|
+
|
|
531
|
+
def seed_from_reference(
|
|
532
|
+
self,
|
|
533
|
+
*,
|
|
534
|
+
reference: IngestionManifestReferenceV1,
|
|
535
|
+
manifest_prefix: str,
|
|
536
|
+
run_id: str,
|
|
537
|
+
) -> IngestionManifestV1:
|
|
538
|
+
"""Verify a predecessor then atomically copy it into this delivery's own namespace."""
|
|
539
|
+
|
|
540
|
+
if self.delivery_binding is None:
|
|
541
|
+
raise IngestionConflict(
|
|
542
|
+
"INGESTION_DELIVERY_BINDING",
|
|
543
|
+
"gcs.delivery_binding",
|
|
544
|
+
"is required to seed a predecessor into a delivery namespace",
|
|
545
|
+
)
|
|
546
|
+
manifest = self.load_manifest_reference(
|
|
547
|
+
reference=reference, manifest_prefix=manifest_prefix, run_id=run_id
|
|
548
|
+
)
|
|
549
|
+
self.delivery_binding.require_manifest(manifest)
|
|
550
|
+
raw = self._manifest_raw(manifest)
|
|
551
|
+
try:
|
|
552
|
+
self._put(self._manifest_name(run_id), [raw], len(raw), "0")
|
|
553
|
+
except (GCSPreconditionFailed, GCSStorageError) as error:
|
|
554
|
+
existing = self._load_versioned(run_id)
|
|
555
|
+
if existing is not None and existing.manifest.digest == manifest.digest:
|
|
556
|
+
return existing.manifest
|
|
557
|
+
if isinstance(error, GCSPreconditionFailed):
|
|
558
|
+
raise IngestionConflict(
|
|
559
|
+
"INGESTION_DELIVERY_SEED_FENCE",
|
|
560
|
+
"ingestion_manifest.run_id",
|
|
561
|
+
"already names a different delivery manifest",
|
|
562
|
+
) from None
|
|
563
|
+
raise _storage_refusal("ingestion_manifest", error) from error
|
|
564
|
+
return manifest
|
|
565
|
+
|
|
566
|
+
def append_stream(
|
|
567
|
+
self,
|
|
568
|
+
*,
|
|
569
|
+
run_id: str,
|
|
570
|
+
expected_manifest_sha256: str,
|
|
571
|
+
chunk: ChunkRef,
|
|
572
|
+
parts: Iterable[bytes | bytearray | memoryview],
|
|
573
|
+
) -> IngestionManifestV1:
|
|
574
|
+
self._validate_run_id(run_id)
|
|
575
|
+
self._validate_manifest_digest(expected_manifest_sha256)
|
|
576
|
+
if not isinstance(chunk, ChunkRef):
|
|
577
|
+
raise IngestionError("INGESTION_CHUNK", "chunk", "must be a ChunkRef")
|
|
578
|
+
spooled = self._staging.spool(parts)
|
|
579
|
+
try:
|
|
580
|
+
self._verify_staged_spool(spooled, chunk)
|
|
581
|
+
current = self._require_versioned(run_id)
|
|
582
|
+
replay = _matching_replay(current.manifest, chunk)
|
|
583
|
+
if replay is not None:
|
|
584
|
+
return replay
|
|
585
|
+
if current.manifest.is_finalized:
|
|
586
|
+
raise IngestionConflict(
|
|
587
|
+
"INGESTION_FINALIZED",
|
|
588
|
+
"ingestion_manifest.state",
|
|
589
|
+
"cannot append to a finalized run",
|
|
590
|
+
)
|
|
591
|
+
if current.manifest.digest != expected_manifest_sha256:
|
|
592
|
+
raise IngestionConflict(
|
|
593
|
+
"INGESTION_MANIFEST_FENCE",
|
|
594
|
+
"expected_manifest_sha256",
|
|
595
|
+
"does not match the current manifest",
|
|
596
|
+
)
|
|
597
|
+
successor = current.manifest.append(chunk)
|
|
598
|
+
self._ensure_chunk(chunk, spooled)
|
|
599
|
+
return self._put_successor(
|
|
600
|
+
run_id=run_id,
|
|
601
|
+
predecessor=current,
|
|
602
|
+
successor=successor,
|
|
603
|
+
expected_manifest_sha256=expected_manifest_sha256,
|
|
604
|
+
chunk=chunk,
|
|
605
|
+
)
|
|
606
|
+
finally:
|
|
607
|
+
self._discard_staged_spool(spooled)
|
|
608
|
+
|
|
609
|
+
def finalize(self, *, run_id: str, expected_manifest_sha256: str) -> IngestionManifestV1:
|
|
610
|
+
self._validate_run_id(run_id)
|
|
611
|
+
self._validate_manifest_digest(expected_manifest_sha256)
|
|
612
|
+
current = self._require_versioned(run_id)
|
|
613
|
+
if current.manifest.is_finalized:
|
|
614
|
+
if (
|
|
615
|
+
current.manifest.digest == expected_manifest_sha256
|
|
616
|
+
or current.manifest.is_direct_successor_of(expected_manifest_sha256)
|
|
617
|
+
):
|
|
618
|
+
return current.manifest
|
|
619
|
+
raise IngestionConflict(
|
|
620
|
+
"INGESTION_MANIFEST_FENCE",
|
|
621
|
+
"expected_manifest_sha256",
|
|
622
|
+
"does not name the finalization predecessor",
|
|
623
|
+
)
|
|
624
|
+
if current.manifest.digest != expected_manifest_sha256:
|
|
625
|
+
raise IngestionConflict(
|
|
626
|
+
"INGESTION_MANIFEST_FENCE",
|
|
627
|
+
"expected_manifest_sha256",
|
|
628
|
+
"does not match the current manifest",
|
|
629
|
+
)
|
|
630
|
+
for item in current.manifest.chunks:
|
|
631
|
+
self._verify_remote_chunk(item)
|
|
632
|
+
successor = current.manifest.finalize()
|
|
633
|
+
raw = self._manifest_raw(successor)
|
|
634
|
+
try:
|
|
635
|
+
self._put(self._manifest_name(run_id), [raw], len(raw), current.generation)
|
|
636
|
+
return successor
|
|
637
|
+
except (GCSPreconditionFailed, GCSStorageError) as error:
|
|
638
|
+
resolved = self._load_versioned(run_id)
|
|
639
|
+
if (
|
|
640
|
+
resolved is not None
|
|
641
|
+
and resolved.manifest.is_finalized
|
|
642
|
+
and resolved.manifest.is_direct_successor_of(expected_manifest_sha256)
|
|
643
|
+
):
|
|
644
|
+
return resolved.manifest
|
|
645
|
+
if isinstance(error, GCSPreconditionFailed):
|
|
646
|
+
raise IngestionConflict(
|
|
647
|
+
"INGESTION_MANIFEST_FENCE",
|
|
648
|
+
"expected_manifest_sha256",
|
|
649
|
+
"does not match the current manifest",
|
|
650
|
+
) from None
|
|
651
|
+
raise _storage_refusal("ingestion_manifest", error) from error
|
|
652
|
+
|
|
653
|
+
def iter_chunk_parts(self, chunk: ChunkRef) -> Iterator[bytes]:
|
|
654
|
+
if not isinstance(chunk, ChunkRef):
|
|
655
|
+
raise IngestionError("INGESTION_CHUNK", "chunk", "must be a ChunkRef")
|
|
656
|
+
# Verify a complete generation before returning a readable generation. This matches the
|
|
657
|
+
# local backend's guarantee that consumers never act on bytes later found corrupt, while
|
|
658
|
+
# retaining a bounded two-pass remote read rather than materialising the object.
|
|
659
|
+
verified = self._verify_remote_chunk(chunk)
|
|
660
|
+
try:
|
|
661
|
+
readable = self.transport.get(
|
|
662
|
+
bucket=self.bucket,
|
|
663
|
+
name=self._chunk_name(chunk.content_sha256),
|
|
664
|
+
if_generation_match=verified.generation,
|
|
665
|
+
)
|
|
666
|
+
except GCSObjectNotFound as error:
|
|
667
|
+
raise IngestionCorrupt(
|
|
668
|
+
"INGESTION_BLOB_MISSING",
|
|
669
|
+
"chunk_ref.content_sha256",
|
|
670
|
+
"referenced Cloud Storage object is absent",
|
|
671
|
+
) from error
|
|
672
|
+
except GCSPreconditionFailed as error:
|
|
673
|
+
raise IngestionCorrupt(
|
|
674
|
+
"INGESTION_BLOB_GENERATION",
|
|
675
|
+
"chunk_ref.content_sha256",
|
|
676
|
+
"referenced Cloud Storage object changed during verified read",
|
|
677
|
+
) from error
|
|
678
|
+
except GCSStorageError as error:
|
|
679
|
+
raise _storage_refusal("chunk_ref.content_sha256", error) from error
|
|
680
|
+
try:
|
|
681
|
+
if readable.version != verified or readable.version.byte_length != chunk.byte_length:
|
|
682
|
+
raise IngestionCorrupt(
|
|
683
|
+
"INGESTION_BLOB_GENERATION",
|
|
684
|
+
"chunk_ref.content_sha256",
|
|
685
|
+
"referenced Cloud Storage generation changed during verified read",
|
|
686
|
+
)
|
|
687
|
+
yield from _bounded_parts(
|
|
688
|
+
readable.parts, expected_length=chunk.byte_length, field="chunk_ref.content_sha256"
|
|
689
|
+
)
|
|
690
|
+
finally:
|
|
691
|
+
readable.close()
|
|
692
|
+
|
|
693
|
+
def _put_successor(
|
|
694
|
+
self,
|
|
695
|
+
*,
|
|
696
|
+
run_id: str,
|
|
697
|
+
predecessor: _VersionedManifest,
|
|
698
|
+
successor: IngestionManifestV1,
|
|
699
|
+
expected_manifest_sha256: str,
|
|
700
|
+
chunk: ChunkRef,
|
|
701
|
+
) -> IngestionManifestV1:
|
|
702
|
+
raw = self._manifest_raw(successor)
|
|
703
|
+
try:
|
|
704
|
+
self._put(self._manifest_name(run_id), [raw], len(raw), predecessor.generation)
|
|
705
|
+
return successor
|
|
706
|
+
except (GCSPreconditionFailed, GCSStorageError) as error:
|
|
707
|
+
# Both a real CAS loss and a lost response are resolved by reading the current state.
|
|
708
|
+
# Returning a matching ordinal is safe even if another writer advanced farther: it is
|
|
709
|
+
# exactly the acknowledgement-safe replay rule the local store implements.
|
|
710
|
+
resolved = self._load_versioned(run_id)
|
|
711
|
+
if resolved is not None:
|
|
712
|
+
replay = _matching_replay(resolved.manifest, chunk)
|
|
713
|
+
if replay is not None:
|
|
714
|
+
return replay
|
|
715
|
+
if isinstance(error, GCSPreconditionFailed):
|
|
716
|
+
raise IngestionConflict(
|
|
717
|
+
"INGESTION_MANIFEST_FENCE",
|
|
718
|
+
"expected_manifest_sha256",
|
|
719
|
+
"does not match the current manifest",
|
|
720
|
+
) from None
|
|
721
|
+
raise _storage_refusal("ingestion_manifest", error) from error
|
|
722
|
+
|
|
723
|
+
def _ensure_chunk(self, chunk: ChunkRef, spooled: SpooledChunk) -> None:
|
|
724
|
+
name = self._chunk_name(chunk.content_sha256)
|
|
725
|
+
try:
|
|
726
|
+
parts = self._staged_spool_parts(spooled, chunk)
|
|
727
|
+
try:
|
|
728
|
+
self._put(name, parts, chunk.byte_length, "0")
|
|
729
|
+
finally:
|
|
730
|
+
parts.close()
|
|
731
|
+
except GCSPreconditionFailed:
|
|
732
|
+
pass
|
|
733
|
+
except GCSStorageError as error:
|
|
734
|
+
# An upload acknowledgement can disappear after GCS made the create durable. It is
|
|
735
|
+
# safe to continue only when the immutable object now verifies byte-for-byte.
|
|
736
|
+
try:
|
|
737
|
+
self._verify_remote_chunk(chunk)
|
|
738
|
+
except IngestionCorrupt:
|
|
739
|
+
raise _storage_refusal("chunk_ref.content_sha256", error) from error
|
|
740
|
+
return
|
|
741
|
+
self._verify_remote_chunk(chunk)
|
|
742
|
+
|
|
743
|
+
def _load_versioned(self, run_id: str) -> _VersionedManifest | None:
|
|
744
|
+
try:
|
|
745
|
+
stored = self.transport.get(bucket=self.bucket, name=self._manifest_name(run_id))
|
|
746
|
+
except GCSObjectNotFound:
|
|
747
|
+
return None
|
|
748
|
+
except GCSStorageError as error:
|
|
749
|
+
raise _storage_refusal("ingestion_manifest", error) from error
|
|
750
|
+
try:
|
|
751
|
+
if stored.version.byte_length > MAX_MANIFEST_BYTES:
|
|
752
|
+
raise IngestionCorrupt(
|
|
753
|
+
"INGESTION_FILE_LIMIT",
|
|
754
|
+
"ingestion_manifest",
|
|
755
|
+
"Cloud Storage manifest exceeds its limit",
|
|
756
|
+
)
|
|
757
|
+
raw = _collect_bounded(
|
|
758
|
+
stored.parts, maximum=MAX_MANIFEST_BYTES, field="ingestion_manifest"
|
|
759
|
+
)
|
|
760
|
+
finally:
|
|
761
|
+
stored.close()
|
|
762
|
+
if len(raw) != stored.version.byte_length:
|
|
763
|
+
raise IngestionCorrupt(
|
|
764
|
+
"INGESTION_MANIFEST_SHAPE",
|
|
765
|
+
"ingestion_manifest",
|
|
766
|
+
"Cloud Storage metadata length differs",
|
|
767
|
+
)
|
|
768
|
+
try:
|
|
769
|
+
manifest = self._parse_manifest_raw(raw)
|
|
770
|
+
except IngestionCorrupt:
|
|
771
|
+
raise
|
|
772
|
+
except (CanonicalJSONError, IngestionError) as error:
|
|
773
|
+
raise IngestionCorrupt(
|
|
774
|
+
"INGESTION_MANIFEST_CORRUPT",
|
|
775
|
+
"ingestion_manifest",
|
|
776
|
+
"is not a valid canonical manifest",
|
|
777
|
+
) from error
|
|
778
|
+
if manifest.run_id != run_id:
|
|
779
|
+
raise IngestionCorrupt(
|
|
780
|
+
"INGESTION_MANIFEST_RUN",
|
|
781
|
+
"ingestion_manifest.run_id",
|
|
782
|
+
"does not match its object coordinate",
|
|
783
|
+
)
|
|
784
|
+
return _VersionedManifest(
|
|
785
|
+
manifest=manifest,
|
|
786
|
+
generation=stored.version.generation,
|
|
787
|
+
raw_sha256=hashlib.sha256(raw).hexdigest(),
|
|
788
|
+
byte_length=stored.version.byte_length,
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
def _require_versioned(self, run_id: str) -> _VersionedManifest:
|
|
792
|
+
current = self._load_versioned(run_id)
|
|
793
|
+
if current is None:
|
|
794
|
+
raise IngestionConflict(
|
|
795
|
+
"INGESTION_RUN_MISSING", "ingestion_manifest.run_id", "does not exist"
|
|
796
|
+
)
|
|
797
|
+
return current
|
|
798
|
+
|
|
799
|
+
def _manifest_raw(self, manifest: IngestionManifestV1) -> bytes:
|
|
800
|
+
if self.delivery_binding is None:
|
|
801
|
+
return canonical_json_bytes(manifest.to_dict())
|
|
802
|
+
self.delivery_binding.require_manifest(manifest)
|
|
803
|
+
from mostlyright.data_harness.hosted_ingestion_contract import IngestionDeliveryManifestV1
|
|
804
|
+
|
|
805
|
+
return canonical_json_bytes(
|
|
806
|
+
IngestionDeliveryManifestV1(delivery=self.delivery_binding, manifest=manifest).to_dict()
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
def _parse_manifest_raw(self, raw: bytes) -> IngestionManifestV1:
|
|
810
|
+
document = parse_canonical_json(raw)
|
|
811
|
+
if self.delivery_binding is None:
|
|
812
|
+
return IngestionManifestV1.from_dict(document)
|
|
813
|
+
from mostlyright.data_harness.hosted_ingestion_contract import IngestionDeliveryManifestV1
|
|
814
|
+
|
|
815
|
+
envelope = IngestionDeliveryManifestV1.from_dict(document)
|
|
816
|
+
if envelope.delivery != self.delivery_binding:
|
|
817
|
+
raise IngestionCorrupt(
|
|
818
|
+
"INGESTION_DELIVERY_FENCE",
|
|
819
|
+
"delivery_manifest.delivery",
|
|
820
|
+
"does not match this delivery's fenced binding",
|
|
821
|
+
)
|
|
822
|
+
return envelope.manifest
|
|
823
|
+
|
|
824
|
+
def _verify_staged_spool(self, spooled: SpooledChunk, chunk: ChunkRef) -> None:
|
|
825
|
+
parts = self._staged_spool_parts(spooled, chunk)
|
|
826
|
+
try:
|
|
827
|
+
for _part in parts:
|
|
828
|
+
pass
|
|
829
|
+
finally:
|
|
830
|
+
parts.close()
|
|
831
|
+
|
|
832
|
+
def _staged_spool_parts(self, spooled: SpooledChunk, chunk: ChunkRef) -> _StagedSpoolParts:
|
|
833
|
+
return _StagedSpoolParts(spooled=spooled, chunk=chunk, spool_directory=self._staging._spool)
|
|
834
|
+
|
|
835
|
+
def _discard_staged_spool(self, spooled: SpooledChunk) -> None:
|
|
836
|
+
try:
|
|
837
|
+
info = spooled.path.stat(follow_symlinks=False)
|
|
838
|
+
if (
|
|
839
|
+
stat.S_ISREG(info.st_mode)
|
|
840
|
+
and info.st_dev == spooled.device
|
|
841
|
+
and info.st_ino == spooled.inode
|
|
842
|
+
and spooled.path.parent == self._staging._spool
|
|
843
|
+
):
|
|
844
|
+
spooled.path.unlink()
|
|
845
|
+
except OSError:
|
|
846
|
+
# A later bounded stale-spool sweep owns cleanup. The durable remote transition is
|
|
847
|
+
# already known, so surfacing a local cleanup failure would make its retry ambiguous.
|
|
848
|
+
pass
|
|
849
|
+
|
|
850
|
+
def _verify_remote_chunk(self, chunk: ChunkRef) -> GCSObjectVersion:
|
|
851
|
+
try:
|
|
852
|
+
stored = self.transport.get(
|
|
853
|
+
bucket=self.bucket, name=self._chunk_name(chunk.content_sha256)
|
|
854
|
+
)
|
|
855
|
+
except GCSObjectNotFound as error:
|
|
856
|
+
raise IngestionCorrupt(
|
|
857
|
+
"INGESTION_BLOB_MISSING",
|
|
858
|
+
"chunk_ref.content_sha256",
|
|
859
|
+
"referenced Cloud Storage object is absent",
|
|
860
|
+
) from error
|
|
861
|
+
except GCSStorageError as error:
|
|
862
|
+
raise _storage_refusal("chunk_ref.content_sha256", error) from error
|
|
863
|
+
digest = hashlib.sha256()
|
|
864
|
+
total = 0
|
|
865
|
+
try:
|
|
866
|
+
if stored.version.byte_length != chunk.byte_length:
|
|
867
|
+
raise IngestionCorrupt(
|
|
868
|
+
"INGESTION_BLOB_SHAPE",
|
|
869
|
+
"chunk_ref.content_sha256",
|
|
870
|
+
"object has an unexpected length",
|
|
871
|
+
)
|
|
872
|
+
for part in _bounded_parts(
|
|
873
|
+
stored.parts, expected_length=chunk.byte_length, field="chunk_ref.content_sha256"
|
|
874
|
+
):
|
|
875
|
+
digest.update(part)
|
|
876
|
+
total += len(part)
|
|
877
|
+
finally:
|
|
878
|
+
stored.close()
|
|
879
|
+
if total != chunk.byte_length or digest.hexdigest() != chunk.content_sha256:
|
|
880
|
+
raise IngestionCorrupt(
|
|
881
|
+
"INGESTION_BLOB_DIGEST",
|
|
882
|
+
"chunk_ref.content_sha256",
|
|
883
|
+
"object bytes differ from digest",
|
|
884
|
+
)
|
|
885
|
+
return stored.version
|
|
886
|
+
|
|
887
|
+
def _put(
|
|
888
|
+
self, name: str, parts: Iterable[bytes], byte_length: int, generation: str
|
|
889
|
+
) -> GCSObjectVersion:
|
|
890
|
+
try:
|
|
891
|
+
result = self.transport.put(
|
|
892
|
+
bucket=self.bucket,
|
|
893
|
+
name=name,
|
|
894
|
+
parts=parts,
|
|
895
|
+
byte_length=byte_length,
|
|
896
|
+
if_generation_match=generation,
|
|
897
|
+
)
|
|
898
|
+
except (GCSStorageError, IngestionError):
|
|
899
|
+
raise
|
|
900
|
+
except Exception as error: # pragma: no cover - protects adapter boundary only
|
|
901
|
+
raise GCSStorageError("Cloud Storage adapter failed") from error
|
|
902
|
+
if not isinstance(result, GCSObjectVersion) or result.byte_length != byte_length:
|
|
903
|
+
raise GCSStorageError("Cloud Storage returned an invalid object version")
|
|
904
|
+
return result
|
|
905
|
+
|
|
906
|
+
def _manifest_name(self, run_id: str) -> str:
|
|
907
|
+
return f"{self.manifest_prefix}/runs/{run_id}.json"
|
|
908
|
+
|
|
909
|
+
def _chunk_name(self, digest: str) -> str:
|
|
910
|
+
return f"{self.chunk_prefix}/chunks/{digest}.bin"
|
|
911
|
+
|
|
912
|
+
_validate_run_id = staticmethod(LocalIngestionStore._validate_run_id)
|
|
913
|
+
_validate_manifest_digest = staticmethod(LocalIngestionStore._validate_manifest_digest)
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
class _StagedSpoolParts:
|
|
917
|
+
"""An opened, checked staging spool held against the local stale reaper while uploading."""
|
|
918
|
+
|
|
919
|
+
def __init__(self, *, spooled: SpooledChunk, chunk: ChunkRef, spool_directory: Path) -> None:
|
|
920
|
+
if (
|
|
921
|
+
not isinstance(spooled, SpooledChunk)
|
|
922
|
+
or spooled.path.parent != spool_directory
|
|
923
|
+
or not spooled.path.name.startswith(".spool.")
|
|
924
|
+
or spooled.content_sha256 != chunk.content_sha256
|
|
925
|
+
or spooled.byte_length != chunk.byte_length
|
|
926
|
+
):
|
|
927
|
+
raise IngestionError(
|
|
928
|
+
"INGESTION_SPOOL_PATH", "spooled", "does not name this store's staging spool"
|
|
929
|
+
)
|
|
930
|
+
self._descriptor = -1
|
|
931
|
+
try:
|
|
932
|
+
self._descriptor = os.open(spooled.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
|
933
|
+
info = os.fstat(self._descriptor)
|
|
934
|
+
if (
|
|
935
|
+
not stat.S_ISREG(info.st_mode)
|
|
936
|
+
or info.st_nlink != 1
|
|
937
|
+
or info.st_dev != spooled.device
|
|
938
|
+
or info.st_ino != spooled.inode
|
|
939
|
+
or info.st_size != spooled.byte_length
|
|
940
|
+
):
|
|
941
|
+
raise IngestionError(
|
|
942
|
+
"INGESTION_SPOOL_PATH", "spooled.path", "changed after staging"
|
|
943
|
+
)
|
|
944
|
+
# This prevents a concurrent LocalIngestionStore reaper from deleting the completed
|
|
945
|
+
# temporary during a slow GCS upload. The global quota lock is intentionally not held
|
|
946
|
+
# across network I/O; the spool's own shared advisory lock is the narrow ownership bit.
|
|
947
|
+
fcntl.flock(self._descriptor, fcntl.LOCK_SH)
|
|
948
|
+
digest = hashlib.sha256()
|
|
949
|
+
total = 0
|
|
950
|
+
while raw := os.read(self._descriptor, GCS_READ_CHUNK_BYTES):
|
|
951
|
+
digest.update(raw)
|
|
952
|
+
total += len(raw)
|
|
953
|
+
if total != chunk.byte_length or digest.hexdigest() != chunk.content_sha256:
|
|
954
|
+
raise IngestionError(
|
|
955
|
+
"INGESTION_SPOOL_DIGEST", "spooled.path", "bytes differ from chunk"
|
|
956
|
+
)
|
|
957
|
+
os.lseek(self._descriptor, 0, os.SEEK_SET)
|
|
958
|
+
except OSError as error:
|
|
959
|
+
self.close()
|
|
960
|
+
raise IngestionError(
|
|
961
|
+
"INGESTION_SPOOL_IO", "spooled.path", "cannot stream staging spool"
|
|
962
|
+
) from error
|
|
963
|
+
except BaseException:
|
|
964
|
+
self.close()
|
|
965
|
+
raise
|
|
966
|
+
|
|
967
|
+
def __iter__(self) -> Iterator[bytes]:
|
|
968
|
+
try:
|
|
969
|
+
while raw := os.read(self._descriptor, GCS_READ_CHUNK_BYTES):
|
|
970
|
+
yield raw
|
|
971
|
+
except OSError as error:
|
|
972
|
+
raise IngestionError(
|
|
973
|
+
"INGESTION_SPOOL_IO", "spooled.path", "cannot stream staging spool"
|
|
974
|
+
) from error
|
|
975
|
+
finally:
|
|
976
|
+
self.close()
|
|
977
|
+
|
|
978
|
+
def close(self) -> None:
|
|
979
|
+
if self._descriptor >= 0:
|
|
980
|
+
descriptor = self._descriptor
|
|
981
|
+
self._descriptor = -1
|
|
982
|
+
os.close(descriptor)
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def _matching_replay(manifest: IngestionManifestV1, chunk: ChunkRef) -> IngestionManifestV1 | None:
|
|
986
|
+
if chunk.ordinal >= len(manifest.chunks):
|
|
987
|
+
return None
|
|
988
|
+
if manifest.chunks[chunk.ordinal] != chunk:
|
|
989
|
+
raise IngestionConflict(
|
|
990
|
+
"INGESTION_REPLAY_MISMATCH",
|
|
991
|
+
"chunk_ref.ordinal",
|
|
992
|
+
"already names different committed chunk bytes or metadata",
|
|
993
|
+
)
|
|
994
|
+
return manifest
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def _normalize_prefix(value: object) -> str:
|
|
998
|
+
if not isinstance(value, str) or not value or len(value) > 512:
|
|
999
|
+
raise IngestionError(
|
|
1000
|
+
"INGESTION_GCS_PREFIX", "gcs.prefix", "must be one bounded object prefix"
|
|
1001
|
+
)
|
|
1002
|
+
segments = value.split("/")
|
|
1003
|
+
if any(_PREFIX_SEGMENT.fullmatch(segment) is None for segment in segments):
|
|
1004
|
+
raise IngestionError(
|
|
1005
|
+
"INGESTION_GCS_PREFIX", "gcs.prefix", "must contain only safe nonempty path segments"
|
|
1006
|
+
)
|
|
1007
|
+
return value
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def _require_object_request(bucket: str, name: str, byte_length: int, generation: str) -> None:
|
|
1011
|
+
_require_get_request(bucket, name, generation if generation != "0" else None)
|
|
1012
|
+
if generation != "0" and _GENERATION.fullmatch(generation) is None:
|
|
1013
|
+
raise ValueError("if_generation_match must be zero or a positive generation")
|
|
1014
|
+
if isinstance(byte_length, bool) or not isinstance(byte_length, int) or byte_length < 0:
|
|
1015
|
+
raise ValueError("byte_length must be non-negative")
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def _require_get_request(bucket: str, name: str, generation: str | None) -> None:
|
|
1019
|
+
if not isinstance(bucket, str) or _BUCKET.fullmatch(bucket) is None:
|
|
1020
|
+
raise ValueError("bucket is invalid")
|
|
1021
|
+
if (
|
|
1022
|
+
not isinstance(name, str)
|
|
1023
|
+
or not name
|
|
1024
|
+
or name.startswith("/")
|
|
1025
|
+
or "//" in name
|
|
1026
|
+
or ".." in name
|
|
1027
|
+
):
|
|
1028
|
+
raise ValueError("object name is invalid")
|
|
1029
|
+
if generation is not None and _GENERATION.fullmatch(generation) is None:
|
|
1030
|
+
raise ValueError("if_generation_match must be a positive generation")
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def _part_view(value: object) -> memoryview:
|
|
1034
|
+
if not isinstance(value, (bytes, bytearray, memoryview)):
|
|
1035
|
+
raise GCSStorageError("object stream yielded a non-bytes part")
|
|
1036
|
+
view = memoryview(value)
|
|
1037
|
+
if not view.c_contiguous:
|
|
1038
|
+
raise GCSStorageError("object stream yielded a non-contiguous part")
|
|
1039
|
+
if view.format != "B" or view.ndim != 1:
|
|
1040
|
+
view = view.cast("B")
|
|
1041
|
+
return view
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def _bounded_parts(parts: Iterable[bytes], *, expected_length: int, field: str) -> Iterator[bytes]:
|
|
1045
|
+
total = 0
|
|
1046
|
+
for value in parts:
|
|
1047
|
+
view = _part_view(value)
|
|
1048
|
+
total += view.nbytes
|
|
1049
|
+
if total > expected_length:
|
|
1050
|
+
raise IngestionCorrupt(
|
|
1051
|
+
"INGESTION_BLOB_SHAPE", field, "object exceeds its metadata length"
|
|
1052
|
+
)
|
|
1053
|
+
yield bytes(view)
|
|
1054
|
+
if total != expected_length:
|
|
1055
|
+
raise IngestionCorrupt(
|
|
1056
|
+
"INGESTION_BLOB_SHAPE", field, "object is shorter than its metadata length"
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def _collect_bounded(parts: Iterable[bytes], *, maximum: int, field: str) -> bytes:
|
|
1061
|
+
result: list[bytes] = []
|
|
1062
|
+
total = 0
|
|
1063
|
+
for value in parts:
|
|
1064
|
+
view = _part_view(value)
|
|
1065
|
+
total += view.nbytes
|
|
1066
|
+
if total > maximum:
|
|
1067
|
+
raise IngestionCorrupt(
|
|
1068
|
+
"INGESTION_FILE_LIMIT", field, "object exceeds its permitted byte limit"
|
|
1069
|
+
)
|
|
1070
|
+
result.append(bytes(view))
|
|
1071
|
+
return b"".join(result)
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def _storage_refusal(path: str, error: GCSStorageError) -> IngestionError:
|
|
1075
|
+
return IngestionError("INGESTION_GCS_IO", path, "Cloud Storage request failed")
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def _read_bounded_response(response: http.client.HTTPResponse, maximum: int) -> bytes:
|
|
1079
|
+
chunks: list[bytes] = []
|
|
1080
|
+
total = 0
|
|
1081
|
+
while raw := response.read(min(GCS_READ_CHUNK_BYTES, maximum + 1)):
|
|
1082
|
+
total += len(raw)
|
|
1083
|
+
if total > maximum:
|
|
1084
|
+
raise GCSStorageError("Cloud Storage metadata response exceeds its limit")
|
|
1085
|
+
chunks.append(raw)
|
|
1086
|
+
return b"".join(chunks)
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
class _HTTPResponseParts:
|
|
1090
|
+
"""One response body with explicit cleanup even when callers reject it before iteration."""
|
|
1091
|
+
|
|
1092
|
+
def __init__(
|
|
1093
|
+
self,
|
|
1094
|
+
response: http.client.HTTPResponse,
|
|
1095
|
+
connection: http.client.HTTPSConnection,
|
|
1096
|
+
expected_length: int,
|
|
1097
|
+
) -> None:
|
|
1098
|
+
self._response = response
|
|
1099
|
+
self._connection = connection
|
|
1100
|
+
self._expected_length = expected_length
|
|
1101
|
+
self._closed = False
|
|
1102
|
+
|
|
1103
|
+
def __iter__(self) -> Iterator[bytes]:
|
|
1104
|
+
remaining = self._expected_length
|
|
1105
|
+
try:
|
|
1106
|
+
while remaining:
|
|
1107
|
+
raw = self._response.read(min(GCS_READ_CHUNK_BYTES, remaining))
|
|
1108
|
+
if not raw:
|
|
1109
|
+
raise GCSStorageError("Cloud Storage media response ended early")
|
|
1110
|
+
remaining -= len(raw)
|
|
1111
|
+
yield raw
|
|
1112
|
+
if self._response.read(1):
|
|
1113
|
+
raise GCSStorageError("Cloud Storage media response exceeded metadata length")
|
|
1114
|
+
except (OSError, http.client.HTTPException) as error:
|
|
1115
|
+
raise GCSStorageError("Cloud Storage media response failed") from error
|
|
1116
|
+
finally:
|
|
1117
|
+
self.close()
|
|
1118
|
+
|
|
1119
|
+
def close(self) -> None:
|
|
1120
|
+
if not self._closed:
|
|
1121
|
+
self._closed = True
|
|
1122
|
+
self._response.close()
|
|
1123
|
+
self._connection.close()
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def _raise_for_status(
|
|
1127
|
+
status: int, name: str, *, successful_statuses: tuple[int, ...] = (200,)
|
|
1128
|
+
) -> None:
|
|
1129
|
+
if status in successful_statuses:
|
|
1130
|
+
return
|
|
1131
|
+
if status == 404:
|
|
1132
|
+
raise GCSObjectNotFound("Cloud Storage object does not exist")
|
|
1133
|
+
if status == 412:
|
|
1134
|
+
raise GCSPreconditionFailed("Cloud Storage object generation precondition failed")
|
|
1135
|
+
raise GCSStorageError("Cloud Storage returned an unsuccessful status")
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def _parse_object_version(
|
|
1139
|
+
raw: bytes, name: str, *, expected_size: int | None = None
|
|
1140
|
+
) -> GCSObjectVersion:
|
|
1141
|
+
try:
|
|
1142
|
+
document = parse_json(raw)
|
|
1143
|
+
except CanonicalJSONError as error:
|
|
1144
|
+
raise GCSStorageError("Cloud Storage returned invalid object metadata") from error
|
|
1145
|
+
if not isinstance(document, dict):
|
|
1146
|
+
raise GCSStorageError("Cloud Storage returned invalid object metadata")
|
|
1147
|
+
returned_name = document.get("name")
|
|
1148
|
+
generation = document.get("generation")
|
|
1149
|
+
size = document.get("size")
|
|
1150
|
+
if returned_name != name or not isinstance(generation, str) or not isinstance(size, str):
|
|
1151
|
+
raise GCSStorageError("Cloud Storage object metadata does not bind the requested object")
|
|
1152
|
+
try:
|
|
1153
|
+
parsed_size = int(size)
|
|
1154
|
+
except ValueError as error:
|
|
1155
|
+
raise GCSStorageError("Cloud Storage object metadata has an invalid size") from error
|
|
1156
|
+
try:
|
|
1157
|
+
result = GCSObjectVersion(generation=generation, byte_length=parsed_size)
|
|
1158
|
+
except ValueError as error:
|
|
1159
|
+
raise GCSStorageError("Cloud Storage object metadata is invalid") from error
|
|
1160
|
+
if expected_size is not None and result.byte_length != expected_size:
|
|
1161
|
+
raise GCSStorageError("Cloud Storage upload length differs from its response")
|
|
1162
|
+
return result
|