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,1105 @@
|
|
|
1
|
+
"""Descriptor-rooted private immutable staging for resumable catalog fills."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import fcntl
|
|
7
|
+
import os
|
|
8
|
+
import secrets
|
|
9
|
+
import stat
|
|
10
|
+
import tempfile
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from mostlyright.data_harness.canonical import (
|
|
16
|
+
CanonicalJSONError,
|
|
17
|
+
canonical_json_bytes,
|
|
18
|
+
canonical_sha256,
|
|
19
|
+
parse_canonical_json,
|
|
20
|
+
sha256_bytes,
|
|
21
|
+
)
|
|
22
|
+
from mostlyright.data_harness.sources.contracts import SourceContractError
|
|
23
|
+
|
|
24
|
+
STATE_SCHEMA = "harness-catalog-fill-state.v2"
|
|
25
|
+
SHARD_SCHEMA = "harness-catalog-fill-shard.v1"
|
|
26
|
+
MAX_STATE_BYTES = 64 * 1024
|
|
27
|
+
MAX_SHARD_BYTES = 64 * 1024 * 1024
|
|
28
|
+
MAX_INDEX_SEGMENT_BYTES = 1024 * 1024
|
|
29
|
+
MAX_INDEX_SEGMENT_ITEMS = 4_096
|
|
30
|
+
MAX_BLOB_BYTES = 16 * 1024 * 1024
|
|
31
|
+
MAX_HISTORY_DIGESTS = 1_000_000
|
|
32
|
+
MAX_INDEX_ITEMS = 20_000_000
|
|
33
|
+
_OPEN_DIRECTORY = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
34
|
+
_OPEN_MEMBER = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
|
|
35
|
+
_SHARD_KINDS = frozenset(
|
|
36
|
+
{
|
|
37
|
+
"classification",
|
|
38
|
+
"history",
|
|
39
|
+
"index",
|
|
40
|
+
"loop_set",
|
|
41
|
+
"normalized",
|
|
42
|
+
"page",
|
|
43
|
+
"partition_run",
|
|
44
|
+
"pass_map",
|
|
45
|
+
"response",
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
_BLOB_KINDS = frozenset({"raw_response"})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class CatalogFillRefused(SourceContractError):
|
|
52
|
+
"""A stable local fill/configuration/staging refusal."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def shard_byte_limit(kind: str) -> int:
|
|
56
|
+
"""Return the live immutable-shard byte ceiling for preflight composition."""
|
|
57
|
+
|
|
58
|
+
return MAX_INDEX_SEGMENT_BYTES if kind == "index" else MAX_SHARD_BYTES
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class FillStaging:
|
|
62
|
+
"""One private staging root retained by descriptor for the whole locked operation."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, root: Path, *, root_descriptor: int | None = None) -> None:
|
|
65
|
+
self.root = Path(root)
|
|
66
|
+
self._retained_root_descriptor = root_descriptor
|
|
67
|
+
self.state_path = self.root / "state.json"
|
|
68
|
+
self.lock_path = self.root / ".lock"
|
|
69
|
+
self.shard_root = self.root / "shards"
|
|
70
|
+
self._root_fd = -1
|
|
71
|
+
self._shards_fd = -1
|
|
72
|
+
self._kind_fds: dict[str, int] = {}
|
|
73
|
+
self._chain: list[tuple[int, str, int, tuple[int, int, int]]] = []
|
|
74
|
+
self._owned_fds: list[int] = []
|
|
75
|
+
self._active_histories: dict[str, list[str]] = {}
|
|
76
|
+
|
|
77
|
+
@contextlib.contextmanager
|
|
78
|
+
def locked(self) -> Iterator[None]:
|
|
79
|
+
if self._root_fd >= 0:
|
|
80
|
+
raise CatalogFillRefused(
|
|
81
|
+
"FILL_STAGING_LOCK", "fill.staging", "staging lock is not reentrant"
|
|
82
|
+
)
|
|
83
|
+
lock_fd = -1
|
|
84
|
+
try:
|
|
85
|
+
self._open_retained_tree()
|
|
86
|
+
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0)
|
|
87
|
+
lock_fd = os.open(".lock", flags, 0o600, dir_fd=self._root_fd)
|
|
88
|
+
info = os.fstat(lock_fd)
|
|
89
|
+
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
|
|
90
|
+
raise CatalogFillRefused(
|
|
91
|
+
"FILL_STAGING_PATH",
|
|
92
|
+
"fill.staging.lock",
|
|
93
|
+
"lock must be a single-link regular file",
|
|
94
|
+
)
|
|
95
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
|
96
|
+
self._verify_tree()
|
|
97
|
+
yield
|
|
98
|
+
self._verify_tree()
|
|
99
|
+
finally:
|
|
100
|
+
if lock_fd >= 0:
|
|
101
|
+
os.close(lock_fd)
|
|
102
|
+
for descriptor in reversed(self._owned_fds):
|
|
103
|
+
os.close(descriptor)
|
|
104
|
+
self._owned_fds = []
|
|
105
|
+
self._chain = []
|
|
106
|
+
self._kind_fds = {}
|
|
107
|
+
self._shards_fd = -1
|
|
108
|
+
self._root_fd = -1
|
|
109
|
+
self._active_histories = {}
|
|
110
|
+
|
|
111
|
+
def load(self) -> dict[str, Any] | None:
|
|
112
|
+
self._require_locked()
|
|
113
|
+
return self._load_state(validate_shards=True)
|
|
114
|
+
|
|
115
|
+
def write_shard(self, kind: str, payload: dict[str, Any]) -> str:
|
|
116
|
+
self._require_locked()
|
|
117
|
+
if kind not in _SHARD_KINDS:
|
|
118
|
+
raise CatalogFillRefused("FILL_STAGING_KIND", "fill.staging.kind", "unknown shard")
|
|
119
|
+
document = {"schema_version": SHARD_SCHEMA, "kind": kind, "payload": payload}
|
|
120
|
+
raw = canonical_json_bytes(document)
|
|
121
|
+
maximum = shard_byte_limit(kind)
|
|
122
|
+
if len(raw) > maximum:
|
|
123
|
+
raise CatalogFillRefused(
|
|
124
|
+
"FILL_SHARD_LIMIT", "fill.staging.shard", "shard exceeds the byte limit"
|
|
125
|
+
)
|
|
126
|
+
digest = sha256_bytes(raw)
|
|
127
|
+
name = f"{digest}.json"
|
|
128
|
+
directory_fd = self._kind_fds[kind]
|
|
129
|
+
self._publish_immutable(
|
|
130
|
+
directory_fd,
|
|
131
|
+
name,
|
|
132
|
+
raw,
|
|
133
|
+
maximum=len(raw),
|
|
134
|
+
field="fill.staging.shard",
|
|
135
|
+
)
|
|
136
|
+
self._verify_tree()
|
|
137
|
+
return digest
|
|
138
|
+
|
|
139
|
+
def read_shard(self, kind: str, digest: str) -> dict[str, Any]:
|
|
140
|
+
self._require_locked()
|
|
141
|
+
if kind not in _SHARD_KINDS or not _is_digest(digest):
|
|
142
|
+
raise CatalogFillRefused(
|
|
143
|
+
"FILL_CHECKPOINT_CORRUPT", "fill.staging.shard", "shard coordinate is invalid"
|
|
144
|
+
)
|
|
145
|
+
try:
|
|
146
|
+
raw = self._read_regular_at(
|
|
147
|
+
self._kind_fds[kind],
|
|
148
|
+
f"{digest}.json",
|
|
149
|
+
maximum=MAX_INDEX_SEGMENT_BYTES if kind == "index" else MAX_SHARD_BYTES,
|
|
150
|
+
)
|
|
151
|
+
except FileNotFoundError as error:
|
|
152
|
+
raise CatalogFillRefused(
|
|
153
|
+
"FILL_CHECKPOINT_CORRUPT", "fill.staging.shard", "referenced shard is absent"
|
|
154
|
+
) from error
|
|
155
|
+
if sha256_bytes(raw) != digest:
|
|
156
|
+
raise CatalogFillRefused(
|
|
157
|
+
"FILL_CHECKPOINT_CORRUPT", "fill.staging.shard", "shard digest mismatch"
|
|
158
|
+
)
|
|
159
|
+
try:
|
|
160
|
+
document = parse_canonical_json(raw)
|
|
161
|
+
except CanonicalJSONError:
|
|
162
|
+
raise CatalogFillRefused(
|
|
163
|
+
"FILL_CHECKPOINT_CORRUPT", "fill.staging.shard", "shard is not canonical"
|
|
164
|
+
) from None
|
|
165
|
+
if (
|
|
166
|
+
not isinstance(document, dict)
|
|
167
|
+
or set(document) != {"schema_version", "kind", "payload"}
|
|
168
|
+
or document["schema_version"] != SHARD_SCHEMA
|
|
169
|
+
or document["kind"] != kind
|
|
170
|
+
or not isinstance(document["payload"], dict)
|
|
171
|
+
):
|
|
172
|
+
raise CatalogFillRefused(
|
|
173
|
+
"FILL_CHECKPOINT_CORRUPT", "fill.staging.shard", "shard envelope is invalid"
|
|
174
|
+
)
|
|
175
|
+
return document["payload"]
|
|
176
|
+
|
|
177
|
+
def write_blob(self, kind: str, content: bytes) -> str:
|
|
178
|
+
"""Install exact bounded bytes under their digest without a JSON/base64 rewrite."""
|
|
179
|
+
|
|
180
|
+
self._require_locked()
|
|
181
|
+
if kind not in _BLOB_KINDS or not isinstance(content, bytes):
|
|
182
|
+
raise CatalogFillRefused("FILL_STAGING_KIND", "fill.staging.kind", "unknown blob")
|
|
183
|
+
if len(content) > MAX_BLOB_BYTES:
|
|
184
|
+
raise CatalogFillRefused(
|
|
185
|
+
"FILL_SHARD_LIMIT", "fill.staging.blob", "blob exceeds the byte limit"
|
|
186
|
+
)
|
|
187
|
+
digest = sha256_bytes(content)
|
|
188
|
+
name = f"{digest}.bin"
|
|
189
|
+
directory_fd = self._kind_fds[kind]
|
|
190
|
+
self._publish_immutable(
|
|
191
|
+
directory_fd,
|
|
192
|
+
name,
|
|
193
|
+
content,
|
|
194
|
+
maximum=len(content),
|
|
195
|
+
field="fill.staging.blob",
|
|
196
|
+
)
|
|
197
|
+
return digest
|
|
198
|
+
|
|
199
|
+
def _publish_immutable(
|
|
200
|
+
self,
|
|
201
|
+
directory_fd: int,
|
|
202
|
+
name: str,
|
|
203
|
+
content: bytes,
|
|
204
|
+
*,
|
|
205
|
+
maximum: int,
|
|
206
|
+
field: str,
|
|
207
|
+
) -> None:
|
|
208
|
+
"""Atomically install immutable bytes without ever exposing a partial final name."""
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
existing = self._read_regular_at(directory_fd, name, maximum=maximum)
|
|
212
|
+
except FileNotFoundError:
|
|
213
|
+
existing = None
|
|
214
|
+
except OSError as error:
|
|
215
|
+
raise CatalogFillRefused(
|
|
216
|
+
"FILL_STAGING_IO", field, "immutable object lookup failed"
|
|
217
|
+
) from error
|
|
218
|
+
if existing is not None:
|
|
219
|
+
if existing != content:
|
|
220
|
+
raise CatalogFillRefused("FILL_SHARD_COLLISION", field, "digest path bytes differ")
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
temporary = f".publish.{os.getpid()}.{secrets.token_hex(16)}.tmp"
|
|
224
|
+
descriptor = -1
|
|
225
|
+
installed = False
|
|
226
|
+
try:
|
|
227
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
|
228
|
+
descriptor = os.open(temporary, flags, 0o600, dir_fd=directory_fd)
|
|
229
|
+
_write_all(descriptor, content)
|
|
230
|
+
os.fsync(descriptor)
|
|
231
|
+
os.close(descriptor)
|
|
232
|
+
descriptor = -1
|
|
233
|
+
try:
|
|
234
|
+
os.link(
|
|
235
|
+
temporary,
|
|
236
|
+
name,
|
|
237
|
+
src_dir_fd=directory_fd,
|
|
238
|
+
dst_dir_fd=directory_fd,
|
|
239
|
+
follow_symlinks=False,
|
|
240
|
+
)
|
|
241
|
+
installed = True
|
|
242
|
+
except FileExistsError:
|
|
243
|
+
pass
|
|
244
|
+
except OSError as error:
|
|
245
|
+
raise CatalogFillRefused(
|
|
246
|
+
"FILL_STAGING_IO", field, "immutable object publication failed"
|
|
247
|
+
) from error
|
|
248
|
+
finally:
|
|
249
|
+
if descriptor >= 0:
|
|
250
|
+
os.close(descriptor)
|
|
251
|
+
try:
|
|
252
|
+
os.unlink(temporary, dir_fd=directory_fd)
|
|
253
|
+
except FileNotFoundError:
|
|
254
|
+
pass
|
|
255
|
+
except OSError as error:
|
|
256
|
+
raise CatalogFillRefused(
|
|
257
|
+
"FILL_STAGING_IO", field, "temporary object cleanup failed"
|
|
258
|
+
) from error
|
|
259
|
+
try:
|
|
260
|
+
os.fsync(directory_fd)
|
|
261
|
+
except OSError as error:
|
|
262
|
+
raise CatalogFillRefused(
|
|
263
|
+
"FILL_STAGING_IO", field, "immutable object directory sync failed"
|
|
264
|
+
) from error
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
observed = self._read_regular_at(directory_fd, name, maximum=maximum)
|
|
268
|
+
except OSError as error:
|
|
269
|
+
raise CatalogFillRefused(
|
|
270
|
+
"FILL_STAGING_IO", field, "immutable object readback failed"
|
|
271
|
+
) from error
|
|
272
|
+
if observed != content:
|
|
273
|
+
code = "FILL_SHARD_READBACK" if installed else "FILL_SHARD_COLLISION"
|
|
274
|
+
raise CatalogFillRefused(code, field, "immutable object readback differs")
|
|
275
|
+
|
|
276
|
+
def read_blob(self, kind: str, digest: str) -> bytes:
|
|
277
|
+
self._require_locked()
|
|
278
|
+
if kind not in _BLOB_KINDS or not _is_digest(digest):
|
|
279
|
+
raise CatalogFillRefused(
|
|
280
|
+
"FILL_CHECKPOINT_CORRUPT", "fill.staging.blob", "blob coordinate is invalid"
|
|
281
|
+
)
|
|
282
|
+
try:
|
|
283
|
+
content = self._read_regular_at(
|
|
284
|
+
self._kind_fds[kind], f"{digest}.bin", maximum=MAX_BLOB_BYTES
|
|
285
|
+
)
|
|
286
|
+
except FileNotFoundError as error:
|
|
287
|
+
raise CatalogFillRefused(
|
|
288
|
+
"FILL_CHECKPOINT_CORRUPT", "fill.staging.blob", "referenced blob is absent"
|
|
289
|
+
) from error
|
|
290
|
+
if sha256_bytes(content) != digest:
|
|
291
|
+
raise CatalogFillRefused(
|
|
292
|
+
"FILL_CHECKPOINT_CORRUPT", "fill.staging.blob", "blob digest mismatch"
|
|
293
|
+
)
|
|
294
|
+
return content
|
|
295
|
+
|
|
296
|
+
@staticmethod
|
|
297
|
+
def empty_index_head() -> dict[str, Any]:
|
|
298
|
+
return {
|
|
299
|
+
"root_sha256": None,
|
|
300
|
+
"count": 0,
|
|
301
|
+
"tail_sha256": None,
|
|
302
|
+
"segment_count": 0,
|
|
303
|
+
"descriptor_count": 0,
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
def append_index(
|
|
307
|
+
self,
|
|
308
|
+
head: dict[str, Any],
|
|
309
|
+
*,
|
|
310
|
+
stream: str,
|
|
311
|
+
items: list[dict[str, Any]],
|
|
312
|
+
) -> dict[str, Any]:
|
|
313
|
+
"""Append one immutable <=4096-item/1-MiB segment to a compact Merkle-style head."""
|
|
314
|
+
|
|
315
|
+
self._require_locked()
|
|
316
|
+
_validate_index_head(head)
|
|
317
|
+
if (
|
|
318
|
+
not isinstance(stream, str)
|
|
319
|
+
or not stream
|
|
320
|
+
or not isinstance(items, list)
|
|
321
|
+
or not 1 <= len(items) <= MAX_INDEX_SEGMENT_ITEMS
|
|
322
|
+
or any(not isinstance(item, dict) for item in items)
|
|
323
|
+
):
|
|
324
|
+
raise CatalogFillRefused(
|
|
325
|
+
"FILL_INDEX_SEGMENT", "fill.staging.index", "index segment is invalid"
|
|
326
|
+
)
|
|
327
|
+
if head["count"] + len(items) > MAX_INDEX_ITEMS:
|
|
328
|
+
raise CatalogFillRefused(
|
|
329
|
+
"FILL_CHECKPOINT_LIMIT",
|
|
330
|
+
"fill.staging.index",
|
|
331
|
+
"prospective index count exceeds the segmented cap",
|
|
332
|
+
)
|
|
333
|
+
payload = {
|
|
334
|
+
"stream": stream,
|
|
335
|
+
"previous_sha256": head["tail_sha256"],
|
|
336
|
+
"start": head["count"],
|
|
337
|
+
"items": items,
|
|
338
|
+
}
|
|
339
|
+
digest = self.write_shard("index", payload)
|
|
340
|
+
root = canonical_index_root(
|
|
341
|
+
previous_root=head["root_sha256"],
|
|
342
|
+
segment_sha256=digest,
|
|
343
|
+
start=head["count"],
|
|
344
|
+
item_count=len(items),
|
|
345
|
+
)
|
|
346
|
+
return {
|
|
347
|
+
"root_sha256": root,
|
|
348
|
+
"count": head["count"] + len(items),
|
|
349
|
+
"tail_sha256": digest,
|
|
350
|
+
"segment_count": head["segment_count"] + 1,
|
|
351
|
+
# Segment descriptors live only in the immutable chain. The bounded head embeds none.
|
|
352
|
+
"descriptor_count": 0,
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
def iter_index(
|
|
356
|
+
self,
|
|
357
|
+
head: dict[str, Any],
|
|
358
|
+
*,
|
|
359
|
+
stream: str,
|
|
360
|
+
start: int = 0,
|
|
361
|
+
) -> Iterator[dict[str, Any]]:
|
|
362
|
+
self._require_locked()
|
|
363
|
+
_validate_index_head(head)
|
|
364
|
+
if type(start) is not int or not 0 <= start <= head["count"]:
|
|
365
|
+
raise CatalogFillRefused(
|
|
366
|
+
"FILL_CHECKPOINT_CORRUPT", "fill.staging.index", "index start is invalid"
|
|
367
|
+
)
|
|
368
|
+
traversed = 0
|
|
369
|
+
current = head["tail_sha256"]
|
|
370
|
+
with tempfile.TemporaryFile(mode="w+b") as coordinates:
|
|
371
|
+
while current is not None:
|
|
372
|
+
if traversed >= head["segment_count"] + 1:
|
|
373
|
+
raise CatalogFillRefused(
|
|
374
|
+
"FILL_CHECKPOINT_CORRUPT", "fill.staging.index", "index chain loops"
|
|
375
|
+
)
|
|
376
|
+
traversed += 1
|
|
377
|
+
node = self.read_shard("index", current)
|
|
378
|
+
if (
|
|
379
|
+
set(node) != {"stream", "previous_sha256", "start", "items"}
|
|
380
|
+
or node["stream"] != stream
|
|
381
|
+
or type(node["start"]) is not int
|
|
382
|
+
or not isinstance(node["items"], list)
|
|
383
|
+
or not 1 <= len(node["items"]) <= MAX_INDEX_SEGMENT_ITEMS
|
|
384
|
+
or any(not isinstance(item, dict) for item in node["items"])
|
|
385
|
+
or (
|
|
386
|
+
node["previous_sha256"] is not None
|
|
387
|
+
and not _is_digest(node["previous_sha256"])
|
|
388
|
+
)
|
|
389
|
+
):
|
|
390
|
+
raise CatalogFillRefused(
|
|
391
|
+
"FILL_CHECKPOINT_CORRUPT", "fill.staging.index", "index node is invalid"
|
|
392
|
+
)
|
|
393
|
+
coordinates.write(current.encode("ascii") + b"\n")
|
|
394
|
+
current = node["previous_sha256"]
|
|
395
|
+
if traversed != head["segment_count"]:
|
|
396
|
+
raise CatalogFillRefused(
|
|
397
|
+
"FILL_CHECKPOINT_CORRUPT", "fill.staging.index", "segment count differs"
|
|
398
|
+
)
|
|
399
|
+
count = 0
|
|
400
|
+
root: str | None = None
|
|
401
|
+
for position in range(head["segment_count"] - 1, -1, -1):
|
|
402
|
+
coordinates.seek(position * 65)
|
|
403
|
+
digest = coordinates.read(64).decode("ascii")
|
|
404
|
+
node = self.read_shard("index", digest)
|
|
405
|
+
if node["start"] != count:
|
|
406
|
+
raise CatalogFillRefused(
|
|
407
|
+
"FILL_CHECKPOINT_CORRUPT",
|
|
408
|
+
"fill.staging.index",
|
|
409
|
+
"index is not contiguous",
|
|
410
|
+
)
|
|
411
|
+
root = canonical_index_root(
|
|
412
|
+
previous_root=root,
|
|
413
|
+
segment_sha256=digest,
|
|
414
|
+
start=count,
|
|
415
|
+
item_count=len(node["items"]),
|
|
416
|
+
)
|
|
417
|
+
for offset, item in enumerate(node["items"]):
|
|
418
|
+
if count + offset >= start:
|
|
419
|
+
yield item
|
|
420
|
+
count += len(node["items"])
|
|
421
|
+
if count != head["count"] or root != head["root_sha256"]:
|
|
422
|
+
raise CatalogFillRefused(
|
|
423
|
+
"FILL_CHECKPOINT_CORRUPT", "fill.staging.index", "index head differs"
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
def validate_index(self, head: dict[str, Any], *, stream: str) -> None:
|
|
427
|
+
"""Read and authenticate every bounded segment behind one compact head."""
|
|
428
|
+
|
|
429
|
+
for _item in self.iter_index(head, stream=stream):
|
|
430
|
+
pass
|
|
431
|
+
|
|
432
|
+
def integrity_epoch(self) -> tuple[tuple[str, int, int, int, int], ...]:
|
|
433
|
+
"""Return a cheap process-local mutation coordinate for the retained tree."""
|
|
434
|
+
|
|
435
|
+
self._require_locked()
|
|
436
|
+
descriptors = {
|
|
437
|
+
"$root": self._root_fd,
|
|
438
|
+
"$shards": self._shards_fd,
|
|
439
|
+
**self._kind_fds,
|
|
440
|
+
}
|
|
441
|
+
coordinates: list[tuple[str, int, int, int, int]] = []
|
|
442
|
+
for name, descriptor in sorted(descriptors.items()):
|
|
443
|
+
info = os.fstat(descriptor)
|
|
444
|
+
coordinates.append((name, info.st_dev, info.st_ino, info.st_mtime_ns, info.st_ctime_ns))
|
|
445
|
+
return tuple(coordinates)
|
|
446
|
+
|
|
447
|
+
def index_tail_items(self, head: dict[str, Any], *, stream: str) -> tuple[dict[str, Any], ...]:
|
|
448
|
+
"""Authenticate only the newly appended immutable tail segment."""
|
|
449
|
+
|
|
450
|
+
self._require_locked()
|
|
451
|
+
_validate_index_head(head)
|
|
452
|
+
if head["count"] == 0:
|
|
453
|
+
if (
|
|
454
|
+
head["tail_sha256"] is not None
|
|
455
|
+
or head["root_sha256"] is not None
|
|
456
|
+
or head["segment_count"] != 0
|
|
457
|
+
):
|
|
458
|
+
raise CatalogFillRefused(
|
|
459
|
+
"FILL_CHECKPOINT_CORRUPT",
|
|
460
|
+
"fill.staging.index",
|
|
461
|
+
"empty index head has a tail",
|
|
462
|
+
)
|
|
463
|
+
return ()
|
|
464
|
+
tail = head["tail_sha256"]
|
|
465
|
+
if not _is_digest(tail) or head["segment_count"] < 1:
|
|
466
|
+
raise CatalogFillRefused(
|
|
467
|
+
"FILL_CHECKPOINT_CORRUPT", "fill.staging.index", "index tail is absent"
|
|
468
|
+
)
|
|
469
|
+
node = self.read_shard("index", tail)
|
|
470
|
+
if (
|
|
471
|
+
set(node) != {"stream", "previous_sha256", "start", "items"}
|
|
472
|
+
or node["stream"] != stream
|
|
473
|
+
or type(node["start"]) is not int
|
|
474
|
+
or not isinstance(node["items"], list)
|
|
475
|
+
or not 1 <= len(node["items"]) <= MAX_INDEX_SEGMENT_ITEMS
|
|
476
|
+
or any(not isinstance(item, dict) for item in node["items"])
|
|
477
|
+
or node["start"] + len(node["items"]) != head["count"]
|
|
478
|
+
or (head["segment_count"] == 1) != (node["previous_sha256"] is None)
|
|
479
|
+
or (node["previous_sha256"] is not None and not _is_digest(node["previous_sha256"]))
|
|
480
|
+
):
|
|
481
|
+
raise CatalogFillRefused(
|
|
482
|
+
"FILL_CHECKPOINT_CORRUPT", "fill.staging.index", "index tail is invalid"
|
|
483
|
+
)
|
|
484
|
+
return tuple(node["items"])
|
|
485
|
+
|
|
486
|
+
def commit(
|
|
487
|
+
self,
|
|
488
|
+
state: dict[str, Any],
|
|
489
|
+
*,
|
|
490
|
+
expected_generation: int,
|
|
491
|
+
expected_checkpoint_digest: str | None,
|
|
492
|
+
) -> dict[str, Any]:
|
|
493
|
+
"""CAS one state generation against both generation and exact live bytes."""
|
|
494
|
+
|
|
495
|
+
self._require_locked()
|
|
496
|
+
live_document, live_raw = self._read_state_document()
|
|
497
|
+
live_generation = -1 if live_document is None else live_document["generation"]
|
|
498
|
+
live_digest = None if live_raw is None else sha256_bytes(live_raw)
|
|
499
|
+
if live_generation != expected_generation or live_digest != expected_checkpoint_digest:
|
|
500
|
+
raise CatalogFillRefused(
|
|
501
|
+
"FILL_CHECKPOINT_STALE",
|
|
502
|
+
"fill.staging.state",
|
|
503
|
+
"live checkpoint generation or digest changed",
|
|
504
|
+
)
|
|
505
|
+
next_state = dict(state)
|
|
506
|
+
next_state["schema_version"] = STATE_SCHEMA
|
|
507
|
+
next_state["generation"] = expected_generation + 1
|
|
508
|
+
compact = dict(next_state)
|
|
509
|
+
if "evidence_indexes" in next_state:
|
|
510
|
+
if "page_shards" in next_state or "response_shards" in next_state:
|
|
511
|
+
raise CatalogFillRefused(
|
|
512
|
+
"FILL_CHECKPOINT_CORRUPT",
|
|
513
|
+
"fill.staging.state",
|
|
514
|
+
"segmented state may not embed growing shard histories",
|
|
515
|
+
)
|
|
516
|
+
else:
|
|
517
|
+
for stream in ("page", "response"):
|
|
518
|
+
values = next_state[f"{stream}_shards"]
|
|
519
|
+
compact.pop(f"{stream}_shards")
|
|
520
|
+
compact[f"{stream}_history"] = self._advance_history(
|
|
521
|
+
stream,
|
|
522
|
+
values,
|
|
523
|
+
live_document=live_document,
|
|
524
|
+
)
|
|
525
|
+
raw = canonical_json_bytes(compact)
|
|
526
|
+
if len(raw) > MAX_STATE_BYTES:
|
|
527
|
+
raise CatalogFillRefused(
|
|
528
|
+
"FILL_CHECKPOINT_LIMIT", "fill.staging.state", "state exceeds the byte limit"
|
|
529
|
+
)
|
|
530
|
+
temporary = f".state.{os.getpid()}.{expected_generation + 1}.{secrets.token_hex(8)}.tmp"
|
|
531
|
+
flags = os.O_RDWR | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
|
532
|
+
replaced = False
|
|
533
|
+
try:
|
|
534
|
+
descriptor = os.open(temporary, flags, 0o600, dir_fd=self._root_fd)
|
|
535
|
+
try:
|
|
536
|
+
_write_all(descriptor, raw)
|
|
537
|
+
os.fsync(descriptor)
|
|
538
|
+
os.lseek(descriptor, 0, os.SEEK_SET)
|
|
539
|
+
if _read_open(descriptor, maximum=len(raw)) != raw:
|
|
540
|
+
raise CatalogFillRefused(
|
|
541
|
+
"FILL_CHECKPOINT_READBACK",
|
|
542
|
+
"fill.staging.state",
|
|
543
|
+
"temporary readback differs",
|
|
544
|
+
)
|
|
545
|
+
finally:
|
|
546
|
+
os.close(descriptor)
|
|
547
|
+
self._verify_tree()
|
|
548
|
+
os.replace(
|
|
549
|
+
temporary,
|
|
550
|
+
"state.json",
|
|
551
|
+
src_dir_fd=self._root_fd,
|
|
552
|
+
dst_dir_fd=self._root_fd,
|
|
553
|
+
)
|
|
554
|
+
replaced = True
|
|
555
|
+
os.fsync(self._root_fd)
|
|
556
|
+
if self._read_regular_at(self._root_fd, "state.json", maximum=len(raw)) != raw:
|
|
557
|
+
raise CatalogFillRefused(
|
|
558
|
+
"FILL_CHECKPOINT_READBACK", "fill.staging.state", "state readback differs"
|
|
559
|
+
)
|
|
560
|
+
self._verify_tree()
|
|
561
|
+
except BaseException:
|
|
562
|
+
if replaced:
|
|
563
|
+
self._restore_checkpoint(live_raw)
|
|
564
|
+
raise
|
|
565
|
+
finally:
|
|
566
|
+
try:
|
|
567
|
+
os.unlink(temporary, dir_fd=self._root_fd)
|
|
568
|
+
except FileNotFoundError:
|
|
569
|
+
pass
|
|
570
|
+
if "page_shards" in next_state:
|
|
571
|
+
self._active_histories = {
|
|
572
|
+
"page": next_state["page_shards"],
|
|
573
|
+
"response": next_state["response_shards"],
|
|
574
|
+
}
|
|
575
|
+
return next_state
|
|
576
|
+
|
|
577
|
+
def _restore_checkpoint(self, predecessor: bytes | None) -> None:
|
|
578
|
+
"""Restore the authorized head after a post-replace durability failure."""
|
|
579
|
+
|
|
580
|
+
if predecessor is None:
|
|
581
|
+
try:
|
|
582
|
+
os.unlink("state.json", dir_fd=self._root_fd)
|
|
583
|
+
except FileNotFoundError:
|
|
584
|
+
pass
|
|
585
|
+
os.fsync(self._root_fd)
|
|
586
|
+
return
|
|
587
|
+
temporary = f".state.recover.{os.getpid()}.{secrets.token_hex(8)}.tmp"
|
|
588
|
+
flags = os.O_RDWR | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
|
589
|
+
try:
|
|
590
|
+
descriptor = os.open(temporary, flags, 0o600, dir_fd=self._root_fd)
|
|
591
|
+
try:
|
|
592
|
+
_write_all(descriptor, predecessor)
|
|
593
|
+
os.fsync(descriptor)
|
|
594
|
+
finally:
|
|
595
|
+
os.close(descriptor)
|
|
596
|
+
os.replace(
|
|
597
|
+
temporary,
|
|
598
|
+
"state.json",
|
|
599
|
+
src_dir_fd=self._root_fd,
|
|
600
|
+
dst_dir_fd=self._root_fd,
|
|
601
|
+
)
|
|
602
|
+
os.fsync(self._root_fd)
|
|
603
|
+
if (
|
|
604
|
+
self._read_regular_at(self._root_fd, "state.json", maximum=len(predecessor))
|
|
605
|
+
!= predecessor
|
|
606
|
+
):
|
|
607
|
+
raise CatalogFillRefused(
|
|
608
|
+
"FILL_CHECKPOINT_RECOVERY",
|
|
609
|
+
"fill.staging.state",
|
|
610
|
+
"predecessor checkpoint recovery readback differs",
|
|
611
|
+
)
|
|
612
|
+
finally:
|
|
613
|
+
try:
|
|
614
|
+
os.unlink(temporary, dir_fd=self._root_fd)
|
|
615
|
+
except FileNotFoundError:
|
|
616
|
+
pass
|
|
617
|
+
|
|
618
|
+
def state_digest(self) -> str:
|
|
619
|
+
self._require_locked()
|
|
620
|
+
try:
|
|
621
|
+
raw = self._read_regular_at(self._root_fd, "state.json", maximum=MAX_STATE_BYTES)
|
|
622
|
+
except FileNotFoundError as error:
|
|
623
|
+
raise CatalogFillRefused(
|
|
624
|
+
"FILL_CHECKPOINT_CORRUPT", "fill.staging.state", "state is absent"
|
|
625
|
+
) from error
|
|
626
|
+
return sha256_bytes(raw)
|
|
627
|
+
|
|
628
|
+
def _load_state(self, *, validate_shards: bool) -> dict[str, Any] | None:
|
|
629
|
+
state, _raw = self._read_state_document()
|
|
630
|
+
if state is None:
|
|
631
|
+
return None
|
|
632
|
+
if type(state.get("generation")) is not int or state["generation"] < 0:
|
|
633
|
+
raise CatalogFillRefused(
|
|
634
|
+
"FILL_CHECKPOINT_CORRUPT", "fill.staging.generation", "generation is invalid"
|
|
635
|
+
)
|
|
636
|
+
budget = state.get("budget")
|
|
637
|
+
budget_keys = {
|
|
638
|
+
"requests",
|
|
639
|
+
"responses",
|
|
640
|
+
"pages",
|
|
641
|
+
"records",
|
|
642
|
+
"network_bytes",
|
|
643
|
+
"elapsed_ns",
|
|
644
|
+
"skipped_records",
|
|
645
|
+
}
|
|
646
|
+
if (
|
|
647
|
+
not isinstance(budget, dict)
|
|
648
|
+
or set(budget) != budget_keys
|
|
649
|
+
or any(type(value) is not int or value < 0 for value in budget.values())
|
|
650
|
+
):
|
|
651
|
+
raise CatalogFillRefused(
|
|
652
|
+
"FILL_CHECKPOINT_CORRUPT", "fill.staging.budget", "budget is invalid"
|
|
653
|
+
)
|
|
654
|
+
if "evidence_indexes" in state:
|
|
655
|
+
if "page_history" in state or "response_history" in state:
|
|
656
|
+
raise CatalogFillRefused(
|
|
657
|
+
"FILL_CHECKPOINT_CORRUPT",
|
|
658
|
+
"fill.staging.state",
|
|
659
|
+
"segmented state may not carry legacy histories",
|
|
660
|
+
)
|
|
661
|
+
indexes = state["evidence_indexes"]
|
|
662
|
+
if not isinstance(indexes, dict) or not indexes:
|
|
663
|
+
raise CatalogFillRefused(
|
|
664
|
+
"FILL_CHECKPOINT_CORRUPT", "fill.staging.indexes", "indexes are invalid"
|
|
665
|
+
)
|
|
666
|
+
for stream, head in indexes.items():
|
|
667
|
+
if not isinstance(stream, str) or not stream:
|
|
668
|
+
raise CatalogFillRefused(
|
|
669
|
+
"FILL_CHECKPOINT_CORRUPT",
|
|
670
|
+
"fill.staging.indexes",
|
|
671
|
+
"index stream is invalid",
|
|
672
|
+
)
|
|
673
|
+
_validate_index_head(head)
|
|
674
|
+
if validate_shards:
|
|
675
|
+
# Segmented checkpoints carry a producer-authenticated tail seal. Ordinary
|
|
676
|
+
# resumes authenticate the immutable appended tail here; eligibility runs
|
|
677
|
+
# the full streaming chain audit explicitly.
|
|
678
|
+
self.index_tail_items(head, stream=stream)
|
|
679
|
+
return state
|
|
680
|
+
pages = self._expand_history("page", state.get("page_history"))
|
|
681
|
+
responses = self._expand_history("response", state.get("response_history"))
|
|
682
|
+
if budget["pages"] != len(pages) or budget["responses"] < len(responses):
|
|
683
|
+
raise CatalogFillRefused(
|
|
684
|
+
"FILL_CHECKPOINT_CORRUPT",
|
|
685
|
+
"fill.staging.budget",
|
|
686
|
+
"budget does not bind the staged shards",
|
|
687
|
+
)
|
|
688
|
+
if validate_shards:
|
|
689
|
+
for digest in pages:
|
|
690
|
+
self.read_shard("page", digest)
|
|
691
|
+
for digest in responses:
|
|
692
|
+
self.read_shard("response", digest)
|
|
693
|
+
expanded = dict(state)
|
|
694
|
+
del expanded["page_history"]
|
|
695
|
+
del expanded["response_history"]
|
|
696
|
+
expanded["page_shards"] = pages
|
|
697
|
+
expanded["response_shards"] = responses
|
|
698
|
+
self._active_histories = {"page": pages, "response": responses}
|
|
699
|
+
return expanded
|
|
700
|
+
|
|
701
|
+
def _read_state_document(self) -> tuple[dict[str, Any] | None, bytes | None]:
|
|
702
|
+
try:
|
|
703
|
+
raw = self._read_regular_at(self._root_fd, "state.json", maximum=MAX_STATE_BYTES)
|
|
704
|
+
except FileNotFoundError:
|
|
705
|
+
return None, None
|
|
706
|
+
try:
|
|
707
|
+
state = parse_canonical_json(raw)
|
|
708
|
+
except CanonicalJSONError as error:
|
|
709
|
+
raise CatalogFillRefused(
|
|
710
|
+
"FILL_CHECKPOINT_CORRUPT",
|
|
711
|
+
"fill.staging.state",
|
|
712
|
+
f"state is not canonical: {error.code}",
|
|
713
|
+
) from None
|
|
714
|
+
if not isinstance(state, dict) or state.get("schema_version") != STATE_SCHEMA:
|
|
715
|
+
raise CatalogFillRefused(
|
|
716
|
+
"FILL_CHECKPOINT_CORRUPT", "fill.staging.state", "state schema is invalid"
|
|
717
|
+
)
|
|
718
|
+
if "page_shards" in state or "response_shards" in state:
|
|
719
|
+
raise CatalogFillRefused(
|
|
720
|
+
"FILL_CHECKPOINT_CORRUPT",
|
|
721
|
+
"fill.staging.state",
|
|
722
|
+
"growing shard history may not be embedded in the checkpoint head",
|
|
723
|
+
)
|
|
724
|
+
return state, raw
|
|
725
|
+
|
|
726
|
+
def _advance_history(
|
|
727
|
+
self,
|
|
728
|
+
stream: str,
|
|
729
|
+
values: Any,
|
|
730
|
+
*,
|
|
731
|
+
live_document: dict[str, Any] | None,
|
|
732
|
+
) -> dict[str, Any]:
|
|
733
|
+
if not isinstance(values, list) or any(not _is_digest(value) for value in values):
|
|
734
|
+
raise CatalogFillRefused(
|
|
735
|
+
"FILL_CHECKPOINT_CORRUPT",
|
|
736
|
+
f"fill.staging.{stream}_shards",
|
|
737
|
+
"history values must be SHA-256 digests",
|
|
738
|
+
)
|
|
739
|
+
prior = (
|
|
740
|
+
{"head_sha256": None, "count": 0}
|
|
741
|
+
if live_document is None
|
|
742
|
+
else live_document.get(f"{stream}_history")
|
|
743
|
+
)
|
|
744
|
+
if not _valid_history_head(prior):
|
|
745
|
+
raise CatalogFillRefused(
|
|
746
|
+
"FILL_CHECKPOINT_CORRUPT",
|
|
747
|
+
f"fill.staging.{stream}_history",
|
|
748
|
+
"history head is invalid",
|
|
749
|
+
)
|
|
750
|
+
prior_count = prior["count"]
|
|
751
|
+
active = self._active_histories.get(stream)
|
|
752
|
+
if live_document is not None and values is not active:
|
|
753
|
+
raise CatalogFillRefused(
|
|
754
|
+
"FILL_CHECKPOINT_STALE",
|
|
755
|
+
f"fill.staging.{stream}_history",
|
|
756
|
+
"history must extend the exact list returned by the retained staging load",
|
|
757
|
+
)
|
|
758
|
+
if not prior_count <= len(values) <= MAX_HISTORY_DIGESTS:
|
|
759
|
+
raise CatalogFillRefused(
|
|
760
|
+
"FILL_CHECKPOINT_LIMIT",
|
|
761
|
+
f"fill.staging.{stream}_history",
|
|
762
|
+
"history count is outside its bound",
|
|
763
|
+
)
|
|
764
|
+
suffix = values[prior_count:]
|
|
765
|
+
if not suffix:
|
|
766
|
+
return dict(prior)
|
|
767
|
+
head = self.write_shard(
|
|
768
|
+
"history",
|
|
769
|
+
{
|
|
770
|
+
"stream": stream,
|
|
771
|
+
"previous_sha256": prior["head_sha256"],
|
|
772
|
+
"start": prior_count,
|
|
773
|
+
"digests": suffix,
|
|
774
|
+
},
|
|
775
|
+
)
|
|
776
|
+
return {"head_sha256": head, "count": len(values)}
|
|
777
|
+
|
|
778
|
+
def _expand_history(self, stream: str, head: Any) -> list[str]:
|
|
779
|
+
if not _valid_history_head(head):
|
|
780
|
+
raise CatalogFillRefused(
|
|
781
|
+
"FILL_CHECKPOINT_CORRUPT",
|
|
782
|
+
f"fill.staging.{stream}_history",
|
|
783
|
+
"history head is invalid",
|
|
784
|
+
)
|
|
785
|
+
count = head["count"]
|
|
786
|
+
if count > MAX_HISTORY_DIGESTS:
|
|
787
|
+
raise CatalogFillRefused(
|
|
788
|
+
"FILL_CHECKPOINT_LIMIT",
|
|
789
|
+
f"fill.staging.{stream}_history",
|
|
790
|
+
"history exceeds the digest-count limit",
|
|
791
|
+
)
|
|
792
|
+
chunks: list[tuple[int, list[str]]] = []
|
|
793
|
+
seen: set[str] = set()
|
|
794
|
+
current = head["head_sha256"]
|
|
795
|
+
while current is not None:
|
|
796
|
+
if current in seen or len(seen) > count:
|
|
797
|
+
raise CatalogFillRefused(
|
|
798
|
+
"FILL_CHECKPOINT_CORRUPT",
|
|
799
|
+
f"fill.staging.{stream}_history",
|
|
800
|
+
"history chain loops or exceeds its count",
|
|
801
|
+
)
|
|
802
|
+
seen.add(current)
|
|
803
|
+
node = self.read_shard("history", current)
|
|
804
|
+
if (
|
|
805
|
+
set(node) != {"stream", "previous_sha256", "start", "digests"}
|
|
806
|
+
or node["stream"] != stream
|
|
807
|
+
or type(node["start"]) is not int
|
|
808
|
+
or not isinstance(node["digests"], list)
|
|
809
|
+
or any(not _is_digest(value) for value in node["digests"])
|
|
810
|
+
or (node["previous_sha256"] is not None and not _is_digest(node["previous_sha256"]))
|
|
811
|
+
):
|
|
812
|
+
raise CatalogFillRefused(
|
|
813
|
+
"FILL_CHECKPOINT_CORRUPT",
|
|
814
|
+
f"fill.staging.{stream}_history",
|
|
815
|
+
"history node is invalid",
|
|
816
|
+
)
|
|
817
|
+
chunks.append((node["start"], node["digests"]))
|
|
818
|
+
current = node["previous_sha256"]
|
|
819
|
+
result: list[str] = []
|
|
820
|
+
for start, digests in reversed(chunks):
|
|
821
|
+
if start != len(result):
|
|
822
|
+
raise CatalogFillRefused(
|
|
823
|
+
"FILL_CHECKPOINT_CORRUPT",
|
|
824
|
+
f"fill.staging.{stream}_history",
|
|
825
|
+
"history nodes are not contiguous",
|
|
826
|
+
)
|
|
827
|
+
result.extend(digests)
|
|
828
|
+
if len(result) != count:
|
|
829
|
+
raise CatalogFillRefused(
|
|
830
|
+
"FILL_CHECKPOINT_CORRUPT",
|
|
831
|
+
f"fill.staging.{stream}_history",
|
|
832
|
+
"history count differs from its chain",
|
|
833
|
+
)
|
|
834
|
+
return result
|
|
835
|
+
|
|
836
|
+
def _open_retained_tree(self) -> None:
|
|
837
|
+
if self._retained_root_descriptor is not None:
|
|
838
|
+
try:
|
|
839
|
+
descriptor = os.dup(self._retained_root_descriptor)
|
|
840
|
+
opened = os.fstat(descriptor)
|
|
841
|
+
except OSError as error:
|
|
842
|
+
raise CatalogFillRefused(
|
|
843
|
+
"FILL_STAGING_PATH",
|
|
844
|
+
"fill.staging",
|
|
845
|
+
"the retained staging root is unusable",
|
|
846
|
+
) from error
|
|
847
|
+
self._owned_fds.append(descriptor)
|
|
848
|
+
if not stat.S_ISDIR(opened.st_mode) or stat.S_IMODE(opened.st_mode) & 0o077:
|
|
849
|
+
raise CatalogFillRefused(
|
|
850
|
+
"FILL_STAGING_PERMISSIONS",
|
|
851
|
+
"fill.staging",
|
|
852
|
+
"staging root must be a retained private directory",
|
|
853
|
+
)
|
|
854
|
+
self._root_fd = descriptor
|
|
855
|
+
self._shards_fd = self._open_or_create_directory(self._root_fd, "shards")
|
|
856
|
+
for kind in sorted(_SHARD_KINDS | _BLOB_KINDS):
|
|
857
|
+
self._kind_fds[kind] = self._open_or_create_directory(self._shards_fd, kind)
|
|
858
|
+
return
|
|
859
|
+
absolute = Path(os.path.abspath(self.root))
|
|
860
|
+
descriptor = os.open("/", _OPEN_DIRECTORY)
|
|
861
|
+
self._owned_fds.append(descriptor)
|
|
862
|
+
for part in absolute.parts[1:]:
|
|
863
|
+
try:
|
|
864
|
+
named = os.stat(part, dir_fd=descriptor, follow_symlinks=False)
|
|
865
|
+
except FileNotFoundError:
|
|
866
|
+
os.mkdir(part, mode=0o700, dir_fd=descriptor)
|
|
867
|
+
os.fsync(descriptor)
|
|
868
|
+
named = os.stat(part, dir_fd=descriptor, follow_symlinks=False)
|
|
869
|
+
child = os.open(part, _OPEN_DIRECTORY, dir_fd=descriptor)
|
|
870
|
+
self._owned_fds.append(child)
|
|
871
|
+
opened = os.fstat(child)
|
|
872
|
+
if not stat.S_ISDIR(named.st_mode) or _directory_identity(named) != _directory_identity(
|
|
873
|
+
opened
|
|
874
|
+
):
|
|
875
|
+
raise CatalogFillRefused(
|
|
876
|
+
"FILL_STAGING_RACE", "fill.staging", "named and opened directory differ"
|
|
877
|
+
)
|
|
878
|
+
self._chain.append((descriptor, part, child, _directory_identity(opened)))
|
|
879
|
+
descriptor = child
|
|
880
|
+
self._root_fd = descriptor
|
|
881
|
+
root_info = os.fstat(self._root_fd)
|
|
882
|
+
if stat.S_IMODE(root_info.st_mode) & 0o077:
|
|
883
|
+
raise CatalogFillRefused(
|
|
884
|
+
"FILL_STAGING_PERMISSIONS", "fill.staging", "staging root must be mode 0700"
|
|
885
|
+
)
|
|
886
|
+
self._shards_fd = self._open_or_create_directory(self._root_fd, "shards")
|
|
887
|
+
for kind in sorted(_SHARD_KINDS | _BLOB_KINDS):
|
|
888
|
+
self._kind_fds[kind] = self._open_or_create_directory(self._shards_fd, kind)
|
|
889
|
+
|
|
890
|
+
def _open_or_create_directory(self, parent_fd: int, name: str) -> int:
|
|
891
|
+
try:
|
|
892
|
+
os.mkdir(name, mode=0o700, dir_fd=parent_fd)
|
|
893
|
+
os.fsync(parent_fd)
|
|
894
|
+
except FileExistsError:
|
|
895
|
+
pass
|
|
896
|
+
named = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
|
897
|
+
descriptor = os.open(name, _OPEN_DIRECTORY, dir_fd=parent_fd)
|
|
898
|
+
self._owned_fds.append(descriptor)
|
|
899
|
+
opened = os.fstat(descriptor)
|
|
900
|
+
if (
|
|
901
|
+
not stat.S_ISDIR(named.st_mode)
|
|
902
|
+
or _directory_identity(named) != _directory_identity(opened)
|
|
903
|
+
or stat.S_IMODE(opened.st_mode) & 0o077
|
|
904
|
+
):
|
|
905
|
+
raise CatalogFillRefused(
|
|
906
|
+
"FILL_STAGING_PATH",
|
|
907
|
+
"fill.staging",
|
|
908
|
+
"staging components must be retained private directories",
|
|
909
|
+
)
|
|
910
|
+
return descriptor
|
|
911
|
+
|
|
912
|
+
def _verify_tree(self) -> None:
|
|
913
|
+
for parent_fd, name, child_fd, identity in self._chain:
|
|
914
|
+
try:
|
|
915
|
+
named = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
|
916
|
+
except OSError as error:
|
|
917
|
+
raise CatalogFillRefused(
|
|
918
|
+
"FILL_STAGING_RACE", "fill.staging", "staging ancestor was replaced"
|
|
919
|
+
) from error
|
|
920
|
+
if (
|
|
921
|
+
_directory_identity(named) != identity
|
|
922
|
+
or _directory_identity(os.fstat(child_fd)) != identity
|
|
923
|
+
):
|
|
924
|
+
raise CatalogFillRefused(
|
|
925
|
+
"FILL_STAGING_RACE", "fill.staging", "staging ancestor was replaced"
|
|
926
|
+
)
|
|
927
|
+
for parent_fd, name, child_fd in (
|
|
928
|
+
(self._root_fd, "shards", self._shards_fd),
|
|
929
|
+
*((self._shards_fd, kind, fd) for kind, fd in self._kind_fds.items()),
|
|
930
|
+
):
|
|
931
|
+
named = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
|
932
|
+
if _directory_identity(named) != _directory_identity(os.fstat(child_fd)):
|
|
933
|
+
raise CatalogFillRefused(
|
|
934
|
+
"FILL_STAGING_RACE", "fill.staging", "staging directory was replaced"
|
|
935
|
+
)
|
|
936
|
+
|
|
937
|
+
def _read_regular_at(self, parent_fd: int, name: str, *, maximum: int) -> bytes:
|
|
938
|
+
descriptor = -1
|
|
939
|
+
try:
|
|
940
|
+
named_before = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
|
941
|
+
descriptor = os.open(name, _OPEN_MEMBER, dir_fd=parent_fd)
|
|
942
|
+
except FileNotFoundError:
|
|
943
|
+
if descriptor >= 0:
|
|
944
|
+
os.close(descriptor)
|
|
945
|
+
raise
|
|
946
|
+
except OSError as error:
|
|
947
|
+
if descriptor >= 0:
|
|
948
|
+
os.close(descriptor)
|
|
949
|
+
raise CatalogFillRefused(
|
|
950
|
+
"FILL_CHECKPOINT_CORRUPT", "fill.staging.member", "member cannot be opened"
|
|
951
|
+
) from error
|
|
952
|
+
try:
|
|
953
|
+
opened_before = os.fstat(descriptor)
|
|
954
|
+
if (
|
|
955
|
+
not stat.S_ISREG(named_before.st_mode)
|
|
956
|
+
or not stat.S_ISREG(opened_before.st_mode)
|
|
957
|
+
or named_before.st_nlink != 1
|
|
958
|
+
or opened_before.st_nlink != 1
|
|
959
|
+
or opened_before.st_size > maximum
|
|
960
|
+
or _identity(named_before) != _identity(opened_before)
|
|
961
|
+
):
|
|
962
|
+
raise CatalogFillRefused(
|
|
963
|
+
"FILL_CHECKPOINT_CORRUPT",
|
|
964
|
+
"fill.staging.member",
|
|
965
|
+
"member is not a bounded single-link regular file",
|
|
966
|
+
)
|
|
967
|
+
raw = _read_open(descriptor, maximum=maximum)
|
|
968
|
+
opened_after = os.fstat(descriptor)
|
|
969
|
+
named_after = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
|
970
|
+
if (
|
|
971
|
+
_identity(opened_before) != _identity(opened_after)
|
|
972
|
+
or _identity(opened_after) != _identity(named_after)
|
|
973
|
+
or len(raw) != opened_after.st_size
|
|
974
|
+
):
|
|
975
|
+
raise CatalogFillRefused(
|
|
976
|
+
"FILL_CHECKPOINT_CORRUPT", "fill.staging.member", "member changed during read"
|
|
977
|
+
)
|
|
978
|
+
return raw
|
|
979
|
+
finally:
|
|
980
|
+
os.close(descriptor)
|
|
981
|
+
|
|
982
|
+
def _require_locked(self) -> None:
|
|
983
|
+
if self._root_fd < 0:
|
|
984
|
+
raise CatalogFillRefused(
|
|
985
|
+
"FILL_STAGING_LOCK", "fill.staging", "operation requires the retained lock"
|
|
986
|
+
)
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _read_open(descriptor: int, *, maximum: int) -> bytes:
|
|
990
|
+
chunks: list[bytes] = []
|
|
991
|
+
observed = 0
|
|
992
|
+
while True:
|
|
993
|
+
chunk = os.read(descriptor, min(1024 * 1024, maximum - observed + 1))
|
|
994
|
+
if not chunk:
|
|
995
|
+
break
|
|
996
|
+
observed += len(chunk)
|
|
997
|
+
if observed > maximum:
|
|
998
|
+
raise CatalogFillRefused(
|
|
999
|
+
"FILL_CHECKPOINT_CORRUPT", "fill.staging.member", "member exceeds byte limit"
|
|
1000
|
+
)
|
|
1001
|
+
chunks.append(chunk)
|
|
1002
|
+
return b"".join(chunks)
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def _write_all(descriptor: int, raw: bytes) -> None:
|
|
1006
|
+
written = 0
|
|
1007
|
+
while written < len(raw):
|
|
1008
|
+
count = os.write(descriptor, raw[written:])
|
|
1009
|
+
if count < 1:
|
|
1010
|
+
raise CatalogFillRefused(
|
|
1011
|
+
"FILL_STAGING_WRITE", "fill.staging.member", "write made no progress"
|
|
1012
|
+
)
|
|
1013
|
+
written += count
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def _identity(info: os.stat_result) -> tuple[int, ...]:
|
|
1017
|
+
return (
|
|
1018
|
+
info.st_dev,
|
|
1019
|
+
info.st_ino,
|
|
1020
|
+
info.st_mode,
|
|
1021
|
+
info.st_nlink,
|
|
1022
|
+
info.st_size,
|
|
1023
|
+
info.st_mtime_ns,
|
|
1024
|
+
info.st_ctime_ns,
|
|
1025
|
+
)
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def _directory_identity(info: os.stat_result) -> tuple[int, int, int]:
|
|
1029
|
+
return (info.st_dev, info.st_ino, stat.S_IFMT(info.st_mode))
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def _is_digest(value: Any) -> bool:
|
|
1033
|
+
return (
|
|
1034
|
+
isinstance(value, str) and len(value) == 64 and all(c in "0123456789abcdef" for c in value)
|
|
1035
|
+
)
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def _valid_history_head(value: Any) -> bool:
|
|
1039
|
+
return (
|
|
1040
|
+
isinstance(value, dict)
|
|
1041
|
+
and set(value) == {"head_sha256", "count"}
|
|
1042
|
+
and type(value["count"]) is int
|
|
1043
|
+
and value["count"] >= 0
|
|
1044
|
+
and (
|
|
1045
|
+
(value["count"] == 0 and value["head_sha256"] is None)
|
|
1046
|
+
or (value["count"] > 0 and _is_digest(value["head_sha256"]))
|
|
1047
|
+
)
|
|
1048
|
+
)
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def canonical_index_root(
|
|
1052
|
+
*,
|
|
1053
|
+
previous_root: str | None,
|
|
1054
|
+
segment_sha256: str,
|
|
1055
|
+
start: int,
|
|
1056
|
+
item_count: int,
|
|
1057
|
+
) -> str:
|
|
1058
|
+
return canonical_sha256(
|
|
1059
|
+
{
|
|
1060
|
+
"previous_root_sha256": previous_root,
|
|
1061
|
+
"segment_sha256": segment_sha256,
|
|
1062
|
+
"start": start,
|
|
1063
|
+
"item_count": item_count,
|
|
1064
|
+
}
|
|
1065
|
+
)
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
def _validate_index_head(value: Any) -> None:
|
|
1069
|
+
if (
|
|
1070
|
+
not isinstance(value, dict)
|
|
1071
|
+
or set(value)
|
|
1072
|
+
!= {
|
|
1073
|
+
"root_sha256",
|
|
1074
|
+
"count",
|
|
1075
|
+
"tail_sha256",
|
|
1076
|
+
"segment_count",
|
|
1077
|
+
"descriptor_count",
|
|
1078
|
+
}
|
|
1079
|
+
or type(value["count"]) is not int
|
|
1080
|
+
or type(value["segment_count"]) is not int
|
|
1081
|
+
or value["count"] < 0
|
|
1082
|
+
or value["segment_count"] < 0
|
|
1083
|
+
or value["count"] > MAX_INDEX_ITEMS
|
|
1084
|
+
or value["segment_count"] > value["count"]
|
|
1085
|
+
or value["descriptor_count"] != 0
|
|
1086
|
+
or (
|
|
1087
|
+
value["count"] == 0
|
|
1088
|
+
and (
|
|
1089
|
+
value["root_sha256"] is not None
|
|
1090
|
+
or value["tail_sha256"] is not None
|
|
1091
|
+
or value["segment_count"] != 0
|
|
1092
|
+
)
|
|
1093
|
+
)
|
|
1094
|
+
or (
|
|
1095
|
+
value["count"] > 0
|
|
1096
|
+
and (
|
|
1097
|
+
not _is_digest(value["root_sha256"])
|
|
1098
|
+
or not _is_digest(value["tail_sha256"])
|
|
1099
|
+
or value["segment_count"] < 1
|
|
1100
|
+
)
|
|
1101
|
+
)
|
|
1102
|
+
):
|
|
1103
|
+
raise CatalogFillRefused(
|
|
1104
|
+
"FILL_CHECKPOINT_CORRUPT", "fill.staging.index", "index head is invalid"
|
|
1105
|
+
)
|