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,885 @@
|
|
|
1
|
+
"""Fenced ingestion-store protocol and a crash-safe local implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import fcntl
|
|
7
|
+
import hashlib
|
|
8
|
+
import os
|
|
9
|
+
import secrets
|
|
10
|
+
import stat
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Iterable, Iterator
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Protocol, runtime_checkable
|
|
16
|
+
|
|
17
|
+
from mostlyright.data_harness.canonical import (
|
|
18
|
+
CanonicalJSONError,
|
|
19
|
+
canonical_json_bytes,
|
|
20
|
+
parse_canonical_json,
|
|
21
|
+
)
|
|
22
|
+
from mostlyright.data_harness.ingestion.contracts import (
|
|
23
|
+
ChunkRef,
|
|
24
|
+
IngestionConflict,
|
|
25
|
+
IngestionCorrupt,
|
|
26
|
+
IngestionError,
|
|
27
|
+
IngestionManifestV1,
|
|
28
|
+
)
|
|
29
|
+
from mostlyright.data_harness.ingestion.faults import DeterministicFaultInjector
|
|
30
|
+
from mostlyright.data_harness.ingestion.spool import SpooledChunk, spool_hash_stream
|
|
31
|
+
|
|
32
|
+
MAX_MANIFEST_BYTES = 16 * 1024 * 1024
|
|
33
|
+
DEFAULT_MAX_SPOOL_BYTES = 256 * 1024 * 1024
|
|
34
|
+
DEFAULT_MAX_SPOOL_TOTAL_BYTES = 512 * 1024 * 1024
|
|
35
|
+
DEFAULT_MAX_STORE_BYTES = 4 * 1024 * 1024 * 1024
|
|
36
|
+
DEFAULT_STALE_SPOOL_AGE_SECONDS = 60 * 60
|
|
37
|
+
_READ_CHUNK_SIZE = 1024 * 1024
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@runtime_checkable
|
|
41
|
+
class IngestionStore(Protocol):
|
|
42
|
+
"""Durable source-pinned state transitions required by the ingestion engine.
|
|
43
|
+
|
|
44
|
+
A caller supplies the digest it observed before a transition. Implementations must
|
|
45
|
+
either apply that exact transition or report a conflict; an immediate replay of an
|
|
46
|
+
already applied append/finalization may instead return the committed successor.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def create(self, manifest: IngestionManifestV1) -> IngestionManifestV1:
|
|
50
|
+
"""Create an initial manifest, returning it on exact create replay."""
|
|
51
|
+
|
|
52
|
+
def load(self, run_id: str) -> IngestionManifestV1 | None:
|
|
53
|
+
"""Return the current manifest for a run, or ``None`` if it does not exist."""
|
|
54
|
+
|
|
55
|
+
def append_stream(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
run_id: str,
|
|
59
|
+
expected_manifest_sha256: str,
|
|
60
|
+
chunk: ChunkRef,
|
|
61
|
+
parts: Iterable[bytes | bytearray | memoryview],
|
|
62
|
+
) -> IngestionManifestV1:
|
|
63
|
+
"""Durably spool exact stream bytes then append their reference under a manifest fence."""
|
|
64
|
+
|
|
65
|
+
def finalize(self, *, run_id: str, expected_manifest_sha256: str) -> IngestionManifestV1:
|
|
66
|
+
"""Freeze a gap-free sequence under a manifest fence."""
|
|
67
|
+
|
|
68
|
+
def iter_chunk_parts(self, chunk: ChunkRef) -> Iterator[bytes]:
|
|
69
|
+
"""Yield one verified immutable chunk without materializing its full byte payload."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class LocalIngestionStore:
|
|
74
|
+
"""Private-directory reference store with durable immutable blobs and atomic manifests.
|
|
75
|
+
|
|
76
|
+
The only irreversible ordering is ``fsync(blob) -> link immutable blob -> fsync(chunk
|
|
77
|
+
directory) -> replace+fsync manifest``. A crash before the final replacement leaves an
|
|
78
|
+
unreferenced immutable blob, which is safe to reuse; a crash after it leaves a valid
|
|
79
|
+
successor manifest, which an idempotent retry recognizes through its predecessor link.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
root: Path
|
|
83
|
+
fault_injector: DeterministicFaultInjector | None = None
|
|
84
|
+
maximum_spool_bytes: int = DEFAULT_MAX_SPOOL_BYTES
|
|
85
|
+
maximum_spool_total_bytes: int = DEFAULT_MAX_SPOOL_TOTAL_BYTES
|
|
86
|
+
maximum_store_bytes: int = DEFAULT_MAX_STORE_BYTES
|
|
87
|
+
stale_spool_age_seconds: int = DEFAULT_STALE_SPOOL_AGE_SECONDS
|
|
88
|
+
|
|
89
|
+
def __post_init__(self) -> None:
|
|
90
|
+
self.root = Path(self.root)
|
|
91
|
+
if not self.root.is_absolute() or self.root.is_symlink() or not self.root.is_dir():
|
|
92
|
+
raise IngestionError(
|
|
93
|
+
"INGESTION_STORE_ROOT",
|
|
94
|
+
"ingestion_store.root",
|
|
95
|
+
"must be an existing absolute directory",
|
|
96
|
+
)
|
|
97
|
+
self._require_private_directory(self.root, "ingestion_store.root")
|
|
98
|
+
self._validate_limits()
|
|
99
|
+
for name in ("runs", "chunks", "spool"):
|
|
100
|
+
path = self.root / name
|
|
101
|
+
try:
|
|
102
|
+
path.mkdir(mode=0o700)
|
|
103
|
+
except FileExistsError:
|
|
104
|
+
pass
|
|
105
|
+
except OSError as error:
|
|
106
|
+
raise IngestionError(
|
|
107
|
+
"INGESTION_STORE_IO", "ingestion_store.root", "could not create store directory"
|
|
108
|
+
) from error
|
|
109
|
+
self._require_private_directory(path, f"ingestion_store.{name}")
|
|
110
|
+
self._runs = self.root / "runs"
|
|
111
|
+
self._chunks = self.root / "chunks"
|
|
112
|
+
self._spool = self.root / "spool"
|
|
113
|
+
self._lock_path = self.root / ".ingestion.lock"
|
|
114
|
+
self._checkpoint("store.before_root_fsync")
|
|
115
|
+
self._fsync_directory(self.root, "ingestion_store.root")
|
|
116
|
+
self._checkpoint("store.after_root_fsync")
|
|
117
|
+
with self._locked():
|
|
118
|
+
self._reap_stale_spools_locked(now=time.time())
|
|
119
|
+
|
|
120
|
+
def spool(
|
|
121
|
+
self,
|
|
122
|
+
parts: Iterable[bytes | bytearray | memoryview],
|
|
123
|
+
*,
|
|
124
|
+
maximum_bytes: int | None = None,
|
|
125
|
+
) -> SpooledChunk:
|
|
126
|
+
"""Spool a byte stream once; this does not mutate a run manifest."""
|
|
127
|
+
|
|
128
|
+
limit = self.maximum_spool_bytes if maximum_bytes is None else maximum_bytes
|
|
129
|
+
if (
|
|
130
|
+
isinstance(limit, bool)
|
|
131
|
+
or not isinstance(limit, int)
|
|
132
|
+
or limit < 0
|
|
133
|
+
or limit > self.maximum_spool_bytes
|
|
134
|
+
):
|
|
135
|
+
raise IngestionError(
|
|
136
|
+
"INGESTION_SPOOL_LIMIT",
|
|
137
|
+
"spool.maximum_bytes",
|
|
138
|
+
"must not exceed the local per-spool quota",
|
|
139
|
+
)
|
|
140
|
+
return spool_hash_stream(
|
|
141
|
+
self._spool,
|
|
142
|
+
parts,
|
|
143
|
+
maximum_bytes=limit,
|
|
144
|
+
fault_injector=self.fault_injector,
|
|
145
|
+
reserve_bytes=self._reserve_spool_bytes,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def reap_stale_spools(self, *, now: float | None = None) -> int:
|
|
149
|
+
"""Reclaim abandoned private temporary spools without touching a live writer."""
|
|
150
|
+
|
|
151
|
+
current = time.time() if now is None else now
|
|
152
|
+
if not isinstance(current, (int, float)) or current < 0:
|
|
153
|
+
raise IngestionError("INGESTION_SPOOL_CLOCK", "spool.now", "must be a Unix timestamp")
|
|
154
|
+
with self._locked():
|
|
155
|
+
return self._reap_stale_spools_locked(now=float(current))
|
|
156
|
+
|
|
157
|
+
def create(self, manifest: IngestionManifestV1) -> IngestionManifestV1:
|
|
158
|
+
if (
|
|
159
|
+
manifest.state != "open"
|
|
160
|
+
or manifest.chunks
|
|
161
|
+
or manifest.previous_manifest_sha256 is not None
|
|
162
|
+
):
|
|
163
|
+
raise IngestionConflict(
|
|
164
|
+
"INGESTION_CREATE_STATE",
|
|
165
|
+
"ingestion_manifest",
|
|
166
|
+
"only an initial open manifest may be created",
|
|
167
|
+
)
|
|
168
|
+
with self._locked():
|
|
169
|
+
current = self._load_locked(manifest.run_id)
|
|
170
|
+
if current is not None:
|
|
171
|
+
if current.digest == manifest.digest:
|
|
172
|
+
return current
|
|
173
|
+
raise IngestionConflict(
|
|
174
|
+
"INGESTION_RUN_EXISTS",
|
|
175
|
+
"ingestion_manifest.run_id",
|
|
176
|
+
"already has a different manifest",
|
|
177
|
+
)
|
|
178
|
+
self._replace_manifest_locked(manifest, checkpoint_prefix="store.create")
|
|
179
|
+
return manifest
|
|
180
|
+
|
|
181
|
+
def load(self, run_id: str) -> IngestionManifestV1 | None:
|
|
182
|
+
self._validate_run_id(run_id)
|
|
183
|
+
with self._locked():
|
|
184
|
+
return self._load_locked(run_id)
|
|
185
|
+
|
|
186
|
+
def append_stream(
|
|
187
|
+
self,
|
|
188
|
+
*,
|
|
189
|
+
run_id: str,
|
|
190
|
+
expected_manifest_sha256: str,
|
|
191
|
+
chunk: ChunkRef,
|
|
192
|
+
parts: Iterable[bytes | bytearray | memoryview],
|
|
193
|
+
) -> IngestionManifestV1:
|
|
194
|
+
"""Spool one replayable stream then perform the fenced durable append."""
|
|
195
|
+
|
|
196
|
+
spooled = self.spool(parts)
|
|
197
|
+
return self.append_spooled(
|
|
198
|
+
run_id=run_id,
|
|
199
|
+
expected_manifest_sha256=expected_manifest_sha256,
|
|
200
|
+
chunk=chunk,
|
|
201
|
+
spooled=spooled,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
def append_spooled(
|
|
205
|
+
self,
|
|
206
|
+
*,
|
|
207
|
+
run_id: str,
|
|
208
|
+
expected_manifest_sha256: str,
|
|
209
|
+
chunk: ChunkRef,
|
|
210
|
+
spooled: SpooledChunk,
|
|
211
|
+
) -> IngestionManifestV1:
|
|
212
|
+
self._validate_run_id(run_id)
|
|
213
|
+
self._validate_manifest_digest(expected_manifest_sha256)
|
|
214
|
+
if not isinstance(chunk, ChunkRef):
|
|
215
|
+
raise IngestionError("INGESTION_CHUNK", "chunk", "must be a ChunkRef")
|
|
216
|
+
self._verify_spool(spooled, chunk)
|
|
217
|
+
try:
|
|
218
|
+
with self._locked():
|
|
219
|
+
current = self._require_manifest_locked(run_id)
|
|
220
|
+
if chunk.ordinal < len(current.chunks):
|
|
221
|
+
if current.chunks[chunk.ordinal] != chunk:
|
|
222
|
+
raise IngestionConflict(
|
|
223
|
+
"INGESTION_REPLAY_MISMATCH",
|
|
224
|
+
"chunk_ref.ordinal",
|
|
225
|
+
"already names different committed chunk bytes or metadata",
|
|
226
|
+
)
|
|
227
|
+
result = current
|
|
228
|
+
else:
|
|
229
|
+
if current.is_finalized:
|
|
230
|
+
raise IngestionConflict(
|
|
231
|
+
"INGESTION_FINALIZED",
|
|
232
|
+
"ingestion_manifest.state",
|
|
233
|
+
"cannot append to a finalized run",
|
|
234
|
+
)
|
|
235
|
+
if current.digest != expected_manifest_sha256:
|
|
236
|
+
raise IngestionConflict(
|
|
237
|
+
"INGESTION_MANIFEST_FENCE",
|
|
238
|
+
"expected_manifest_sha256",
|
|
239
|
+
"does not match the current manifest",
|
|
240
|
+
)
|
|
241
|
+
successor = current.append(chunk)
|
|
242
|
+
self._publish_spooled_blob_locked(spooled, chunk)
|
|
243
|
+
self._checkpoint("store.after_blob_publish")
|
|
244
|
+
self._replace_manifest_locked(successor, checkpoint_prefix="store.append")
|
|
245
|
+
result = successor
|
|
246
|
+
except IngestionConflict:
|
|
247
|
+
# No blob is published until after all logical fences (including source drift) have
|
|
248
|
+
# passed. A conflict therefore owns no durable work and can release its temporary.
|
|
249
|
+
self._discard_spool(spooled)
|
|
250
|
+
raise
|
|
251
|
+
self._discard_spool(spooled)
|
|
252
|
+
return result
|
|
253
|
+
|
|
254
|
+
def finalize(self, *, run_id: str, expected_manifest_sha256: str) -> IngestionManifestV1:
|
|
255
|
+
self._validate_run_id(run_id)
|
|
256
|
+
self._validate_manifest_digest(expected_manifest_sha256)
|
|
257
|
+
with self._locked():
|
|
258
|
+
current = self._require_manifest_locked(run_id)
|
|
259
|
+
if current.is_finalized:
|
|
260
|
+
if current.digest == expected_manifest_sha256 or current.is_direct_successor_of(
|
|
261
|
+
expected_manifest_sha256
|
|
262
|
+
):
|
|
263
|
+
return current
|
|
264
|
+
raise IngestionConflict(
|
|
265
|
+
"INGESTION_MANIFEST_FENCE",
|
|
266
|
+
"expected_manifest_sha256",
|
|
267
|
+
"does not name the finalization predecessor",
|
|
268
|
+
)
|
|
269
|
+
if current.digest != expected_manifest_sha256:
|
|
270
|
+
raise IngestionConflict(
|
|
271
|
+
"INGESTION_MANIFEST_FENCE",
|
|
272
|
+
"expected_manifest_sha256",
|
|
273
|
+
"does not match the current manifest",
|
|
274
|
+
)
|
|
275
|
+
# A Merkle commitment protects the ordered *references*. Before making that
|
|
276
|
+
# commitment externally consumable, prove every local immutable object still has
|
|
277
|
+
# the exact bytes those references name.
|
|
278
|
+
for chunk in current.chunks:
|
|
279
|
+
self._verify_chunk_file(self._chunk_path(chunk.content_sha256), chunk)
|
|
280
|
+
successor = current.finalize()
|
|
281
|
+
self._replace_manifest_locked(successor, checkpoint_prefix="store.finalize")
|
|
282
|
+
return successor
|
|
283
|
+
|
|
284
|
+
def iter_chunk_parts(self, chunk: ChunkRef) -> Iterator[bytes]:
|
|
285
|
+
"""Yield verified immutable bytes in bounded pieces without materializing a chunk.
|
|
286
|
+
|
|
287
|
+
The complete object is hashed once before yielding from the still-open descriptor.
|
|
288
|
+
Immutable-object publication means that descriptor is then stable after the store
|
|
289
|
+
lock is released, without holding writers hostage for the duration of a large read.
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
if not isinstance(chunk, ChunkRef):
|
|
293
|
+
raise IngestionError("INGESTION_CHUNK", "chunk", "must be a ChunkRef")
|
|
294
|
+
descriptor = -1
|
|
295
|
+
try:
|
|
296
|
+
with self._locked():
|
|
297
|
+
path = self._chunk_path(chunk.content_sha256)
|
|
298
|
+
try:
|
|
299
|
+
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
|
300
|
+
except FileNotFoundError as error:
|
|
301
|
+
raise IngestionCorrupt(
|
|
302
|
+
"INGESTION_BLOB_MISSING",
|
|
303
|
+
"chunk_ref.content_sha256",
|
|
304
|
+
"referenced blob is absent",
|
|
305
|
+
) from error
|
|
306
|
+
except OSError as error:
|
|
307
|
+
raise IngestionCorrupt(
|
|
308
|
+
"INGESTION_BLOB_IO",
|
|
309
|
+
"chunk_ref.content_sha256",
|
|
310
|
+
"referenced blob cannot be opened",
|
|
311
|
+
) from error
|
|
312
|
+
info = os.fstat(descriptor)
|
|
313
|
+
if not stat.S_ISREG(info.st_mode) or info.st_size != chunk.byte_length:
|
|
314
|
+
raise IngestionCorrupt(
|
|
315
|
+
"INGESTION_BLOB_SHAPE",
|
|
316
|
+
"chunk_ref.content_sha256",
|
|
317
|
+
"referenced blob is not the expected regular-file length",
|
|
318
|
+
)
|
|
319
|
+
if self._hash_descriptor(descriptor) != chunk.content_sha256:
|
|
320
|
+
raise IngestionCorrupt(
|
|
321
|
+
"INGESTION_BLOB_DIGEST",
|
|
322
|
+
"chunk_ref.content_sha256",
|
|
323
|
+
"referenced blob hash differs",
|
|
324
|
+
)
|
|
325
|
+
os.lseek(descriptor, 0, os.SEEK_SET)
|
|
326
|
+
while raw := os.read(descriptor, _READ_CHUNK_SIZE):
|
|
327
|
+
yield raw
|
|
328
|
+
finally:
|
|
329
|
+
if descriptor >= 0:
|
|
330
|
+
os.close(descriptor)
|
|
331
|
+
|
|
332
|
+
def read_chunk(self, chunk: ChunkRef, *, maximum_bytes: int = 64 * 1024 * 1024) -> bytes:
|
|
333
|
+
"""Read a small verified blob; streaming callers must use :meth:`iter_chunk_parts`."""
|
|
334
|
+
|
|
335
|
+
if not isinstance(chunk, ChunkRef):
|
|
336
|
+
raise IngestionError("INGESTION_CHUNK", "chunk", "must be a ChunkRef")
|
|
337
|
+
if (
|
|
338
|
+
isinstance(maximum_bytes, bool)
|
|
339
|
+
or not isinstance(maximum_bytes, int)
|
|
340
|
+
or maximum_bytes < 0
|
|
341
|
+
or chunk.byte_length > maximum_bytes
|
|
342
|
+
):
|
|
343
|
+
raise IngestionError(
|
|
344
|
+
"INGESTION_READ_LIMIT",
|
|
345
|
+
"chunk_ref.byte_length",
|
|
346
|
+
"requires bounded streaming access",
|
|
347
|
+
)
|
|
348
|
+
return b"".join(self.iter_chunk_parts(chunk))
|
|
349
|
+
|
|
350
|
+
@contextlib.contextmanager
|
|
351
|
+
def _locked(self) -> Iterator[None]:
|
|
352
|
+
descriptor = -1
|
|
353
|
+
try:
|
|
354
|
+
# The lock coordinate itself must not be creatable or replaceable by another local
|
|
355
|
+
# user. Check its parent before opening and then check the opened inode before flock.
|
|
356
|
+
self._require_private_directory(self.root, "ingestion_store.root")
|
|
357
|
+
descriptor = os.open(
|
|
358
|
+
self._lock_path,
|
|
359
|
+
os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0),
|
|
360
|
+
0o600,
|
|
361
|
+
)
|
|
362
|
+
info = os.fstat(descriptor)
|
|
363
|
+
if (
|
|
364
|
+
not stat.S_ISREG(info.st_mode)
|
|
365
|
+
or info.st_nlink != 1
|
|
366
|
+
or info.st_uid != os.geteuid()
|
|
367
|
+
or stat.S_IMODE(info.st_mode) != 0o600
|
|
368
|
+
):
|
|
369
|
+
raise IngestionCorrupt(
|
|
370
|
+
"INGESTION_LOCK_PATH",
|
|
371
|
+
"ingestion_store.lock",
|
|
372
|
+
"must be an effective-user-owned mode-0600 regular file",
|
|
373
|
+
)
|
|
374
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX)
|
|
375
|
+
self._require_private_directory(self.root, "ingestion_store.root")
|
|
376
|
+
self._require_private_directory(self._runs, "ingestion_store.runs")
|
|
377
|
+
self._require_private_directory(self._chunks, "ingestion_store.chunks")
|
|
378
|
+
self._require_private_directory(self._spool, "ingestion_store.spool")
|
|
379
|
+
yield
|
|
380
|
+
except OSError as error:
|
|
381
|
+
raise IngestionError(
|
|
382
|
+
"INGESTION_STORE_IO", "ingestion_store.lock", "lock operation failed"
|
|
383
|
+
) from error
|
|
384
|
+
finally:
|
|
385
|
+
if descriptor >= 0:
|
|
386
|
+
os.close(descriptor)
|
|
387
|
+
|
|
388
|
+
def _load_locked(self, run_id: str) -> IngestionManifestV1 | None:
|
|
389
|
+
path = self._manifest_path(run_id)
|
|
390
|
+
try:
|
|
391
|
+
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
|
392
|
+
except FileNotFoundError:
|
|
393
|
+
return None
|
|
394
|
+
except OSError as error:
|
|
395
|
+
raise IngestionCorrupt(
|
|
396
|
+
"INGESTION_MANIFEST_IO", "ingestion_manifest", "manifest cannot be opened"
|
|
397
|
+
) from error
|
|
398
|
+
try:
|
|
399
|
+
info = os.fstat(descriptor)
|
|
400
|
+
if (
|
|
401
|
+
not stat.S_ISREG(info.st_mode)
|
|
402
|
+
or info.st_nlink != 1
|
|
403
|
+
or info.st_size > MAX_MANIFEST_BYTES
|
|
404
|
+
):
|
|
405
|
+
raise IngestionCorrupt(
|
|
406
|
+
"INGESTION_MANIFEST_SHAPE",
|
|
407
|
+
"ingestion_manifest",
|
|
408
|
+
"manifest has an unsafe file shape",
|
|
409
|
+
)
|
|
410
|
+
raw = self._read_descriptor(descriptor, maximum=MAX_MANIFEST_BYTES)
|
|
411
|
+
finally:
|
|
412
|
+
os.close(descriptor)
|
|
413
|
+
try:
|
|
414
|
+
manifest = IngestionManifestV1.from_dict(parse_canonical_json(raw))
|
|
415
|
+
except (CanonicalJSONError, IngestionError) as error:
|
|
416
|
+
raise IngestionCorrupt(
|
|
417
|
+
"INGESTION_MANIFEST_CORRUPT",
|
|
418
|
+
"ingestion_manifest",
|
|
419
|
+
"is not a valid canonical manifest",
|
|
420
|
+
) from error
|
|
421
|
+
if manifest.run_id != run_id:
|
|
422
|
+
raise IngestionCorrupt(
|
|
423
|
+
"INGESTION_MANIFEST_RUN",
|
|
424
|
+
"ingestion_manifest.run_id",
|
|
425
|
+
"does not match its file coordinate",
|
|
426
|
+
)
|
|
427
|
+
return manifest
|
|
428
|
+
|
|
429
|
+
def _require_manifest_locked(self, run_id: str) -> IngestionManifestV1:
|
|
430
|
+
current = self._load_locked(run_id)
|
|
431
|
+
if current is None:
|
|
432
|
+
raise IngestionConflict(
|
|
433
|
+
"INGESTION_RUN_MISSING", "ingestion_manifest.run_id", "does not exist"
|
|
434
|
+
)
|
|
435
|
+
return current
|
|
436
|
+
|
|
437
|
+
def _publish_spooled_blob_locked(self, spooled: SpooledChunk, chunk: ChunkRef) -> None:
|
|
438
|
+
self._verify_spool(spooled, chunk)
|
|
439
|
+
destination = self._chunk_path(chunk.content_sha256)
|
|
440
|
+
if destination.exists():
|
|
441
|
+
self._verify_chunk_file(destination, chunk)
|
|
442
|
+
return
|
|
443
|
+
self._checkpoint("store.before_blob_link")
|
|
444
|
+
try:
|
|
445
|
+
os.link(spooled.path, destination, follow_symlinks=False)
|
|
446
|
+
except FileExistsError:
|
|
447
|
+
self._verify_chunk_file(destination, chunk)
|
|
448
|
+
return
|
|
449
|
+
except OSError as error:
|
|
450
|
+
raise IngestionError(
|
|
451
|
+
"INGESTION_BLOB_IO", "chunk_ref.content_sha256", "could not publish immutable blob"
|
|
452
|
+
) from error
|
|
453
|
+
try:
|
|
454
|
+
self._verify_chunk_file(destination, chunk)
|
|
455
|
+
directory_descriptor = os.open(
|
|
456
|
+
self._chunks, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
457
|
+
)
|
|
458
|
+
try:
|
|
459
|
+
os.fsync(directory_descriptor)
|
|
460
|
+
finally:
|
|
461
|
+
os.close(directory_descriptor)
|
|
462
|
+
except OSError as error:
|
|
463
|
+
raise IngestionError(
|
|
464
|
+
"INGESTION_BLOB_IO", "chunk_ref.content_sha256", "could not sync blob directory"
|
|
465
|
+
) from error
|
|
466
|
+
|
|
467
|
+
def _replace_manifest_locked(
|
|
468
|
+
self, manifest: IngestionManifestV1, *, checkpoint_prefix: str
|
|
469
|
+
) -> None:
|
|
470
|
+
raw = canonical_json_bytes(manifest.to_dict())
|
|
471
|
+
if len(raw) > MAX_MANIFEST_BYTES:
|
|
472
|
+
raise IngestionError(
|
|
473
|
+
"INGESTION_MANIFEST_LIMIT", "ingestion_manifest", "exceeds the manifest byte limit"
|
|
474
|
+
)
|
|
475
|
+
self._ensure_store_capacity_locked(len(raw))
|
|
476
|
+
destination = self._manifest_path(manifest.run_id)
|
|
477
|
+
temporary = self._runs / f".manifest.{os.getpid()}.{secrets.token_hex(16)}.tmp"
|
|
478
|
+
descriptor = -1
|
|
479
|
+
replaced = False
|
|
480
|
+
try:
|
|
481
|
+
descriptor = os.open(
|
|
482
|
+
temporary,
|
|
483
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
|
|
484
|
+
0o600,
|
|
485
|
+
)
|
|
486
|
+
self._write_all(descriptor, raw)
|
|
487
|
+
os.fsync(descriptor)
|
|
488
|
+
os.close(descriptor)
|
|
489
|
+
descriptor = -1
|
|
490
|
+
self._checkpoint(f"{checkpoint_prefix}.before_manifest_replace")
|
|
491
|
+
os.replace(temporary, destination)
|
|
492
|
+
replaced = True
|
|
493
|
+
directory_descriptor = os.open(self._runs, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
494
|
+
try:
|
|
495
|
+
os.fsync(directory_descriptor)
|
|
496
|
+
finally:
|
|
497
|
+
os.close(directory_descriptor)
|
|
498
|
+
self._checkpoint(f"{checkpoint_prefix}.after_manifest_replace")
|
|
499
|
+
except OSError as error:
|
|
500
|
+
raise IngestionError(
|
|
501
|
+
"INGESTION_MANIFEST_IO",
|
|
502
|
+
"ingestion_manifest",
|
|
503
|
+
"could not atomically replace manifest",
|
|
504
|
+
) from error
|
|
505
|
+
finally:
|
|
506
|
+
if descriptor >= 0:
|
|
507
|
+
os.close(descriptor)
|
|
508
|
+
if not replaced:
|
|
509
|
+
try:
|
|
510
|
+
temporary.unlink(missing_ok=True)
|
|
511
|
+
except OSError:
|
|
512
|
+
pass
|
|
513
|
+
|
|
514
|
+
def _verify_spool(self, spooled: SpooledChunk, chunk: ChunkRef) -> None:
|
|
515
|
+
if not isinstance(spooled, SpooledChunk):
|
|
516
|
+
raise IngestionError("INGESTION_SPOOL", "spooled", "must be a SpooledChunk")
|
|
517
|
+
if spooled.path.parent != self._spool or spooled.path.name.startswith(".") is False:
|
|
518
|
+
raise IngestionError(
|
|
519
|
+
"INGESTION_SPOOL_PATH",
|
|
520
|
+
"spooled.path",
|
|
521
|
+
"does not belong to this store's private spool",
|
|
522
|
+
)
|
|
523
|
+
if (
|
|
524
|
+
spooled.content_sha256 != chunk.content_sha256
|
|
525
|
+
or spooled.byte_length != chunk.byte_length
|
|
526
|
+
):
|
|
527
|
+
raise IngestionError(
|
|
528
|
+
"INGESTION_SPOOL_DIGEST", "spooled", "does not match the proposed chunk reference"
|
|
529
|
+
)
|
|
530
|
+
try:
|
|
531
|
+
descriptor = os.open(spooled.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
|
532
|
+
except OSError as error:
|
|
533
|
+
raise IngestionError(
|
|
534
|
+
"INGESTION_SPOOL_IO", "spooled.path", "cannot be opened"
|
|
535
|
+
) from error
|
|
536
|
+
try:
|
|
537
|
+
info = os.fstat(descriptor)
|
|
538
|
+
if (
|
|
539
|
+
not stat.S_ISREG(info.st_mode)
|
|
540
|
+
or info.st_dev != spooled.device
|
|
541
|
+
or info.st_ino != spooled.inode
|
|
542
|
+
or info.st_size != spooled.byte_length
|
|
543
|
+
):
|
|
544
|
+
raise IngestionError(
|
|
545
|
+
"INGESTION_SPOOL_PATH", "spooled.path", "changed after it was spooled"
|
|
546
|
+
)
|
|
547
|
+
digest = self._hash_descriptor(descriptor)
|
|
548
|
+
finally:
|
|
549
|
+
os.close(descriptor)
|
|
550
|
+
if digest != spooled.content_sha256:
|
|
551
|
+
raise IngestionError(
|
|
552
|
+
"INGESTION_SPOOL_DIGEST", "spooled.path", "bytes differ from the spooled digest"
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
def _verify_chunk_file(self, path: Path, chunk: ChunkRef) -> None:
|
|
556
|
+
try:
|
|
557
|
+
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
|
558
|
+
except OSError as error:
|
|
559
|
+
raise IngestionCorrupt(
|
|
560
|
+
"INGESTION_BLOB_IO", "chunk_ref.content_sha256", "blob cannot be opened"
|
|
561
|
+
) from error
|
|
562
|
+
try:
|
|
563
|
+
info = os.fstat(descriptor)
|
|
564
|
+
if not stat.S_ISREG(info.st_mode) or info.st_size != chunk.byte_length:
|
|
565
|
+
raise IngestionCorrupt(
|
|
566
|
+
"INGESTION_BLOB_SHAPE",
|
|
567
|
+
"chunk_ref.content_sha256",
|
|
568
|
+
"blob has an unexpected shape",
|
|
569
|
+
)
|
|
570
|
+
digest = self._hash_descriptor(descriptor)
|
|
571
|
+
finally:
|
|
572
|
+
os.close(descriptor)
|
|
573
|
+
if digest != chunk.content_sha256:
|
|
574
|
+
raise IngestionCorrupt(
|
|
575
|
+
"INGESTION_BLOB_DIGEST", "chunk_ref.content_sha256", "blob digest differs"
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
def _discard_spool(self, spooled: SpooledChunk) -> None:
|
|
579
|
+
try:
|
|
580
|
+
info = spooled.path.stat(follow_symlinks=False)
|
|
581
|
+
if (
|
|
582
|
+
stat.S_ISREG(info.st_mode)
|
|
583
|
+
and info.st_dev == spooled.device
|
|
584
|
+
and info.st_ino == spooled.inode
|
|
585
|
+
and spooled.path.parent == self._spool
|
|
586
|
+
):
|
|
587
|
+
spooled.path.unlink()
|
|
588
|
+
except FileNotFoundError:
|
|
589
|
+
pass
|
|
590
|
+
except OSError:
|
|
591
|
+
# The durable state is already committed. A private temporary may be reclaimed by
|
|
592
|
+
# a later maintenance operation; surfacing this as failure would make retry unsafe.
|
|
593
|
+
pass
|
|
594
|
+
|
|
595
|
+
def _reserve_spool_bytes(self, descriptor: int, reserved_length: int) -> None:
|
|
596
|
+
"""Durably reserve one spool's next logical length without holding it while it streams.
|
|
597
|
+
|
|
598
|
+
The temporary file's ``st_size`` is the durable reservation ledger. Reserving its next
|
|
599
|
+
exact length under the global lock makes every concurrent quota check see pending bytes;
|
|
600
|
+
writing the part happens after the lock is released. An interrupted writer therefore
|
|
601
|
+
leaves only a locked, stale-reapable temporary file, never an invisible quota promise.
|
|
602
|
+
"""
|
|
603
|
+
|
|
604
|
+
if isinstance(reserved_length, bool) or not isinstance(reserved_length, int):
|
|
605
|
+
raise IngestionError(
|
|
606
|
+
"INGESTION_SPOOL_LIMIT", "spool.parts", "must have an integer byte length"
|
|
607
|
+
)
|
|
608
|
+
if reserved_length < 0:
|
|
609
|
+
raise IngestionError(
|
|
610
|
+
"INGESTION_SPOOL_LIMIT", "spool.parts", "must not have a negative byte length"
|
|
611
|
+
)
|
|
612
|
+
try:
|
|
613
|
+
before = os.fstat(descriptor)
|
|
614
|
+
except OSError as error:
|
|
615
|
+
raise IngestionError(
|
|
616
|
+
"INGESTION_SPOOL_IO", "spool.path", "cannot inspect temporary spool"
|
|
617
|
+
) from error
|
|
618
|
+
if not stat.S_ISREG(before.st_mode) or before.st_nlink != 1:
|
|
619
|
+
raise IngestionError(
|
|
620
|
+
"INGESTION_SPOOL_PATH", "spool.path", "must be a private regular file"
|
|
621
|
+
)
|
|
622
|
+
if reserved_length < before.st_size:
|
|
623
|
+
raise IngestionError(
|
|
624
|
+
"INGESTION_SPOOL_LIMIT", "spool.parts", "cannot shrink a quota reservation"
|
|
625
|
+
)
|
|
626
|
+
with self._locked():
|
|
627
|
+
spool_bytes, store_bytes = self._usage_locked()
|
|
628
|
+
additional_bytes = reserved_length - before.st_size
|
|
629
|
+
if spool_bytes + additional_bytes > self.maximum_spool_total_bytes:
|
|
630
|
+
raise IngestionError(
|
|
631
|
+
"INGESTION_SPOOL_QUOTA",
|
|
632
|
+
"spool.parts",
|
|
633
|
+
"would exceed the aggregate temporary-spool quota",
|
|
634
|
+
)
|
|
635
|
+
if store_bytes + additional_bytes > self.maximum_store_bytes:
|
|
636
|
+
raise IngestionError(
|
|
637
|
+
"INGESTION_STORE_QUOTA",
|
|
638
|
+
"spool.parts",
|
|
639
|
+
"would exceed the aggregate local-store quota",
|
|
640
|
+
)
|
|
641
|
+
try:
|
|
642
|
+
os.ftruncate(descriptor, reserved_length)
|
|
643
|
+
os.fsync(descriptor)
|
|
644
|
+
except OSError as error:
|
|
645
|
+
raise IngestionError(
|
|
646
|
+
"INGESTION_SPOOL_IO", "spool.path", "could not persist quota reservation"
|
|
647
|
+
) from error
|
|
648
|
+
|
|
649
|
+
def _reap_stale_spools_locked(self, *, now: float) -> int:
|
|
650
|
+
"""Delete only stale private temporary names not locked by an active writer."""
|
|
651
|
+
|
|
652
|
+
removed = 0
|
|
653
|
+
try:
|
|
654
|
+
entries = list(os.scandir(self._spool))
|
|
655
|
+
except OSError as error:
|
|
656
|
+
raise IngestionError(
|
|
657
|
+
"INGESTION_SPOOL_IO", "spool.directory", "cannot be listed"
|
|
658
|
+
) from error
|
|
659
|
+
for entry in entries:
|
|
660
|
+
if not entry.name.startswith(".spool.") or not entry.name.endswith(".tmp"):
|
|
661
|
+
continue
|
|
662
|
+
descriptor = -1
|
|
663
|
+
try:
|
|
664
|
+
descriptor = os.open(entry.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
|
665
|
+
info = os.fstat(descriptor)
|
|
666
|
+
if (
|
|
667
|
+
not stat.S_ISREG(info.st_mode)
|
|
668
|
+
or now - info.st_mtime < self.stale_spool_age_seconds
|
|
669
|
+
):
|
|
670
|
+
continue
|
|
671
|
+
try:
|
|
672
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
673
|
+
except BlockingIOError:
|
|
674
|
+
continue
|
|
675
|
+
# Re-stat after obtaining the exclusive advisory lock: a writer that was active
|
|
676
|
+
# when we inspected the directory cannot be unlinked behind its back.
|
|
677
|
+
info = os.fstat(descriptor)
|
|
678
|
+
if now - info.st_mtime < self.stale_spool_age_seconds:
|
|
679
|
+
continue
|
|
680
|
+
os.unlink(entry.path)
|
|
681
|
+
removed += 1
|
|
682
|
+
except FileNotFoundError:
|
|
683
|
+
continue
|
|
684
|
+
except OSError as error:
|
|
685
|
+
raise IngestionError(
|
|
686
|
+
"INGESTION_SPOOL_IO", "spool.directory", "stale spool cleanup failed"
|
|
687
|
+
) from error
|
|
688
|
+
finally:
|
|
689
|
+
if descriptor >= 0:
|
|
690
|
+
os.close(descriptor)
|
|
691
|
+
if removed:
|
|
692
|
+
self._fsync_directory(self._spool, "ingestion_store.spool")
|
|
693
|
+
return removed
|
|
694
|
+
|
|
695
|
+
def _usage_locked(self) -> tuple[int, int]:
|
|
696
|
+
"""Return logical ``(spool_bytes, total_store_bytes)`` with hard links counted once."""
|
|
697
|
+
|
|
698
|
+
spool_seen: set[tuple[int, int]] = set()
|
|
699
|
+
store_seen: set[tuple[int, int]] = set()
|
|
700
|
+
spool_bytes = 0
|
|
701
|
+
store_bytes = 0
|
|
702
|
+
for directory, is_spool in (
|
|
703
|
+
(self._runs, False),
|
|
704
|
+
(self._chunks, False),
|
|
705
|
+
(self._spool, True),
|
|
706
|
+
):
|
|
707
|
+
try:
|
|
708
|
+
entries = list(os.scandir(directory))
|
|
709
|
+
except OSError as error:
|
|
710
|
+
raise IngestionError(
|
|
711
|
+
"INGESTION_STORE_IO", "ingestion_store", "cannot inspect quota usage"
|
|
712
|
+
) from error
|
|
713
|
+
for entry in entries:
|
|
714
|
+
try:
|
|
715
|
+
info = entry.stat(follow_symlinks=False)
|
|
716
|
+
except FileNotFoundError:
|
|
717
|
+
continue
|
|
718
|
+
except OSError as error:
|
|
719
|
+
raise IngestionError(
|
|
720
|
+
"INGESTION_STORE_IO", "ingestion_store", "cannot inspect stored object"
|
|
721
|
+
) from error
|
|
722
|
+
if not stat.S_ISREG(info.st_mode):
|
|
723
|
+
raise IngestionCorrupt(
|
|
724
|
+
"INGESTION_STORE_SHAPE",
|
|
725
|
+
"ingestion_store",
|
|
726
|
+
"contains a non-regular stored object",
|
|
727
|
+
)
|
|
728
|
+
identity = (info.st_dev, info.st_ino)
|
|
729
|
+
if is_spool and identity not in spool_seen:
|
|
730
|
+
spool_seen.add(identity)
|
|
731
|
+
spool_bytes += info.st_size
|
|
732
|
+
if identity not in store_seen:
|
|
733
|
+
store_seen.add(identity)
|
|
734
|
+
store_bytes += info.st_size
|
|
735
|
+
return spool_bytes, store_bytes
|
|
736
|
+
|
|
737
|
+
def _ensure_store_capacity_locked(self, additional_bytes: int) -> None:
|
|
738
|
+
"""Reserve temporary manifest-write space before creating the replacement file."""
|
|
739
|
+
|
|
740
|
+
_spool_bytes, store_bytes = self._usage_locked()
|
|
741
|
+
if store_bytes + additional_bytes > self.maximum_store_bytes:
|
|
742
|
+
raise IngestionError(
|
|
743
|
+
"INGESTION_STORE_QUOTA",
|
|
744
|
+
"ingestion_manifest",
|
|
745
|
+
"would exceed the aggregate local-store quota",
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
def _manifest_path(self, run_id: str) -> Path:
|
|
749
|
+
return self._runs / f"{run_id}.json"
|
|
750
|
+
|
|
751
|
+
def _chunk_path(self, digest: str) -> Path:
|
|
752
|
+
return self._chunks / f"{digest}.bin"
|
|
753
|
+
|
|
754
|
+
def _checkpoint(self, name: str) -> None:
|
|
755
|
+
if self.fault_injector is not None:
|
|
756
|
+
self.fault_injector.checkpoint(name)
|
|
757
|
+
|
|
758
|
+
@staticmethod
|
|
759
|
+
def _fsync_directory(path: Path, field: str) -> None:
|
|
760
|
+
descriptor = -1
|
|
761
|
+
try:
|
|
762
|
+
descriptor = os.open(
|
|
763
|
+
path,
|
|
764
|
+
os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0),
|
|
765
|
+
)
|
|
766
|
+
os.fsync(descriptor)
|
|
767
|
+
except OSError as error:
|
|
768
|
+
raise IngestionError("INGESTION_STORE_IO", field, "directory sync failed") from error
|
|
769
|
+
finally:
|
|
770
|
+
if descriptor >= 0:
|
|
771
|
+
os.close(descriptor)
|
|
772
|
+
|
|
773
|
+
def _validate_limits(self) -> None:
|
|
774
|
+
for field, value in (
|
|
775
|
+
("maximum_spool_bytes", self.maximum_spool_bytes),
|
|
776
|
+
("maximum_spool_total_bytes", self.maximum_spool_total_bytes),
|
|
777
|
+
("maximum_store_bytes", self.maximum_store_bytes),
|
|
778
|
+
):
|
|
779
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
780
|
+
raise IngestionError(
|
|
781
|
+
"INGESTION_STORE_QUOTA", field, "must be a non-negative integer"
|
|
782
|
+
)
|
|
783
|
+
if self.maximum_spool_bytes > self.maximum_spool_total_bytes:
|
|
784
|
+
raise IngestionError(
|
|
785
|
+
"INGESTION_STORE_QUOTA",
|
|
786
|
+
"maximum_spool_bytes",
|
|
787
|
+
"must not exceed the aggregate temporary-spool quota",
|
|
788
|
+
)
|
|
789
|
+
if self.maximum_spool_total_bytes > self.maximum_store_bytes:
|
|
790
|
+
raise IngestionError(
|
|
791
|
+
"INGESTION_STORE_QUOTA",
|
|
792
|
+
"maximum_spool_total_bytes",
|
|
793
|
+
"must not exceed the aggregate local-store quota",
|
|
794
|
+
)
|
|
795
|
+
if (
|
|
796
|
+
isinstance(self.stale_spool_age_seconds, bool)
|
|
797
|
+
or not isinstance(self.stale_spool_age_seconds, int)
|
|
798
|
+
or self.stale_spool_age_seconds < 0
|
|
799
|
+
):
|
|
800
|
+
raise IngestionError(
|
|
801
|
+
"INGESTION_SPOOL_AGE",
|
|
802
|
+
"stale_spool_age_seconds",
|
|
803
|
+
"must be a non-negative integer",
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
@staticmethod
|
|
807
|
+
def _require_private_directory(path: Path, field: str) -> None:
|
|
808
|
+
try:
|
|
809
|
+
info = path.stat(follow_symlinks=False)
|
|
810
|
+
except OSError as error:
|
|
811
|
+
raise IngestionError("INGESTION_STORE_IO", field, "cannot be inspected") from error
|
|
812
|
+
if (
|
|
813
|
+
not stat.S_ISDIR(info.st_mode)
|
|
814
|
+
or info.st_uid != os.geteuid()
|
|
815
|
+
or stat.S_IMODE(info.st_mode) != 0o700
|
|
816
|
+
):
|
|
817
|
+
raise IngestionError(
|
|
818
|
+
"INGESTION_STORE_PATH",
|
|
819
|
+
field,
|
|
820
|
+
"must be an effective-user-owned mode-0700 non-symlink directory",
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
@staticmethod
|
|
824
|
+
def _validate_run_id(run_id: str) -> None:
|
|
825
|
+
# Constructing a temporary manifest would be misleading; the manifest constructor is
|
|
826
|
+
# intentionally the sole authority for this grammar, so reject path separators here too.
|
|
827
|
+
if (
|
|
828
|
+
not isinstance(run_id, str)
|
|
829
|
+
or not run_id
|
|
830
|
+
or len(run_id) > 128
|
|
831
|
+
or any(
|
|
832
|
+
character not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-"
|
|
833
|
+
for character in run_id
|
|
834
|
+
)
|
|
835
|
+
or run_id[0] in ".-"
|
|
836
|
+
):
|
|
837
|
+
raise IngestionError("INGESTION_RUN_ID", "run_id", "is invalid")
|
|
838
|
+
|
|
839
|
+
@staticmethod
|
|
840
|
+
def _validate_manifest_digest(value: str) -> None:
|
|
841
|
+
if (
|
|
842
|
+
not isinstance(value, str)
|
|
843
|
+
or len(value) != 64
|
|
844
|
+
or any(character not in "0123456789abcdef" for character in value)
|
|
845
|
+
):
|
|
846
|
+
raise IngestionError(
|
|
847
|
+
"INGESTION_MANIFEST_DIGEST",
|
|
848
|
+
"expected_manifest_sha256",
|
|
849
|
+
"must be a lowercase SHA-256",
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
@staticmethod
|
|
853
|
+
def _write_all(descriptor: int, raw: bytes) -> None:
|
|
854
|
+
view = memoryview(raw)
|
|
855
|
+
while view:
|
|
856
|
+
written = os.write(descriptor, view)
|
|
857
|
+
if written <= 0:
|
|
858
|
+
raise OSError("short manifest write")
|
|
859
|
+
view = view[written:]
|
|
860
|
+
|
|
861
|
+
@staticmethod
|
|
862
|
+
def _hash_descriptor(descriptor: int) -> str:
|
|
863
|
+
digest = hashlib.sha256()
|
|
864
|
+
os.lseek(descriptor, 0, os.SEEK_SET)
|
|
865
|
+
while raw := os.read(descriptor, _READ_CHUNK_SIZE):
|
|
866
|
+
digest.update(raw)
|
|
867
|
+
return digest.hexdigest()
|
|
868
|
+
|
|
869
|
+
@staticmethod
|
|
870
|
+
def _read_descriptor(descriptor: int, *, maximum: int) -> bytes:
|
|
871
|
+
"""Read a small control-plane document under a strict byte ceiling."""
|
|
872
|
+
|
|
873
|
+
parts: list[bytes] = []
|
|
874
|
+
total = 0
|
|
875
|
+
os.lseek(descriptor, 0, os.SEEK_SET)
|
|
876
|
+
while raw := os.read(descriptor, _READ_CHUNK_SIZE):
|
|
877
|
+
total += len(raw)
|
|
878
|
+
if total > maximum:
|
|
879
|
+
raise IngestionCorrupt(
|
|
880
|
+
"INGESTION_FILE_LIMIT",
|
|
881
|
+
"ingestion_store",
|
|
882
|
+
"file exceeds its permitted byte limit",
|
|
883
|
+
)
|
|
884
|
+
parts.append(raw)
|
|
885
|
+
return b"".join(parts)
|