diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..c9cb054d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,69 @@ +name: 🐛 Bug Report +description: File a bug report to help us improve +labels: [bug] +body: + - type: textarea + id: what-happened + attributes: + label: What happened? + description: | + Thanks for reporting a bug! Please describe what you were trying to get done. + Tell us what happened, what went wrong. + validations: + required: true + + - type: textarea + id: what-did-you-expect-to-happen + attributes: + label: What did you expect to happen? + description: | + Describe what you expected to happen. + validations: + required: false + + - type: textarea + id: sample-code + attributes: + label: Minimal Complete Verifiable Example + description: | + Minimal, self-contained copy-pastable example that demonstrates the issue. This will be automatically formatted into code, so no need for markdown backticks. + render: Python + + - type: checkboxes + id: mvce-checkboxes + attributes: + label: MVCE confirmation + description: | + Please confirm that the bug report is in an excellent state, so we can understand & fix it quickly & efficiently. For more details, check out: + + - [Minimal Complete Verifiable Examples](https://stackoverflow.com/help/mcve) + - [Craft Minimal Bug Reports](https://matthewrocklin.com/minimal-bug-reports) + + options: + - label: Minimal example — the example is as focused as reasonably possible to demonstrate the underlying issue in xarray. + - label: Complete example — the example is self-contained, including all data and the text of any traceback. + - label: Verifiable example — the example runs when copied & pasted into an fresh python environment. + - label: New issue — a search of GitHub Issues suggests this is not a duplicate. + + - type: textarea + id: log-output + attributes: + label: Relevant log output + description: Please copy and paste any relevant output. This will be automatically formatted into code, so no need for markdown backticks. + render: Python + + - type: textarea + id: extra + attributes: + label: Anything else we need to know? + description: | + Please describe any other information you want to share. + + - type: textarea + id: show-versions + attributes: + label: Environment + description: | + Paste the output of `icechunk.print_debug_info()` + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..3eece15e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: True diff --git a/.github/ISSUE_TEMPLATE/documentation.md b/.github/ISSUE_TEMPLATE/documentation.md new file mode 100644 index 00000000..12aac94d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.md @@ -0,0 +1,29 @@ +--- +name: 📚 Documentation Issue/Suggestion +about: Report parts probems with the docs or suggest improvments +labels: documentation +--- + + + + +### Problem + + + + +### Suggested Improvement + + diff --git a/.github/ISSUE_TEMPLATE/enhancement.md b/.github/ISSUE_TEMPLATE/enhancement.md new file mode 100644 index 00000000..2446cfca --- /dev/null +++ b/.github/ISSUE_TEMPLATE/enhancement.md @@ -0,0 +1,35 @@ +--- +name: Enhancement/Feature Request +about: Suggest something that could be improved or a New Feature to add +labels: enhancement +--- + + + +### Problem + + + +### Proposed Solution + + + +### Additional context + + diff --git a/.github/workflows/python-check.yaml b/.github/workflows/python-check.yaml index 32b9eabb..aaa033f6 100644 --- a/.github/workflows/python-check.yaml +++ b/.github/workflows/python-check.yaml @@ -6,16 +6,6 @@ on: - main pull_request: types: [opened, reopened, synchronize, labeled] - paths: - - 'icechunk/**' - - 'icechunk-python/**' - - '.github/workflows/python-check.yaml' - - 'Cargo.toml' - - 'Cargo.lock' - - 'compose.yaml' - - 'deny.toml' - - 'Justfile' - - 'rustfmt.toml' workflow_dispatch: concurrency: @@ -173,5 +163,6 @@ jobs: python3 -m venv .venv source .venv/bin/activate pip install icechunk['test'] --find-links dist --force-reinstall + pip install pytest-mypy-plugins # pass xarray's pyproject.toml so that pytest can find the `flaky` fixture pytest -c=../../xarray/pyproject.toml -W ignore tests/run_xarray_backends_tests.py diff --git a/.github/workflows/rust-ci.yaml b/.github/workflows/rust-ci.yaml index 8e272df9..d6cccc7c 100644 --- a/.github/workflows/rust-ci.yaml +++ b/.github/workflows/rust-ci.yaml @@ -5,16 +5,6 @@ name: Rust CI on: pull_request: types: [opened, reopened, synchronize, labeled] - paths: - - 'icechunk/**' - - 'icechunk-python/**' - - '.github/workflows/rust-ci.yaml' - - 'Cargo.toml' - - 'Cargo.lock' - - 'compose.yaml' - - 'deny.toml' - - 'Justfile' - - 'rustfmt.toml' push: branches: - main diff --git a/Cargo.lock b/Cargo.lock index 317e251c..069cc8e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,12 +60,6 @@ dependencies = [ "libc", ] -[[package]] -name = "anyhow" -version = "1.0.94" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7" - [[package]] name = "async-recursion" version = "1.1.1" @@ -532,6 +526,15 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + [[package]] name = "base16ct" version = "0.1.1" @@ -589,9 +592,9 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitflags" -version = "2.6.0" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" [[package]] name = "block-buffer" @@ -882,6 +885,12 @@ dependencies = [ "typeid", ] +[[package]] +name = "err-into" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f003b437a8029298beb1a849ea8c5c8229f0c1225e3e854c4523dbe8d90b02d" + [[package]] name = "errno" version = "0.3.9" @@ -908,6 +917,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "flatbuffers" +version = "25.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1045398c1bfd89168b5fd3f1fc11f6e70b34f6f66300c87d44d3de849463abf1" +dependencies = [ + "bitflags", + "rustc_version", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1339,7 +1358,7 @@ dependencies = [ [[package]] name = "icechunk" -version = "0.1.0" +version = "0.2.3" dependencies = [ "async-recursion", "async-stream", @@ -1352,6 +1371,8 @@ dependencies = [ "base64 0.22.1", "bytes", "chrono", + "err-into", + "flatbuffers", "futures", "itertools 0.14.0", "object_store", @@ -1367,12 +1388,15 @@ dependencies = [ "serde_bytes", "serde_json", "serde_with", - "serde_yml", + "serde_yaml_ng", "tempfile", "test-strategy", "thiserror 2.0.11", "tokio", "tokio-util", + "tracing", + "tracing-error", + "tracing-subscriber", "typed-path", "typetag", "url", @@ -1381,7 +1405,7 @@ dependencies = [ [[package]] name = "icechunk-python" -version = "0.1.0" +version = "0.2.3" dependencies = [ "async-stream", "async-trait", @@ -1390,6 +1414,7 @@ dependencies = [ "futures", "icechunk", "itertools 0.14.0", + "miette", "pyo3", "pyo3-async-runtimes", "serde", @@ -1584,6 +1609,12 @@ version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708" +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "itertools" version = "0.13.0" @@ -1638,16 +1669,6 @@ version = "0.2.169" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" -[[package]] -name = "libyml" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" -dependencies = [ - "anyhow", - "version_check", -] - [[package]] name = "linux-raw-sys" version = "0.4.14" @@ -1685,6 +1706,15 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + [[package]] name = "md-5" version = "0.10.6" @@ -1710,6 +1740,37 @@ dependencies = [ "autocfg", ] +[[package]] +name = "miette" +version = "7.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a955165f87b37fd1862df2a59547ac542c77ef6d17c666f619d1ad22dd89484" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "thiserror 1.0.69", + "unicode-width", +] + +[[package]] +name = "miette-derive" +version = "7.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf45bf44ab49be92fd1227a3be6fc6f617f1a337c06af54981048574d8783147" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "mime" version = "0.3.17" @@ -1737,6 +1798,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -1820,6 +1891,18 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4030760ffd992bef45b0ae3f10ce1aba99e33464c90d14dd7c039884963ddc7a" +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "owo-colors" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb37767f6569cd834a413442455e0f066d0d522de8630436e2a1761d9726ba56" + [[package]] name = "p256" version = "0.11.1" @@ -1948,7 +2031,7 @@ dependencies = [ "rand 0.8.5", "rand_chacha 0.3.1", "rand_xorshift", - "regex-syntax", + "regex-syntax 0.8.5", "rusty-fork", "tempfile", "unarray", @@ -2212,8 +2295,17 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata", - "regex-syntax", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", ] [[package]] @@ -2224,7 +2316,7 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.8.5", ] [[package]] @@ -2233,6 +2325,12 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + [[package]] name = "regex-syntax" version = "0.8.5" @@ -2653,18 +2751,16 @@ dependencies = [ ] [[package]] -name = "serde_yml" -version = "0.0.12" +name = "serde_yaml_ng" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" dependencies = [ "indexmap 2.2.6", "itoa", - "libyml", - "memchr", "ryu", "serde", - "version_check", + "unsafe-libyaml", ] [[package]] @@ -2689,6 +2785,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "signal-hook-registry" version = "1.4.2" @@ -2811,6 +2916,27 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f44ed3c63152de6a9f90acbea1a110441de43006ea51bcce8f436196a288b" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + [[package]] name = "syn" version = "2.0.89" @@ -2862,6 +2988,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "terminal_size" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5352447f921fda68cf61b4101566c0bdb5104eff6804d0678e5227580ab6a4e9" +dependencies = [ + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "test-strategy" version = "0.4.0" @@ -2874,6 +3010,16 @@ dependencies = [ "syn", ] +[[package]] +name = "textwrap" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d434d3f8967a09480fb04132ebe0a3e088c173e6d0ee7897abbdf4eab0f8b9" +dependencies = [ + "unicode-linebreak", + "unicode-width", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2914,6 +3060,16 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + [[package]] name = "time" version = "0.3.36" @@ -3040,9 +3196,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.40" +version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -3051,9 +3207,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" dependencies = [ "proc-macro2", "quote", @@ -3062,11 +3218,51 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.32" +version = "0.1.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" +dependencies = [ + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -3129,12 +3325,30 @@ version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unindent" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -3176,6 +3390,12 @@ version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version_check" version = "0.9.5" @@ -3320,6 +3540,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.9" @@ -3329,6 +3565,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index 2d0c0136..7c8f6958 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ unwrap_used = "warn" panic = "warn" todo = "warn" unimplemented = "warn" +dbg_macro = "warn" [workspace.metadata.release] allow-branch = ["main"] diff --git a/Changelog.python.md b/Changelog.python.md index d4c2e700..1211c3ba 100644 --- a/Changelog.python.md +++ b/Changelog.python.md @@ -1,14 +1,159 @@ # Changelog +## Python Icechunk Library 0.2.3 + +### Features + +- `Repository` can now be pickled. +- `icechunk.print_debug_info()` now prints out relative information about the installed version of icechunk and relative dependencies. +- `icechunk.Storage` now supports `__repr__`. Only configuration values will be printed, no credentials. + +### Fixes + +- Fixes a missing export for Google Cloud Storage credentials. + +## Python Icechunk Library 0.2.2 + +### Features + +- Added the ability to checkout a session `as_of` a specific time. This is useful for replaying what the repo would be at a specific point in time. +- Support for refreshable Google Cloud Storage credentials. + +### Fixes + +- Fix a bug where the clean prefix detection was hiding other errors when creating repositories. +- API now correctly uses `snapshot_id` instead of `snapshot` consistently. +- Only write `content-type` to metadata files if the target object store supports it. + +## Python Icechunk Library 0.2.1 + +### Features + +- Users can now override consistency defaults. With this Icechunk is usable in a larger set of object stores, +including those without support for conditional updates. In this setting, Icechunk loses some of its consistency guarantees. +This configuration variables are for advanced users only, and should only be changed if necessary for compatibility. + + ```python + class StorageSettings: + ... + + @property + def unsafe_use_conditional_update(self) -> bool | None: + ... + @property + def unsafe_use_conditional_create(self) -> bool | None: + ... + @property + def unsafe_use_metadata(self) -> bool | None: + ... + ``` + +## Python Icechunk Library 0.2.0 + +This release is focused on stabilizing Icechunk's on-disk serialization format. It's a non-backwards +compatible change, hopefully the last one. Data written with previous versions must be reingested to be read with +Icechunk 0.2.0. + +### Features + +- `Repository.ancestry` now returns an iterator, allowing interrupting the traversal of the version tree at any point. +- New on-disk format using [flatbuffers](https://flatbuffers.dev/) makes it easier to document and implement +(de-)serialization. This enables the creation of alternative readers and writers for the Icechunk format. +- `Repository.readonly_session` interprets its first positional argument as a branch name: + +```python +# before: +repo.readonly_session(branch="dev") + +# after: +repo.readonly_session("dev") + +# still possible: +repo.readonly_session(tag="v0.1") +repo.readonly_session(branch="foo") +repo.readonly_session(snapshot_id="NXH3M0HJ7EEJ0699DPP0") +``` + +- Icechunk is now more resilient to changes in Zarr metadata spec, and can handle Zarr extensions. +- More documentation. + +### Performance + +- We have improved our benchmarks, making them more flexible and effective at finding possible regressions. +- New `Store.set_virtual_refs` method allows setting multiple virtual chunks for the same array. This +significantly speeds up the creation of virtual datasets. + +### Fixes + +- Fix a bug in clean prefix detection + +## Python Icechunk Library 0.1.3 + +### Features + +- Repositories can now evaluate the `diff` between two snapshots. +- Sessions can show the current `status` of the working copy. +- Adds the ability to specify bearer tokens for authenticating with Google Cloud Storage. + +### Fixes + +- Dont write `dimension_names` to the zarr metadata if no dimension names are set. Previously, `null` was written. + +## Python Icechunk Library 0.1.2 + +### Features + +- Improved error messages. Exceptions raised by Icechunk now include a lot more information +on what happened, and what was Icechunk doing when the exception was raised. Example error message: + ![image](https://private-user-images.githubusercontent.com/20792/411051347-2babe5df-dc3b-4305-8ad2-a18fdb4da796.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3Mzg5NTY0NzMsIm5iZiI6MTczODk1NjE3MywicGF0aCI6Ii8yMDc5Mi80MTEwNTEzNDctMmJhYmU1ZGYtZGMzYi00MzA1LThhZDItYTE4ZmRiNGRhNzk2LnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTAyMDclMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwMjA3VDE5MjI1M1omWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPWYzNjY1MTUyOTUwYWMwNWJmMTYwODkzYmY1NGM2YTczNzcxOTBmMTUyNzg3NWE2MWVmMzVmOTcwOTM2MDAxYTQmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.rAstKp5GPVLbftBAAibWNPSCZ0ppz8FTJEvmvbL_Fdw) +- Icechunk generates logs now. Set the environment variable `ICECHUNK_LOG=icechunk=debug` to print debug logs to stdout. Available "levels" in order of increasing verbosity are `error`, `warn`, `info`, `debug`, `trace`. The default level is `error`. Example log: + ![image](https://private-user-images.githubusercontent.com/20792/411051729-7e6de243-73f4-4863-ba79-2dde204fe6e5.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3Mzg5NTY3NTQsIm5iZiI6MTczODk1NjQ1NCwicGF0aCI6Ii8yMDc5Mi80MTEwNTE3MjktN2U2ZGUyNDMtNzNmNC00ODYzLWJhNzktMmRkZTIwNGZlNmU1LnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTAyMDclMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwMjA3VDE5MjczNFomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTQ1MzdmMDY2MDA2YjdiNzUzM2RhMGE5ZDAxZDA2NWI4ZWU3MjcyZTE0YjRkY2U0ZTZkMTcxMzQzMDVjOGQ0NGQmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.LnILQIXxOjkR1y6P5w6k9UREm0zOH1tIzt2vrjVcRKM) +- Icechunk can now be installed using `conda`: + + ```shell + conda install -c conda-forge icechunk + ``` + +- Optionally delete branches and tags that point to expired snapshots: + + ```python + def expire_snapshots( + self, + older_than: datetime.datetime, + *, + delete_expired_branches: bool = False, + delete_expired_tags: bool = False, + ) -> set[str]: ... + ``` + +- More documentation. See [the Icechunk website](https://icechunk.io/) + +### Performance + +- Faster `exists` zarr `Store` method. +- Implement `Store.getsize_prefix` method. This significantly speeds up `info_complete`. + +### Fixes + +- Default regular expression to preload manifests. + +## Python Icechunk Library 0.1.1 + +### Fixes + +- Session deserialization error when using distributed writes + ## Python Icechunk Library 0.1.0 ### Features - Expiration and garbage collection. It's now possible to maintain only recent versions of the repository, reclaiming the storage used exclusively by expired versions. - Allow an arbitrary map of properties to commits. Example: + ``` session.commit("some message", metadata={"author": "icechunk-team"}) ``` + This properties can be retrieved via `ancestry`. - New `chunk_coordinates` function to list all initialized chunks in an array. - It's now possible to delete tags. New tags with the same name won't be allowed to preserve the immutability of snapshots pointed by a tag. @@ -33,7 +178,6 @@ - Bad manifest split in unmodified arrays - Documentation was updated to the latest API. - ## Python Icechunk Library 0.1.0a15 ### Fixes @@ -48,6 +192,7 @@ - The snapshot now keeps track of the chunk space bounding box for each manifest - Configuration settings can now be overridden in a field-by-field basis Example: + ```python config = icechunk.RepositoryConfig(inline_chunk_threshold_byte=0) storage = ... @@ -57,6 +202,7 @@ config=config, ) ``` + will use 0 for `inline_chunk_threshold_byte` but all other configuration fields will come from the repository persistent config. If persistent config is not set, configuration defaults will take its place. @@ -91,6 +237,7 @@ config=config, ) - `ancestry` function can now receive a branch/tag name or a snapshot id + - `set_virtual_ref` can now validate the virtual chunk container exists ``` diff --git a/README.md b/README.md index 6e4f5e0d..2834b952 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ ![Icechunk logo](https://raw.githubusercontent.com/earth-mover/icechunk/refs/heads/main/docs/docs/assets/logo.svg) PyPI +Conda Forge Crates.io GitHub Repo stars Earthmover Community Slack @@ -17,12 +18,12 @@ that enhance performance, collaboration, and safety in a cloud-computing context - This page: a general overview of the project's goals and components. - [Icechunk Launch Blog Post](https://earthmover.io/blog/icechunk) -- [Frequently Asked Questions](https://icechunk.io/faq) -- Documentation for [Icechunk Python](https://icechunk.io/icechunk-python), the main user-facing +- [Frequently Asked Questions](https://icechunk.io/en/latest/faq/) +- Documentation for [Icechunk Python](https://icechunk.io/en/latest/icechunk-python), the main user-facing library -- Documentation for the [Icechunk Rust Crate](https://icechunk.io/icechunk-rust) -- The [Contributor Guide](https://icechunk.io/contributing) -- The [Icechunk Spec](https://icechunk.io/spec) +- Documentation for the [Icechunk Rust Crate](https://icechunk.io/en/latest/icechunk-rust) +- The [Contributor Guide](https://icechunk.io/en/latest/contributing) +- The [Icechunk Spec](https://icechunk.io/en/latest/spec) ## Icechunk Overview @@ -87,6 +88,7 @@ Arbitrary JSON-style key-value metadata can be attached to both arrays and group Every update to an Icechunk store creates a new **snapshot** with a unique ID. Icechunk users must organize their updates into groups of related operations called **transactions**. For example, appending a new time slice to multiple arrays should be done as a single transaction, comprising the following steps + 1. Update the array metadata to resize the array to accommodate the new elements. 2. Write new chunks for each array in the group. diff --git a/design-docs/008-no-copy-serialization-formats.md b/design-docs/008-no-copy-serialization-formats.md new file mode 100644 index 00000000..f83f3a15 --- /dev/null +++ b/design-docs/008-no-copy-serialization-formats.md @@ -0,0 +1,127 @@ +# Evaluation of different serialization formats + +We want to move away from msgpack serialization for Icechunk metadata files. + +## Why + +* Msgpack requires a expensive parsing process upfront. If the user only wants +to pull a few chunk refs from a manifest, they still need to parse the whole manifest. +* Msgpack deserializes to Rust datastructures. This is good for simplicity of code, but +probably not good for memory consumption (more pointers everywhere). +* Msgpack gives too many options on how to serialize things, there is no canonical way, +so it's not easy to predict how `serde` is going to serialize our detastructures, and +could even change from version to version. +* It's hard to explain in the spec what goes into the metadata files, we would need to go +into `rmp_serde` implementation, see what they do, and document that in the spec. + +## Other options + +There is a never ending menu. From a custom binary format, to Parquet, and everything else. +We focused mostly on no-copy formats, for some of the issues enumerated above. Also +there is a preference for formats that have a tight schema and can be documented with +some form of IDL. + +## Performance evaluation + +We evaluated performance of msgpack, flatbuffers and capnproto. Evaluation looks at: + +* Manifest file size, for a big manifest with 1M native chunk refs. +* Speed of writing. +* Speed of reading. + +We wrote an example program in `examples/multithreaded_get_chunk_refs.rs`. +This program writes a big repo to local file storage, it doesn't really write the chunks, +we are not interested in benchmarking that. It executes purely in Rust, not using the python interface. + +It writes a manifest with 1M native chunk refs, using zstd compression level 3. The writes are done +from 1M concurrent async tasks. + +It then executes 1M chunk ref reads (notice, the refs are read, not the chunks that are not there). +Reads are executed from 4 threads with 250k concurrent async tasks each. + +Notice: + +* We are comparing local file system on purpose, to not account for network times +* We are comparing pulling refs only, not chunks, which is a worst case. In the real + world, read operations are dominated by the time taken to fetch the chunks. +* The evaluation was done in an early state of the code, where many parts were unsafe, + but we have verified there are no huge differences. + +### Results for writes + +```sh +nix run nixpkgs#hyperfine -- \ + --prepare 'rm -rf /tmp/test-perf' \ + --warmup 1 \ + 'cargo run --release --example multithreaded_get_chunk_refs -- --write /tmp/test-perf' +``` + +#### Flatbuffers + +Compressed manifest size: 27_527_680 bytes + +``` +Time (mean ± σ): 5.698 s ± 0.163 s [User: 4.764 s, System: 0.910 s] +Range (min … max): 5.562 s … 6.103 s 10 runs +``` + +#### Capnproto + +Compressed manifest size: 26_630_927 bytes + +``` +Time (mean ± σ): 6.276 s ± 0.163 s [User: 5.225 s, System: 1.017 s] +Range (min … max): 6.126 s … 6.630 s 10 runs +``` + +#### Msgpack + +Compressed manifest size: 22_250_152 bytes + +``` +Time (mean ± σ): 6.224 s ± 0.155 s [User: 5.488 s, System: 0.712 s] +Range (min … max): 6.033 s … 6.532 s 10 runs +``` + +### Results for reads + +```sh +nix run nixpkgs#hyperfine -- \ + --warmup 1 \ + 'cargo run --release --example multithreaded_get_chunk_refs -- --read /tmp/test-perf' +``` + +#### Flatbuffers + +``` +Time (mean ± σ): 3.676 s ± 0.257 s [User: 7.385 s, System: 1.819 s] +Range (min … max): 3.171 s … 4.038 s 10 runs +``` + +#### Capnproto + +``` +Time (mean ± σ): 5.254 s ± 0.234 s [User: 11.370 s, System: 1.962 s] +Range (min … max): 4.992 s … 5.799 s 10 runs +``` + +#### Msgpack + +``` +Time (mean ± σ): 3.310 s ± 0.606 s [User: 5.975 s, System: 1.762 s] +Range (min … max): 2.392 s … 4.102 s 10 runs +``` + +## Conclusions + +* Compressed manifest is 25% larger in flatbuffers than msgpack +* Flatbuffers is slightly faster for commits +* Flatbuffers is slightly slower for reads +* Timing differences are not significant for real world scenarios, where performance +is dominated by the time taken downloading or uploading chunks. +* Manifest fetch time differences could be somewhat significant for workloads where +latency to first byte is important. This is not the use case Icechunk optimizes for. + +## Decision + +We are going to use flatbuffers for our metadata on-disk format. diff --git a/docs/docs/assets/storage/tigris-region-set.png b/docs/docs/assets/storage/tigris-region-set.png new file mode 100644 index 00000000..72420c01 Binary files /dev/null and b/docs/docs/assets/storage/tigris-region-set.png differ diff --git a/docs/docs/contributing.md b/docs/docs/contributing.md index 8559a2fd..69608ed4 100644 --- a/docs/docs/contributing.md +++ b/docs/docs/contributing.md @@ -16,6 +16,11 @@ Icechunk is an open source (Apache 2.0) project and welcomes contributions in th ## Development ### Python Development Workflow +The Python code is developed in the `icechunk-python` subdirectory. To make changes first enter that directory: + +```bash +cd icechunk-python +``` Create / activate a virtual environment: @@ -43,6 +48,9 @@ Build the project in dev mode: ```bash maturin develop + +# or with the optional dependencies +maturin develop --extras=test,benchmark ``` or build the project in editable mode: diff --git a/docs/docs/icechunk-python/cheatsheets/git-users.md b/docs/docs/icechunk-python/cheatsheets/git-users.md index 9af96a48..52008f45 100644 --- a/docs/docs/icechunk-python/cheatsheets/git-users.md +++ b/docs/docs/icechunk-python/cheatsheets/git-users.md @@ -54,6 +54,8 @@ We can either check out a branch for [read-only access](../reference/#icechunk.R ```python # check out a branch for read-only access session = repo.readonly_session(branch="my-new-branch") +# readonly_session accepts a branch name by default +session = repo.readonly_session("my-new-branch") # check out a branch for read-write access session = repo.writable_session("my-new-branch") ``` @@ -79,7 +81,7 @@ At this point, the tip of the branch is now the snapshot `198273178639187` and a In Icechunk, you can view the history of a branch by using the [`repo.ancestry()`](../reference/#icechunk.Repository.ancestry) command, similar to the `git log` command. ```python -repo.ancestry(branch="my-new-branch") +[ancestor for ancestor in repo.ancestry(branch="my-new-branch")] #[Snapshot(id='198273178639187', ...), ...] ``` @@ -154,7 +156,7 @@ We can also view the history of a tag by using the [`repo.ancestry()`](../refere repo.ancestry(tag="my-new-tag") ``` -This will return a list of snapshots that are ancestors of the tag. Similar to branches we can lookup the snapshot that a tag is based on by using the [`repo.lookup_tag()`](../reference/#icechunk.Repository.lookup_tag) command. +This will return an iterator of snapshots that are ancestors of the tag. Similar to branches we can lookup the snapshot that a tag is based on by using the [`repo.lookup_tag()`](../reference/#icechunk.Repository.lookup_tag) command. ```python repo.lookup_tag("my-new-tag") diff --git a/docs/docs/icechunk-python/configuration.md b/docs/docs/icechunk-python/configuration.md index b6404e4d..d037aa8f 100644 --- a/docs/docs/icechunk-python/configuration.md +++ b/docs/docs/icechunk-python/configuration.md @@ -1,85 +1,137 @@ # Configuration -When creating and opening Icechunk repositories, there are a two different sets of configuration to be aware of: +When creating and opening Icechunk repositories, there are many configuration options available to control the behavior of the repository and the storage backend. This page will guide you through the available options and how to use them. -- [`Storage`](./reference.md#icechunk.Storage) - for configuring access to the object store or filesystem -- [`RepositoryConfig`](./reference.md#icechunk.RepositoryConfig) - for configuring the behavior of the Icechunk Repository itself +## [`RepositoryConfig`](./reference.md#icechunk.RepositoryConfig) -## Storage +The `RepositoryConfig` object is used to configure the repository. For convenience, this can be constructed using some sane defaults: -Icechunk can be configured to work with both object storage and filesystem backends. The storage configuration defines the location of an Icechunk store, along with any options or information needed to access data from a given storage type. +```python +config = icechunk.RepositoryConfig.default() +``` -### S3 Storage +or it can be optionally loaded from an existing repository: -When using Icechunk with s3 compatible storage systems, credentials must be provided to allow access to the data on the given endpoint. Icechunk allows for creating the storage config for s3 in three ways: +```python +config = icechunk.Repository.fetch_config(storage) +``` -=== "From environment" +It allows you to configure the following parameters: - With this option, the credentials for connecting to S3 are detected automatically from your environment. - This is usually the best choice if you are connecting from within an AWS environment (e.g. from EC2). [See the API](./reference.md#icechunk.s3_storage) +### [`inline_chunk_threshold_bytes`](./reference.md#icechunk.RepositoryConfig.inline_chunk_threshold_bytes) - ```python - icechunk.s3_storage( - bucket="icechunk-test", - prefix="quickstart-demo-1", - from_env=True - ) - ``` +The threshold for when to inline a chunk into a manifest instead of storing it as a separate object in the storage backend. -=== "Provide credentials" +### [`get_partial_values_concurrency`](./reference.md#icechunk.RepositoryConfig.get_partial_values_concurrency) - With this option, you provide your credentials and other details explicitly. [See the API](./reference.md#icechunk.s3_storage) +The number of concurrent requests to make when getting partial values from storage. - ```python - icechunk.s3_storage( - bucket="icechunk-test", - prefix="quickstart-demo-1", - region='us-east-1', - access_key_id='my-access-key', - secret_access_key='my-secret-key', - # session token is optional - session_token='my-token', - endpoint_url=None, # if using a custom endpoint - allow_http=False, # allow http connections (default is False) - ) - ``` +### [`compression`](./reference.md#icechunk.RepositoryConfig.compression) -=== "Anonymous" +Icechunk uses Zstd compression to compress its metadata files. [`CompressionConfig`](./reference.md#icechunk.CompressionConfig) allows you to configure the [compression level](./reference.md#icechunk.CompressionConfig.level) and [algorithm](./reference.md#icechunk.CompressionConfig.algorithm). Currently, the only algorithm available is [`Zstd`](https://facebook.github.io/zstd/). - With this option, you connect to S3 anonymously (without credentials). - This is suitable for public data. [See the API](./reference.md#icechunk.StorageConfig.s3_anonymous) +```python +config.compression = icechunk.CompressionConfig( + level=3, + algorithm=icechunk.CompressionAlgorithm.Zstd, +) +``` - ```python - icechunk.s3_storage( - bucket="icechunk-test", - prefix="quickstart-demo-1", - region='us-east-1, - anonymous=True, - ) - ``` +### [`caching`](./reference.md#icechunk.RepositoryConfig.caching) -### Filesystem Storage +Icechunk caches metadata files to speed up common operations. [`CachingConfig`](./reference.md#icechunk.CachingConfig) allows you to configure the caching behavior for the repository. -Icechunk can also be used on a [local filesystem](./reference.md#icechunk.local_filesystem_storage) by providing a path to the location of the store +```python +config.caching = icechunk.CachingConfig( + num_snapshot_nodes=100, + num_chunk_refs=100, + num_transaction_changes=100, + num_bytes_attributes=1e4, + num_bytes_chunks=1e6, +) +``` -=== "Local filesystem" +### [`storage`](./reference.md#icechunk.RepositoryConfig.storage) - ```python - icechunk.local_filesystem_storage("/path/to/my/dataset") - ``` +This configures how Icechunk loads data from the storage backend. [`StorageSettings`](./reference.md#icechunk.StorageSettings) allows you to configure the storage settings. Currently, the only setting available is the concurrency settings with [`StorageConcurrencySettings`](./reference.md#icechunk.StorageConcurrencySettings). + +```python +config.storage = icechunk.StorageSettings( + concurrency=icechunk.StorageConcurrencySettings( + max_concurrent_requests_for_object=10, + ideal_concurrent_request_size=1e6, + ), +) +``` -## Repository Config +### [`virtual_chunk_containers`](./reference.md#icechunk.RepositoryConfig.virtual_chunk_containers) -Separate from the storage config, the Repository can also be configured with options which control its runtime behavior. +Icechunk allows repos to contain [virtual chunks](./virtual.md). To allow for referencing these virtual chunks, you can configure the `virtual_chunk_containers` parameter to specify the storage locations and configurations for any virtual chunks. Each virtual chunk container is specified by a [`VirtualChunkContainer`](./reference.md#icechunk.VirtualChunkContainer) object which contains a name, a url prefix, and a storage configuration. When a container is added to the settings, any virtual chunks with a url that starts with the configured prefix will use the storage configuration for that matching container. !!! note - This section is under construction and coming soon. -## Creating and Opening Repos + Currently only `s3` compatible storage and `local_filesystem` storage are supported for virtual chunk containers. Other storage backends such as `gcs`, `azure`, and `https` are on the roadmap. + +#### Example + +For example, if we wanted to configure an icechunk repo to be able to contain virtual chunks from an `s3` bucket called `my-s3-bucket` in `us-east-1`, we would do the following: + +```python +config.virtual_chunk_containers = [ + icechunk.VirtualChunkContainer( + name="my-s3-bucket", + url_prefix="s3://my-s3-bucket/", + storage=icechunk.StorageSettings( + storage=icechunk.s3_storage(bucket="my-s3-bucket", region="us-east-1"), + ), + ), +] +``` + +If we also wanted to configure the repo to be able to contain virtual chunks from another `s3` bucket called `my-other-s3-bucket` in `us-west-2`, we would do the following: + +```python +config.set_virtual_chunk_container( + icechunk.VirtualChunkContainer( + name="my-other-s3-bucket", + url_prefix="s3://my-other-s3-bucket/", + storage=icechunk.StorageSettings( + storage=icechunk.s3_storage(bucket="my-other-s3-bucket", region="us-west-2"), + ), + ), +) +``` + +Now at read time, if icechunk encounters a virtual chunk url that starts with `s3://my-other-s3-bucket/`, it will use the storage configuration for the `my-other-s3-bucket` container. + +!!! note + + While virtual chunk containers specify the storage configuration for any virtual chunks, they do not contain any authentication information. The credentials must also be specified when opening the repository using the [`virtual_chunk_credentials`](./reference.md#icechunk.Repository.open) parameter. See the [Virtual Chunk Credentials](#virtual-chunk-credentials) section for more information. + +### [`manifest`](./reference.md#icechunk.RepositoryConfig.manifest) + +The manifest configuration for the repository. [`ManifestConfig`](./reference.md#icechunk.ManifestConfig) allows you to configure behavior for how manifests are loaded. in particular, the `preload` parameter allows you to configure the preload behavior of the manifest using a [`ManifestPreloadConfig`](./reference.md#icechunk.ManifestPreloadConfig). This allows you to control the number of references that are loaded into memory when a session is created, along with which manifests are available to be preloaded. + +#### Example + +For example, if we have a repo which contains data that we plan to open as an [`Xarray`](./xarray.md) dataset, we may want to configure the manifest preload to only preload manifests that contain arrays that are coordinates, in our case `time`, `latitude`, and `longitude`. + +```python +config.manifest = icechunk.ManifestConfig( + preload=icechunk.ManifestPreloadConfig( + max_total_refs=1e8, + preload_if=icechunk.ManifestPreloadCondition.name_matches(".*time|.*latitude|.*longitude"), + ), +) +``` + +### Applying Configuration Now we can now create or open an Icechunk repo using our config. -### Creating a new repo +#### Creating a new repo + +If no config is provided, the repo will be created with the [default configuration](./reference.md#icechunk.RepositoryConfig.default). !!! note @@ -97,6 +149,7 @@ Now we can now create or open an Icechunk repo using our config. repo = icechunk.Repository.create( storage=storage, + config=config, ) ``` @@ -111,6 +164,7 @@ Now we can now create or open an Icechunk repo using our config. repo = icechunk.Repository.create( storage=storage, + config=config, ) ``` @@ -125,6 +179,7 @@ Now we can now create or open an Icechunk repo using our config. repo = icechunk.Repository.create( storage=storage, + config=config, ) ``` @@ -133,63 +188,15 @@ Now we can now create or open an Icechunk repo using our config. ```python repo = icechunk.Repository.create( storage=icechunk.local_filesystem_storage("/path/to/my/dataset"), + config=config ) ``` -If you are not sure if the repo exists yet, an `icechunk Repository` can created or opened if it already exists: - -=== "Open or creating with S3 storage" - - ```python - storage = icechunk.s3_storage( - bucket='earthmover-sample-data', - prefix='icechunk/oisst.2020-2024/', - region='us-east-1', - from_env=True, - ) +#### Opening an existing repo - repo = icechunk.Repository.open_or_create( - storage=storage, - ) - ``` +When opening an existing repo, the config will be loaded from the repo if it exists. If no config exists and no config was specified, the repo will be opened with the [default configuration](./reference.md#icechunk.RepositoryConfig.default). -=== "Open or creating with Google Cloud Storage" - - ```python - storage = icechunk.gcs_storage( - bucket='earthmover-sample-data', - prefix='icechunk/oisst.2020-2024/', - from_env=True, - ) - - repo = icechunk.Repository.open_or_create( - storage=storage, - ) - ``` - -=== "Open or creating with Azure Blob Storage" - - ```python - storage = icechunk.azure_storage( - container='earthmover-sample-data', - prefix='icechunk/oisst.2020-2024/', - from_env=True, - ) - - repo = icechunk.Repository.open_or_create( - storage=storage, - ) - ``` - -=== "Open or creating with local filesystem" - - ```python - repo = icechunk.Repository.open_or_create( - storage=icechunk.local_filesystem_storage("/path/to/my/dataset"), - ) - ``` - -### Opening an existing repo +However, if a config was specified when opening the repo AND a config was previously persisted in the repo, the two configurations will be merged. The config specified when opening the repo will take precedence over the persisted config. === "Opening from S3 Storage" @@ -203,6 +210,7 @@ If you are not sure if the repo exists yet, an `icechunk Repository` can created repo = icechunk.Repository.open( storage=storage, + config=config, ) ``` @@ -217,6 +225,7 @@ If you are not sure if the repo exists yet, an `icechunk Repository` can created repo = icechunk.Repository.open( storage=storage, + config=config, ) ``` @@ -231,6 +240,7 @@ If you are not sure if the repo exists yet, an `icechunk Repository` can created repo = icechunk.Repository.open( storage=storage, + config=config, ) ``` @@ -240,5 +250,37 @@ If you are not sure if the repo exists yet, an `icechunk Repository` can created storage = icechunk.local_filesystem_storage("/path/to/my/dataset") store = icechunk.IcechunkStore.open( storage=storage, + config=config, ) ``` + +### Persisting Configuration + +Once the repo is opened, the current config can be persisted to the repo by calling [`save_config`](./reference.md#icechunk.Repository.save_config). + +```python +repo.save_config() +``` + +The next time this repo is opened, the persisted config will be loaded by default. + +## Virtual Chunk Credentials + +When using virtual chunk containers, the credentials for the storage backend must also be specified. This is done using the [`virtual_chunk_credentials`](./reference.md#icechunk.Repository.open) parameter when creating or opening the repo. Credentials are specified as a dictionary of container names mapping to credential objects. A helper function, [`containers_credentials`](./reference.md#icechunk.containers_credentials), is provided to make it easier to specify credentials for multiple containers. + +### Example + +Expanding on the example from the [Virtual Chunk Containers](#virtual-chunk-containers) section, we can configure the repo to use the credentials for the `my-s3-bucket` and `my-other-s3-bucket` containers. + +```python +credentials = icechunk.containers_credentials( + my_s3_bucket=icechunk.s3_credentials(bucket="my-s3-bucket", region="us-east-1"), + my_other_s3_bucket=icechunk.s3_credentials(bucket="my-other-s3-bucket", region="us-west-2"), +) + +repo = icechunk.Repository.open( + storage=storage, + config=config, + virtual_chunk_credentials=credentials, +) +``` diff --git a/docs/docs/icechunk-python/dask.md b/docs/docs/icechunk-python/dask.md index 7c4dd67d..fe3f52f1 100644 --- a/docs/docs/icechunk-python/dask.md +++ b/docs/docs/icechunk-python/dask.md @@ -21,8 +21,8 @@ client = Client() # initialize the icechunk store import icechunk -storage = icechunk.local_filesystem_storage("./icechunk-xarray") -icechunk_repo = icechunk.Repository.create(storage_config) +storage = icechunk.local_filesystem_storage("./icechunk-dask") +icechunk_repo = icechunk.Repository.create(storage) icechunk_session = icechunk_repo.writable_session("main") ``` @@ -34,6 +34,7 @@ support for the `compute` kwarg. First create a dask array to write: ```python +import dask.array as da shape = (100, 100) dask_chunks = (20, 20) dask_array = dask.array.random.random(shape, chunks=dask_chunks) @@ -41,8 +42,10 @@ dask_array = dask.array.random.random(shape, chunks=dask_chunks) Now create the Zarr array you will write to. ```python +import zarr + zarr_chunks = (10, 10) -group = zarr.group(store=icechunk_sesion.store, overwrite=True) +group = zarr.group(store=icechunk_session.store, overwrite=True) zarray = group.create_array( "array", diff --git a/docs/docs/icechunk-python/faq.md b/docs/docs/icechunk-python/faq.md index 41c0f56e..c5852e67 100644 --- a/docs/docs/icechunk-python/faq.md +++ b/docs/docs/icechunk-python/faq.md @@ -3,3 +3,7 @@ **Why do I have to opt-in to pickling an IcechunkStore or a Session?** Icechunk is different from normal Zarr stores because it is stateful. In a distributed setting, you have to be careful to communicate back the Session objects from remote write tasks, merge them and commit them. The opt-in to pickle is a way for us to hint to the user that they need to be sure about what they are doing. We use pickling because these operations are only tricky once you cross a process boundary. More pragmatically, to_zarr(session.store) fails spectacularly in distributed contexts (e.g. [this issue](https://github.com/earth-mover/icechunk/issues/383)), and we do not want the user to be surprised. + +**Does `icechunk-python` include logging?** + +Yes! Set the environment variable `ICECHUNK_LOG=icechunk=debug` to print debug logs to stdout. Available "levels" in order of increasing verbosity are `error`, `warn`, `info`, `debug`, `trace`. The default level is `error`. The Rust library uses `tracing-subscriber` crate. The `ICECHUNK_LOG` variable can be used to filter logging following that crate's [documentation](https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives). For example, `ICECHUNK_LOG=trace` will set both icechunk and it's dependencies' log levels to `trace` while `ICECHUNK_LOG=icechunk=trace` will enable the `trace` level for icechunk only. diff --git a/docs/docs/icechunk-python/index.md b/docs/docs/icechunk-python/index.md index bfc62e07..b4fd0e99 100644 --- a/docs/docs/icechunk-python/index.md +++ b/docs/docs/icechunk-python/index.md @@ -2,6 +2,7 @@ - [quickstart](/icechunk-python/quickstart/) - [configuration](/icechunk-python/configuration/) +- [storage](/icechunk-python/storage/) - [version control](/icechunk-python/version-control/) - [xarray](/icechunk-python/xarray/) - [concurrency](/icechunk-python/concurrency/) diff --git a/docs/docs/icechunk-python/parallel.md b/docs/docs/icechunk-python/parallel.md index 26ae6370..8a23a713 100644 --- a/docs/docs/icechunk-python/parallel.md +++ b/docs/docs/icechunk-python/parallel.md @@ -5,7 +5,6 @@ with all appropriate metadata, and any coordinate variables. Following this a la is kicked off in a distributed setting, where each worker is responsible for an independent "region" of the output. - ## Why is Icechunk different from any other Zarr store? The reason is that unlike Zarr, Icechunk is a "stateful" store. The Session object keeps a record of all writes, that is then @@ -13,8 +12,10 @@ bundled together in a commit. Thus `Session.commit` must be executed on a Sessio including those executed remotely in a multi-processing or any other remote execution context. ## Example + Here is how you can execute such writes with Icechunk, illustrate with a `ThreadPoolExecutor`. First read some example data, and create an Icechunk Repository. + ```python import xarray as xr import tempfile @@ -24,8 +25,10 @@ ds = xr.tutorial.open_dataset("rasm").isel(time=slice(24)) repo = Repository.create(local_filesystem_storage(tempfile.mkdtemp())) session = repo.writable_session("main") ``` + We will orchestrate so that each task writes one timestep. This is an arbitrary choice but determines what we set for the Zarr chunk size. + ```python chunks = {1 if dim == "time" else ds.sizes[dim] for dim in ds.Tair.dims} ``` @@ -33,6 +36,7 @@ chunks = {1 if dim == "time" else ds.sizes[dim] for dim in ds.Tair.dims} Initialize the dataset using [`Dataset.to_zarr`](https://docs.xarray.dev/en/stable/generated/xarray.Dataset.to_zarr.html) and `compute=False`, this will NOT write any chunked array data, but will write all array metadata, and any in-memory arrays (only `time` in this case). + ```python ds.to_zarr(session.store, compute=False, encoding={"Tair": {"chunks": chunks}}, mode="w") # this commit is optional, but may be useful in your workflow @@ -42,6 +46,7 @@ session.commit("initialize store") ## Multi-threading First define a function that constitutes one "write task". + ```python from icechunk import Session @@ -53,6 +58,7 @@ def write_timestamp(*, itime: int, session: Session) -> None: ``` Now execute the writes. + ```python from concurrent.futures import ThreadPoolExecutor, wait from icechunk.distributed import merge_sessions @@ -67,18 +73,26 @@ session.commit("finished writes") ``` Verify that the writes worked as expected: + ```python -ondisk = xr.open_zarr(repo.readonly_session(branch="main").store, consolidated=False) +ondisk = xr.open_zarr(repo.readonly_session("main").store, consolidated=False) xr.testing.assert_identical(ds, ondisk) ``` ## Distributed writes +!!! info + + This code will not execute with a `ProcessPoolExecutor` without [some changes](https://docs.python.org/3/library/multiprocessing.html#programming-guidelines). + Specifically it requires wrapping the code in a `if __name__ == "__main__":` block. + See a full executable example [here](https://github.com/earth-mover/icechunk/blob/main/icechunk-python/examples/mpwrite.py). + Any task execution framework (e.g. `ProcessPoolExecutor`, Joblib, Lithops, Dask Distributed, Ray, etc.) can be used instead of the `ThreadPoolExecutor`. However such workloads should account for Icehunk being a "stateful" store that records changes executed in a write session. There are three key points to keep in mind: + 1. The `write_task` function *must* return the `Session`. It contains a record of the changes executed by this task. These changes *must* be manually communicated back to the coordinating process, since each of the distributed processes are working with their own independent `Session` instance. @@ -87,6 +101,7 @@ There are three key points to keep in mind: 3. The user *must* manually merge the Session objects to create a meaningful commit. First we modify `write_task` to return the `Session`: + ```python from icechunk import Session @@ -114,8 +129,8 @@ with ProcessPoolExecutor() as executor: executor.submit(write_timestamp, itime=i, session=session) for i in range(ds.sizes["time"]) ] - # grab the Session objects from each individual write task - sessions = [f.result() for f in futures] + # grab the Session objects from each individual write task + sessions = [f.result() for f in futures] # manually merge the remote sessions in to the local session session = merge_sessions(session, *sessions) @@ -123,7 +138,8 @@ session.commit("finished writes") ``` Verify that the writes worked as expected: + ```python -ondisk = xr.open_zarr(repo.readonly_session(branch="main").store, consolidated=False) +ondisk = xr.open_zarr(repo.readonly_session("main").store, consolidated=False) xr.testing.assert_identical(ds, ondisk) ``` diff --git a/docs/docs/icechunk-python/quickstart.md b/docs/docs/icechunk-python/quickstart.md index 04752baa..3bc1303a 100644 --- a/docs/docs/icechunk-python/quickstart.md +++ b/docs/docs/icechunk-python/quickstart.md @@ -6,18 +6,25 @@ If you're not familiar with Zarr, you may want to start with the [Zarr Tutorial] ## Installation -Install Icechunk with pip +Icechunk can be installed using pip or conda: -```python -pip install icechunk -``` +=== "pip" + + ```bash + python -m pip install icechunk + ``` + +=== "conda" + + ```bash + conda install -c conda-forge icechunk + ``` !!! note Icechunk is currently designed to support the [Zarr V3 Specification](https://zarr-specs.readthedocs.io/en/latest/v3/core/v3.0.html). Using it today requires installing Zarr Python 3. - ## Create a new Icechunk repository To get started, let's create a new Icechunk repository. @@ -27,6 +34,7 @@ However, you can also create a repo on your local filesystem. === "S3 Storage" ```python + import icechunk storage = icechunk.s3_storage(bucket="my-bucket", prefix="my-prefix", from_env=True) repo = icechunk.Repository.create(storage) ``` @@ -34,6 +42,7 @@ However, you can also create a repo on your local filesystem. === "Google Cloud Storage" ```python + import icechunk storage = icechunk.gcs_storage(bucket="my-bucket", prefix="my-prefix", from_env=True) repo = icechunk.Repository.create(storage) ``` @@ -41,6 +50,7 @@ However, you can also create a repo on your local filesystem. === "Azure Blob Storage" ```python + import icechunk storage = icechunk.azure_storage(container="my-container", prefix="my-prefix", from_env=True) repo = icechunk.Repository.create(storage) ``` @@ -48,6 +58,7 @@ However, you can also create a repo on your local filesystem. === "Local Storage" ```python + import icechunk storage = icechunk.local_filesystem_storage("./icechunk-local") repo = icechunk.Repository.create(storage) ``` @@ -73,6 +84,7 @@ We can now use our Icechunk `store` with Zarr. Let's first create a group and an array within it. ```python +import zarr group = zarr.group(store) array = group.create("my_array", shape=10, dtype='int32', chunks=(5,)) ``` @@ -95,7 +107,6 @@ session.commit("first commit") Once a writable `Session` has been successfully committed to, it becomes read only to ensure that all writing is done explicitly. - ## Make a second commit At this point, we have already committed using our session, so we need to get a new session and store to make more changes. @@ -124,7 +135,7 @@ snapshot_id_2 = session_2.commit("overwrite some values") We can see the full version history of our repo: ```python -hist = repo.ancestry(snapshot=snapshot_id_2) +hist = list(repo.ancestry(snapshot_id=snapshot_id_2)) for ancestor in hist: print(ancestor.id, ancestor.message, ancestor.written_at) @@ -140,12 +151,12 @@ for ancestor in hist: # latest version assert array[0] == 2 # check out earlier snapshot -earlier_session = repo.readonly_session(snapshot=snapshot_id=hist[1].id) +earlier_session = repo.readonly_session(snapshot_id=hist[1].id) store = earlier_session.store # get the array group = zarr.open_group(store, mode="r") -array = group["my_array] +array = group["my_array"] # verify data matches first version assert array[0] == 1 diff --git a/docs/docs/icechunk-python/storage.md b/docs/docs/icechunk-python/storage.md new file mode 100644 index 00000000..9f73d0b4 --- /dev/null +++ b/docs/docs/icechunk-python/storage.md @@ -0,0 +1,262 @@ +# Storage + +Icechunk can be configured to work with both object storage and filesystem backends. The storage configuration defines the location of an Icechunk store, along with any options or information needed to access data from a given storage type. + +### S3 Storage + +When using Icechunk with s3 compatible storage systems, credentials must be provided to allow access to the data on the given endpoint. Icechunk allows for creating the storage config for s3 in three ways: + +=== "From environment" + + With this option, the credentials for connecting to S3 are detected automatically from your environment. + This is usually the best choice if you are connecting from within an AWS environment (e.g. from EC2). [See the API](./reference.md#icechunk.s3_storage) + + ```python + icechunk.s3_storage( + bucket="icechunk-test", + prefix="quickstart-demo-1", + from_env=True + ) + ``` + +=== "Provide credentials" + + With this option, you provide your credentials and other details explicitly. [See the API](./reference.md#icechunk.s3_storage) + + ```python + icechunk.s3_storage( + bucket="icechunk-test", + prefix="quickstart-demo-1", + region='us-east-1', + access_key_id='my-access-key', + secret_access_key='my-secret-key', + # session token is optional + session_token='my-token', + endpoint_url=None, # if using a custom endpoint + allow_http=False, # allow http connections (default is False) + ) + ``` + +=== "Anonymous" + + With this option, you connect to S3 anonymously (without credentials). + This is suitable for public data. [See the API](./reference.md#icechunk.s3_storage) + + ```python + icechunk.s3_storage( + bucket="icechunk-test", + prefix="quickstart-demo-1", + region='us-east-1, + anonymous=True, + ) + ``` + +=== "Refreshable Credentials" + + With this option, you provide a callback function that will be called to obtain S3 credentials when needed. This is useful for workloads that depend on retrieving short-lived credentials from AWS or similar authority, allowing for credentials to be refreshed as needed without interrupting any workflows. [See the API](./reference.md#icechunk.s3_storage) + + ```python + def get_credentials() -> S3StaticCredentials: + # In practice, you would use a function that actually fetches the credentials and returns them + # along with an optional expiration time which will trigger this callback to run again + return icechunk.S3StaticCredentials( + access_key_id="xyz", + secret_access_key="abc",å + expires_after=datetime.now(UTC) + timedelta(days=1) + ) + + icechunk.s3_storage( + bucket="icechunk-test", + prefix="quickstart-demo-1", + region='us-east-1', + get_credentials=get_credentials, + ) + ``` + +#### Tigris + +[Tigris](https://www.tigrisdata.com/) is available as a storage backend for Icechunk. Functionally this storage backend is the same as S3 storage, but with a different endpoint. Icechunk provides a helper function specifically for [creating Tigris storage configurations](./reference.md#icechunk.tigris_storage). +```python +icechunk.tigris_storage( + bucket="icechunk-test", + prefix="quickstart-demo-1", + access_key_id='my-access-key', + secret_access_key='my-secret-key', +) +``` + +There are a few things to be aware of when using Tigris: +- Tigris is a globally distributed object store by default. The caveat is that Tigris does not currently support the full consistency guarantees when the store is distributed across multiple regions. For now, to get all the consistency guarantees Icechunk offers, you will need to setup your Tigris bucket as restricted to a single region. This can be done by setting the region in the Tigris bucket settings: +![tigris bucket settings](../assets/storage/tigris-region-set.png) + +#### Minio + +[Minio](https://min.io/) is available as a storage backend for Icechunk. Functionally this storage backend is the same as S3 storage, but with a different endpoint. + +For example, if we have a Minio server running at `http://localhost:9000` with access key `minio` and +secret key `minio123` we can create a storage configuration as follows: + +```python +icechunk.s3_storage( + bucket="icechunk-test", + prefix="quickstart-demo-1", + region='us-east-1', + access_key_id='minio', + secret_access_key='minio123', + endpoint_url='http://localhost:9000', + allow_http=True, +``` + +A few things to note: + +1. The `endpoint_url` parameter is set to the URL of the Minio server. +2. If the Minio server is running over HTTP and not HTTPS, the `allow_http` parameter must be set to `True`. +3. Even though this is running on a local server, the `region` parameter must still be set to a valid region. [By default use `us-east-1`](https://github.com/minio/minio/discussions/15063). + +### Google Cloud Storage + +Icechunk can be used with [Google Cloud Storage](https://cloud.google.com/storage?hl=en). + +=== "From environment" + + With this option, the credentials for connecting to GCS are detected automatically from your environment. [See the API](./reference.md#icechunk.gcs_storage) + + ```python + icechunk.gcs_storage( + bucket="icechunk-test", + prefix="quickstart-demo-1", + from_env=True + ) + ``` + +=== "Service Account File" + + With this option, you provide the path to a [service account file](https://cloud.google.com/iam/docs/service-account-creds#key-types). [See the API](./reference.md#icechunk.gcs_storage) + + ```python + icechunk.gcs_storage( + bucket="icechunk-test", + prefix="quickstart-demo-1", + service_account_file="/path/to/service-account.json" + ) + ``` + +=== "Service Account Key" + + With this option, you provide the service account key as a string. [See the API](./reference.md#icechunk.gcs_storage) + + ```python + icechunk.gcs_storage( + bucket="icechunk-test", + prefix="quickstart-demo-1", + service_account_key={ + "type": "service_account", + "project_id": "my-project", + "private_key_id": "my-private-key-id", + "private_key": "-----BEGIN PRIVATE KEY-----\nmy-private-key\n-----END PRIVATE KEY-----\n", + "client_email": " + }, + ) + ``` + +=== "Application Default Credentials" + + With this option, you use the [application default credentials (ADC)](https://cloud.google.com/docs/authentication/provide-credentials-adc) to authentication with GCS. Provide the path to the credentials. [See the API](./reference.md#icechunk.gcs_storage) + + ```python + icechunk.gcs_storage( + bucket="icechunk-test", + prefix="quickstart-demo-1", + application_credentials="/path/to/application-credentials.json" + ) + ``` + +=== "Bearer Token" + + With this option, you provide a bearer token to use for the object store. This is useful for short lived workflows where expiration is not relevant or when the bearer token will not expire [See the API](./reference.md#icechunk.gcs_storage) + + ```python + icechunk.gcs_storage( + bucket="icechunk-test", + prefix="quickstart-demo-1", + bearer_token="my-bearer-token" + ) + ``` + +=== "Refreshable Credentials" + + With this option, you provide a callback function that will be called to obtain GCS credentials when needed. This is useful for workloads that depend on retrieving short-lived credentials from GCS or similar authority, allowing for credentials to be refreshed as needed without interrupting any workflows. This works at a lower level than the other methods, and accepts a bearer token and expiration time. These are the same credentials that are created for you when specifying the service account file, key, or ADC. [See the API](./reference.md#icechunk.gcs_storage) + + ```python + def get_credentials() -> GcsBearerCredential: + # In practice, you would use a function that actually fetches the credentials and returns them + # along with an optional expiration time which will trigger this callback to run again + return icechunk.GcsBearerCredential(bearer="my-bearer-token", expires_after=datetime.now(UTC) + timedelta(days=1)) + + icechunk.gcs_storage( + bucket="icechunk-test", + prefix="quickstart-demo-1", + get_credentials=get_credentials, + ) + ``` + +#### Limitations + +- The consistency guarantees for GCS function differently than S3. Specifically, GCS uses the [generation](https://cloud.google.com/storage/docs/request-preconditions#compose-preconditions) instead of etag for `if-match` `put` requests. Icechunk has not wired this through yet and thus [configuration updating](https://github.com/earth-mover/icechunk/issues/533) is potentially unsafe. This is not a problem for most use cases that are not frequently updating the configuration. +- GCS does not yet support [`bearer` tokens and auth refreshing](https://github.com/earth-mover/icechunk/issues/637). This means currently auth is limited to service account files. +- The GCS storage config does not yet support anonymous access. + +### Azure Blob Storage + +Icechunk can be used with [Azure Blob Storage](https://azure.microsoft.com/en-us/services/storage/blobs/). + +=== "From environment" + + With this option, the credentials for connecting to Azure Blob Storage are detected automatically from your environment. [See the API](./reference.md#icechunk.azure_storage) + + ```python + icechunk.azure_storage( + account="my-account-name", + container="icechunk-test", + prefix="quickstart-demo-1", + from_env=True + ) + ``` + +=== "Provide credentials" + + With this option, you provide your credentials and other details explicitly. [See the API](./reference.md#icechunk.azure_storage) + + ```python + icechunk.azure_storage( + account_name='my-account-name', + container="icechunk-test", + prefix="quickstart-demo-1", + account_key='my-account-key', + access_token=None, # optional + sas_token=None, # optional + bearer_token=None, # optional + ) + ``` + +### Filesystem Storage + +Icechunk can also be used on a [local filesystem](./reference.md#icechunk.local_filesystem_storage) by providing a path to the location of the store + +=== "Local filesystem" + + ```python + icechunk.local_filesystem_storage("/path/to/my/dataset") + ``` + +#### Limitations + +- Icechunk currently does not work with a local filesystem storage backend on Windows. See [this issue](https://github.com/earth-mover/icechunk/issues/665) for more discussion. To work around, try using [WSL](https://learn.microsoft.com/en-us/windows/wsl/about) or a cloud storage backend. + +### In Memory Storage + +While it should never be used for production data, Icechunk can also be used with an in-memory storage backend. This is useful for testing and development purposes. This is volatile and when the Python process ends, all data is lost. + +```python +icechunk.in_memory_storage() +``` diff --git a/docs/docs/icechunk-python/version-control.md b/docs/docs/icechunk-python/version-control.md index d61462cc..8acd4fa2 100644 --- a/docs/docs/icechunk-python/version-control.md +++ b/docs/docs/icechunk-python/version-control.md @@ -27,7 +27,7 @@ repo = icechunk.Repository.create(icechunk.in_memory_storage()) On creating a new [`Repository`](../reference/#icechunk.Repository), it will automatically create a `main` branch with an initial snapshot. We can take a look at the ancestry of the `main` branch to confirm this. ```python -repo.ancestry(branch="main") +[ancestor for ancestor in repo.ancestry(branch="main")] # [SnapshotInfo(id="A840RMN5CF807CM66RY0", parent_id=None, written_at=datetime.datetime(2025,1,30,19,52,41,592998, tzinfo=datetime.timezone.utc), message="Repository...")] ``` @@ -36,8 +36,7 @@ repo.ancestry(branch="main") The [`ancestry`](./reference/#icechunk.Repository.ancestry) method can be used to inspect the ancestry of any branch, snapshot, or tag. -We get back a list of [`SnapshotInfo`](../reference/#icechunk.SnapshotInfo) objects, which contain information about the snapshot, including its ID, the ID of its parent snapshot, and the time it was written. - +We get back an iterator of [`SnapshotInfo`](../reference/#icechunk.SnapshotInfo) objects, which contain information about the snapshot, including its ID, the ID of its parent snapshot, and the time it was written. ## Creating a snapshot @@ -48,7 +47,7 @@ Now that we have a `Repository` with a `main` branch, we can modify the data in Writable `Session` objects are required to create new snapshots, and can only be created from the tip of a branch. Checking out tags or other snapshots is read-only. ```python -session = repo.writable_session(branch="main") +session = repo.writable_session("main") ``` We can now access the `zarr.Store` from the `Session` and create a new root group. Then we can modify the attributes of the root group and create a new snapshot. @@ -68,7 +67,7 @@ Success! We've created a new snapshot with a new attribute on the root group. Once we've committed the snapshot, the `Session` will become read-only, and we can no longer modify the data using our existing `Session`. If we want to modify the data again, we need to create a new writable `Session` from the branch. Notice that we don't have to refresh the `Repository` to get the updates from the `main` branch. Instead, the `Repository` will automatically fetch the latest snapshot from the branch when we create a new writable `Session` from it. ```python -session = repo.writable_session(branch="main") +session = repo.writable_session("main") root = zarr.group(session.store) root.attrs["foo"] = "baz" session.commit(message="Update foo attribute on root group") @@ -123,7 +122,7 @@ repo.create_branch("dev", snapshot_id=main_branch_snapshot_id) We can now create a new writable `Session` from the `dev` branch and modify the data. ```python -session = repo.writable_session(branch="dev") +session = repo.writable_session("dev") root = zarr.group(session.store) root.attrs["foo"] = "balogna" session.commit(message="Update foo attribute on root group") @@ -137,7 +136,7 @@ We can also create a new branch from the tip of the `main` branch if we want to main_branch_snapshot_id = repo.lookup_branch("main") repo.create_branch("feature", snapshot_id=main_branch_snapshot_id) -session = repo.writable_session(branch="feature") +session = repo.writable_session("feature") root = zarr.group(session.store) root.attrs["foo"] = "cherry" session.commit(message="Update foo attribute on root group") @@ -254,7 +253,7 @@ import numpy as np import zarr repo = icechunk.Repository.create(icechunk.in_memory_storage()) -session = repo.writable_session(branch="main") +session = repo.writable_session("main") root = zarr.group(session.store) root.attrs["foo"] = "bar" root.create_dataset("data", shape=(10, 10), chunks=(1, 1), dtype=np.int32) @@ -266,25 +265,21 @@ session.commit(message="Add foo attribute and data array") Lets try to modify the `data` array in two different sessions, created from the `main` branch. ```python -session1 = repo.writable_session(branch="main") -session2 = repo.writable_session(branch="main") +session1 = repo.writable_session("main") +session2 = repo.writable_session("main") root1 = zarr.group(session1.store) root2 = zarr.group(session2.store) -``` -First, we'll modify the attributes of the root group from both sessions. - -```python -root1.attrs["foo"] = "bar" -root2.attrs["foo"] = "baz" +root1["data"][0,0] = 1 +root2["data"][0,:] = 2 ``` and then try to commit the changes. ```python -session1.commit(message="Update foo attribute on root group") -session2.commit(message="Update foo attribute on root group") +session1.commit(message="Update first element of data array") +session2.commit(message="Update first row of data array") # AE9XS2ZWXT861KD2JGHG # --------------------------------------------------------------------------- @@ -328,66 +323,7 @@ session2.rebase(icechunk.ConflictDetector()) # RebaseFailedError: Rebase failed on snapshot AE9XS2ZWXT861KD2JGHG: 1 conflicts found ``` -This however fails because both sessions modified the `foo` attribute on the root group. We can use the `ConflictError` to get more information about the conflict. - -```python -try: - session2.rebase(icechunk.ConflictDetector()) -except icechunk.RebaseFailedError as e: - print(e.conflicts) - -# [Conflict(UserAttributesDoubleUpdate, path=/)] -``` - -This tells us that the conflict is caused by the two sessions modifying the user attributes of the root group (`/`). In this casewe have decided that second session set the `foo` attribute to the correct value, so we can now try to rebase by instructing the `rebase` method to use the second session's changes with the [`BasicConflictSolver`](../reference/#icechunk.BasicConflictSolver). - -```python -session2.rebase(icechunk.BasicConflictSolver(on_user_attributes_conflict=icechunk.VersionSelection.UseOurs)) -``` - -Success! We can now try and commit the changes again. - -```python -session2.commit(message="Update foo attribute on root group") - -# 'SY4WRE8A9TVYMTJPEAHG' -``` - -This same process can be used to resolve conflicts with arrays. Let's try to modify the `data` array from both sessions. - -```python -session1 = repo.writable_session(branch="main") -session2 = repo.writable_session(branch="main") - -root1 = zarr.group(session1.store) -root2 = zarr.group(session2.store) - -root1["data"][0,0] = 1 -root2["data"][0,:] = 2 -``` - -We have now created a conflict, because the first session modified the first element of the `data` array, and the second session modified the first row of the `data` array. Let's commit the changes from the second session first, then see what conflicts are reported when we try to commit the changes from the first session. - - -```python -print(session2.commit(message="Update first row of data array")) -print(session1.commit(message="Update first element of data array")) - -# --------------------------------------------------------------------------- -# ConflictError Traceback (most recent call last) -# Cell In[15], line 2 -# 1 print(session2.commit(message="Update first row of data array")) -# ----> 2 print(session1.commit(message="Update first element of data array")) - -# File ~/Developer/icechunk/icechunk-python/python/icechunk/session.py:224, in Session.commit(self, message, metadata) -# 222 return self._session.commit(message, metadata) -# 223 except PyConflictError as e: -# --> 224 raise ConflictError(e) from None - -# ConflictError: Failed to commit, expected parent: Some("SY4WRE8A9TVYMTJPEAHG"), actual parent: Some("5XRDGZPSG747AMMRTWT0") -``` - -Okay! We have a conflict. Lets see what conflicts are reported. +This however fails because both sessions modified metadata. We can use the `RebaseFailedError` to get more information about the conflict. ```python try: @@ -413,18 +349,24 @@ Success! We have now resolved the conflict and committed the changes. Let's look at the value of the `data` array to confirm that the conflict was resolved correctly. ```python -session = repo.readonly_session(branch="main") +session = repo.readonly_session("main") root = zarr.open_group(session.store, mode="r") root["data"][0,:] # array([1, 2, 2, 2, 2, 2, 2, 2, 2, 2], dtype=int32) ``` +As you can see, `readonly_session` accepts a string for a branch name, or you can also write: + +```python +session = repo.readonly_session(branch="main") +``` + Lastly, if you make changes to non-conflicting chunks or attributes, you can rebase without having to resolve any conflicts. ```python -session1 = repo.writable_session(branch="main") -session2 = repo.writable_session(branch="main") +session1 = repo.writable_session("main") +session2 = repo.writable_session("main") root1 = zarr.group(session1.store) root2 = zarr.group(session2.store) @@ -466,4 +408,4 @@ root["data"][:,:] #### Limitations -At the moment, the rebase functionality is limited to resolving conflicts with attributes on arrays and groups, and conflicts with chunks in arrays. Other types of conflicts are not able to be resolved by icechunk yet and must be resolved manually. +At the moment, the rebase functionality is limited to resolving conflicts with chunks in arrays. Other types of conflicts are not able to be resolved by icechunk yet and must be resolved manually. diff --git a/docs/docs/icechunk-python/virtual.md b/docs/docs/icechunk-python/virtual.md index 1e859ea9..459e3962 100644 --- a/docs/docs/icechunk-python/virtual.md +++ b/docs/docs/icechunk-python/virtual.md @@ -2,29 +2,24 @@ While Icechunk works wonderfully with native chunks managed by Zarr, there is lots of archival data out there in other formats already. To interoperate with such data, Icechunk supports "Virtual" chunks, where any number of chunks in a given dataset may reference external data in existing archival formats, such as netCDF, HDF, GRIB, or TIFF. Virtual chunks are loaded directly from the original source without copying or modifying the original achival data files. This enables Icechunk to manage large datasets from existing data without needing that data to be in Zarr format already. -!!! warning +!!! note - While virtual references are fully supported in Icechunk, creating virtual datasets currently relies on using experimental or pre-release versions of open source tools. For full instructions on how to install the required tools and their current statuses [see the tracking issue on Github](https://github.com/earth-mover/icechunk/issues/197). - With time, these experimental features will make their way into the released packages. + The concept of a "virtual Zarr dataset" originates from the [Kerchunk](https://fsspec.github.io/kerchunk/) project, which preceded and inspired [VirtualiZarr](https://virtualizarr.readthedocs.io/en/latest/). Like `VirtualiZarr`, the `kerchunk` package provides functionality to scan metadata of existing data files and combine these references into larger virtual datasets, but unlike `VirtualiZarr` the `Kerchunk` package currently has no facility for writing to `Icechunk` stores. If you previously were interested in "Kerchunking" your data, you can now achieve a similar result by using `VirtualiZarr` to create virtual datasets and write them to `icechunk`. -To create virtual Icechunk datasets with Python, the community utilizes the [kerchunk](https://fsspec.github.io/kerchunk/) and [VirtualiZarr](https://virtualizarr.readthedocs.io/en/latest/) packages. +`VirtualiZarr` lets users ingest existing data files into virtual datasets using various different tools under the hood, including `kerchunk`, `xarray`, `zarr`, and now `icechunk`. It does so by creating virtual references to existing data that can be combined and manipulated to create larger virtual datasets using `xarray`. These datasets can then be exported to `kerchunk` reference format or to an `Icechunk` repository, without ever copying or moving the existing data files. -`kerchunk` allows scanning the metadata of existing data files to extract virtual references. It also provides methods to combine these references into [larger virtual datasets](https://fsspec.github.io/kerchunk/tutorial.html#combine-multiple-kerchunked-datasets-into-a-single-logical-aggregate-dataset), which can be exported to it's [reference format](https://fsspec.github.io/kerchunk/spec.html). +!!! note -`VirtualiZarr` lets users ingest existing data files into virtual datasets using various different tools under the hood, including `kerchunk`, `xarray`, `zarr`, and now `icechunk`. It does so by creating virtual references to existing data that can be combined and manipulated to create larger virtual datasets using `xarray`. These datasets can then be exported to `kerchunk` reference format or to an `Icechunk` store, without ever copying or moving the existing data files. + [Currently only `s3` compatible storage and `local` storage are supported for virtual references](#virtual-reference-storage-support). Support for other storage types like [`gcs`](https://github.com/earth-mover/icechunk/issues/524), [`azure`](https://github.com/earth-mover/icechunk/issues/602), and [`https`](https://github.com/earth-mover/icechunk/issues/526) are on the roadmap. ## Creating a virtual dataset with VirtualiZarr We are going to create a virtual dataset pointing to all of the [OISST](https://www.ncei.noaa.gov/products/optimum-interpolation-sst) data for August 2024. This data is distributed publicly as netCDF files on AWS S3, with one netCDF file containing the Sea Surface Temperature (SST) data for each day of the month. We are going to use `VirtualiZarr` to combine all of these files into a single virtual dataset spanning the entire month, then write that dataset to Icechunk for use in analysis. -!!! note - - At this point you should have followed the instructions [here](https://github.com/earth-mover/icechunk/issues/197) to install the necessary experimental dependencies. - -Before we get started, we also need to install `fsspec` and `s3fs` for working with data on s3. +Before we get started, we need to install `virtualizarr`, and `icechunk`. We also need to install `fsspec` and `s3fs` for working with data on s3. ```shell -pip install fsspec s3fs +pip install virtualizarr icechunk fsspec s3fs ``` First, we need to find all of the files we are interested in, we will do this with fsspec using a `glob` expression to find every netcdf file in the August 2024 folder in the bucket: @@ -83,43 +78,30 @@ virtual_ds = xr.concat( # err (time, zlev, lat, lon) int16 64MB ManifestArray Size: 17MB # Dimensions: (time: 36, y: 205, x: 275) # Coordinates: @@ -153,7 +154,7 @@ xr.open_zarr(store, consolidated=False) We can also read data from previous snapshots by checking out prior versions: ```python -session = repo.readable_session(snapshot_id='ME4VKFPA5QAY0B2YSG8G') +session = repo.readonly_session(snapshot_id=first_snapshot) xr.open_zarr(session.store, consolidated=False) # Size: 9MB diff --git a/docs/docs/sample-datasets.md b/docs/docs/sample-datasets.md index e3ceb7a8..7216eb4f 100644 --- a/docs/docs/sample-datasets.md +++ b/docs/docs/sample-datasets.md @@ -6,9 +6,67 @@ title: Sample Datasets !!! warning This page is under construction. The listed datasets are outdated and will not work until the icechunk format is more stable. - ## Native Datasets +### Weatherbench2 ERA5 + +=== "AWS" + +```python +import icechunk as ic +import xarray as xr + +storage = ic.s3_storage( + bucket="icechunk-public-data", + prefix="v01/era5_weatherbench2", + region="us-east-1", + anonymous=True, +) + +repo = ic.Repository.open(storage=storage) +session = repo.readonly_session("main") +ds = xr.open_dataset( + session.store, group="1x721x1440", engine="zarr", chunks=None, consolidated=False +) +``` + +=== "Google Cloud" + +```python +import icechunk as ic +import xarray as xr + +storage = ic.gcs_storage( + bucket="icechunk-public-data-gcs", + prefix="v01/era5_weatherbench2", +) + +repo = ic.Repository.open(storage=storage) +session = repo.readonly_session("main") +ds = xr.open_dataset( + session.store, group="1x721x1440", engine="zarr", chunks=None, consolidated=False +) +``` + + + + + + + + + + + + + + + + + + + + ## Virtual Datasets ### NOAA [OISST](https://www.ncei.noaa.gov/products/optimum-interpolation-sst) Data diff --git a/docs/docs/spec.md b/docs/docs/spec.md index 3a582341..58292dbb 100644 --- a/docs/docs/spec.md +++ b/docs/docs/spec.md @@ -72,7 +72,6 @@ Finally, in an atomic put-if-not-exists operation, to commit the transaction, it This operation may fail if a different client has already committed the next snapshot. In this case, the client may attempt to resolve the conflicts and retry the commit. - ```mermaid flowchart TD subgraph metadata[Metadata] @@ -121,6 +120,7 @@ All data and metadata files are stored within a root directory (typically a pref - `$ROOT/snapshots/` snapshot files - `$ROOT/attributes/` attribute files - `$ROOT/manifests/` chunk manifests +- `$ROOT/transactions/` transaction log files - `$ROOT/chunks/` chunks ### File Formats @@ -128,7 +128,6 @@ All data and metadata files are stored within a root directory (typically a pref !!! warning The actual file formats used for each type of metadata file are in flux. The spec currently describes the data structures encoded in these files, rather than a specific file format. - ### Reference Files Similar to Git, Icechunk supports the concept of _branches_ and _tags_. @@ -149,9 +148,8 @@ Different client sessions may simultaneously create two inconsistent snapshots; References (both branches and tags) are stored as JSON files, the content is a JSON object with: -* keys: a single key `"snapshot"`, -* value: a string representation of the snapshot id, using [Base 32 Crockford](https://www.crockford.com/base32.html) encoding. The snapshot id is 12 byte random binary, so the encoded string has 20 characters. - +- keys: a single key `"snapshot"`, +- value: a string representation of the snapshot id, using [Base 32 Crockford](https://www.crockford.com/base32.html) encoding. The snapshot id is 12 byte random binary, so the encoded string has 20 characters. Here is an example of a JSON file corresponding to a tag or branch: @@ -186,6 +184,7 @@ Branch references are stored in the `refs/` directory within a subdirectory corr Branch names may not contain the `/` character. To facilitate easy lookups of the latest branch reference, we use the following encoding for the sequence number: + - subtract the sequence number from the integer `1099511627775` - encode the resulting integer as a string using [Base 32 Crockford](https://www.crockford.com/base32.html) - left-padding the string with 0s to a length of 8 characters @@ -216,30 +215,8 @@ Tags cannot be deleted once created. The snapshot file fully describes the schema of the repository, including all arrays and groups. -The snapshot file is currently encoded using [MessagePack](https://msgpack.org/), but this may change before Icechunk version 1.0. Given the alpha status of this spec, the best way to understand the information stored -in the snapshot file is through the data structure used internally by the Icechunk library for serialization. This data structure will most certainly change before the spec stabilization: - -```rust -pub struct Snapshot { - pub icechunk_snapshot_format_version: IcechunkFormatVersion, - pub icechunk_snapshot_format_flags: BTreeMap, - - pub manifest_files: Vec, - pub attribute_files: Vec, - - pub total_parents: u32, - pub short_term_parents: u16, - pub short_term_history: VecDeque, - - pub metadata: SnapshotMetadata, - pub started_at: DateTime, - pub properties: SnapshotProperties, - nodes: BTreeMap, -} -``` - -To get full details on what each field contains, please refer to the [Icechunk library code](https://github.com/earth-mover/icechunk/blob/f460a56577ec560c4debfd89e401a98153cd3560/icechunk/src/format/snapshot.rs#L97). - +The snapshot file is encoded using [flatbuffers](https://github.com/google/flatbuffers). The IDL for the +on-disk format can be found in [the repository file](https://github.com/earth-mover/icechunk/tree/main/icechunk/flatbuffers/snapshot.fbs) ### Attributes Files @@ -248,8 +225,7 @@ Attribute files hold user-defined attributes separately from the snapshot file. !!! warning Attribute files have not been implemented. -The on-disk format for attribute files has not been defined yet, but it will probably be a -MessagePack serialization of the attributes map. +The on-disk format for attribute files has not been defined in full yet. ### Chunk Manifest Files @@ -257,28 +233,14 @@ A chunk manifest file stores chunk references. Chunk references from multiple arrays can be stored in the same chunk manifest. The chunks from a single array can also be spread across multiple manifests. -Manifest files are currently encoded using [MessagePack](https://msgpack.org/), but this may change before Icechunk version 1.0. Given the alpha status of this spec, the best way to understand the information stored -in the snapshot file is through the data structure used internally by the Icechunk library. This data structure will most certainly change before the spec stabilization: - -```rust -pub struct Manifest { - pub icechunk_manifest_format_version: IcechunkFormatVersion, - pub icechunk_manifest_format_flags: BTreeMap, - chunks: BTreeMap<(NodeId, ChunkIndices), ChunkPayload>, -} - -pub enum ChunkPayload { - Inline(Bytes), - Virtual(VirtualChunkRef), - Ref(ChunkRef), -} -``` +Manifest files are encoded using [flatbuffers](https://github.com/google/flatbuffers). The IDL for the +on-disk format can be found in [the repository file](https://github.com/earth-mover/icechunk/tree/main/icechunk/flatbuffers/manifest.fbs) The most important part to understand from the data structure is the fact that manifests can hold three types of references: -* Native (`Ref`), pointing to the id of a chunk within the Icechunk repository. -* Inline (`Inline`), an optimization for very small chunks that can be embedded directly in the manifest. Mostly used for coordinate arrays. -* Virtual (`Virtual`), pointing to a region of a file outside of the Icechunk repository, for example, +- Native (`Ref`), pointing to the id of a chunk within the Icechunk repository. +- Inline (`Inline`), an optimization for very small chunks that can be embedded directly in the manifest. Mostly used for coordinate arrays. +- Virtual (`Virtual`), pointing to a region of a file outside of the Icechunk repository, for example, a chunk that is inside a NetCDF file in object store To get full details on what each field contains, please refer to the [Icechunk library code](https://github.com/earth-mover/icechunk/blob/f460a56577ec560c4debfd89e401a98153cd3560/icechunk/src/format/manifest.rs#L106). diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 3b8fc81c..41fa880d 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -173,6 +173,7 @@ nav: - Icechunk Python: - Quickstart: icechunk-python/quickstart.md - Configuration: icechunk-python/configuration.md + - Storage: icechunk-python/storage.md - FAQ: icechunk-python/faq.md - Xarray: icechunk-python/xarray.md - Parallel Writes: icechunk-python/parallel.md diff --git a/icechunk-python/Cargo.toml b/icechunk-python/Cargo.toml index 11016229..3d5c7fa7 100644 --- a/icechunk-python/Cargo.toml +++ b/icechunk-python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "icechunk-python" -version = "0.1.0" +version = "0.2.3" description = "Transactional storage engine for Zarr designed for use on cloud object storage" readme = "../README.md" repository = "https://github.com/earth-mover/icechunk" @@ -21,7 +21,7 @@ crate-type = ["cdylib"] bytes = "1.9.0" chrono = { version = "0.4.39" } futures = "0.3.31" -icechunk = { path = "../icechunk", version = "0.1.0" } +icechunk = { path = "../icechunk", version = "0.2.3", features = ["logs"] } itertools = "0.14.0" pyo3 = { version = "0.23", features = [ "chrono", @@ -37,6 +37,7 @@ serde_json = "1.0.137" async-trait = "0.1.85" typetag = "0.2.19" serde = { version = "1.0.217", features = ["derive", "rc"] } +miette = { version = "7.5.0", features = ["fancy"] } [lints] workspace = true diff --git a/icechunk-python/benchmarks/README.md b/icechunk-python/benchmarks/README.md index 0043ce19..0a7e7ab3 100644 --- a/icechunk-python/benchmarks/README.md +++ b/icechunk-python/benchmarks/README.md @@ -25,7 +25,6 @@ pytest -nauto -m setup_benchmarks --force-setup=False benchmarks/ ``` Use `---icechunk-prefix` to add an extra prefix during both setup and running of benchmarks. - ### ERA5 `benchmarks/create_era5.py` creates an ERA5 dataset. @@ -88,6 +87,28 @@ test_time_getsize_prefix[era5-single] (NOW) 2.2133 (1.0) -------------------------------------------------------------------------- ``` +### Notes +### Where to run the benchmarks? + +Pass the `--where [local|s3|gcs|tigris]` flag to control where benchmarks are run. +```sh +python benchmarks/runner.py --where gcs v0.1.2 +``` + +By default all benchmarks are run locally: +1. A temporary directory is used as a staging area. +2. A new virtual env is created there and the dev version is installed using `pip` and a github URI. *This means that you can only benchmark commits that have been pushed to Github.* + +It is possible to run the benchmarks in the cloud using Coiled. You will need to be a member of the Coiled workspaces: `earthmover-devs` (AWS), `earthmover-devs-gcp` (GCS) and `earthmover-devs-azure` (Azure). +1. We create a new "coiled software environment" with a specific name. +2. We use `coiled run` targeting a specific machine type, with a specific software env. +4. The VM stays alive for 10 minutes to allow for quick iteration. +5. Coiled does not sync stdout until the pytest command is done, for some reason. See the logs on the Coiled platform for quick feedback. +6. We use the `--sync` flag, so you will need [`mutagen`](https://mutagen.io/documentation/synchronization/) installed on your system. This will sync the benchmark JSON outputs between the VM and your machine. +Downsides: +1. At the moment, we can only benchmark released versions of icechunk. We may need a more complicated Docker container strategy in the future to support dev branch benchmarks. +2. When a new env is created, the first run always fails :/. The second run works though, so just re-run. + ### `runner.py` `runner.py` abstracts the painful task of setting up envs with different versions (with potential format changes), and recreating datasets where needed. diff --git a/icechunk-python/benchmarks/coiled_runner.py b/icechunk-python/benchmarks/coiled_runner.py new file mode 100644 index 00000000..f5f4af68 --- /dev/null +++ b/icechunk-python/benchmarks/coiled_runner.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +# +# This is just a scratch script for testing purposes +# coiled notebook start --sync --software icechunk-alpha-12 --vm-type m5.4xlarge +import subprocess + +# software = "icechunk-alpha-12" +vm_type = { + "s3": "m5.4xlarge", + "gcs": None, + "tigris": None, +} +ref = "icechunk-v0.1.0-alpha.12" + +COILED_SOFTWARE = { + "icechunk-v0.1.0-alpha.1": "icechunk-alpha-release", + "icechunk-v0.1.0-alpha.12": "icechunk-alpha-12", +} +software = COILED_SOFTWARE[ref] + +cmd = f'python benchmarks/runner.py --where coiled --pytest "-k zarr_open" {ref}' +subprocess.run( + [ + "coiled", + "run", + "--name", + "icebench-712f1eb2", + "--sync", + "--sync-ignore='python/ reports/ profiling/'", + "--keepalive", + "5m", + "--workspace=earthmover-devs", + "--vm-type=m5.4xlarge", + "--software=icechunk-bench-712f1eb2", + "--region=us-east-1", + "pytest -v benchmarks/", + ] +) diff --git a/icechunk-python/benchmarks/conftest.py b/icechunk-python/benchmarks/conftest.py index 20bd3ce1..f8b02bec 100644 --- a/icechunk-python/benchmarks/conftest.py +++ b/icechunk-python/benchmarks/conftest.py @@ -1,6 +1,13 @@ import pytest -from benchmarks.datasets import ERA5, ERA5_SINGLE, GB_8MB_CHUNKS, GB_128MB_CHUNKS +from benchmarks.datasets import ( + ERA5, + ERA5_ARCO, + ERA5_SINGLE, + GB_8MB_CHUNKS, + GB_128MB_CHUNKS, + TEST_BUCKETS, +) from icechunk import Repository, local_filesystem_storage from zarr.abc.store import Store @@ -12,21 +19,32 @@ def repo(tmpdir: str) -> Repository: @pytest.fixture( params=[ - pytest.param(ERA5, id="era5-weatherbench"), - pytest.param(ERA5_SINGLE, id="era5-single"), - pytest.param(GB_128MB_CHUNKS, id="gb-128mb"), pytest.param(GB_8MB_CHUNKS, id="gb-8mb"), + pytest.param(GB_128MB_CHUNKS, id="gb-128mb"), + pytest.param(ERA5_SINGLE, id="era5-single"), + pytest.param(ERA5, id="era5-weatherbench"), + pytest.param(ERA5_ARCO, id="era5-arco"), ], ) def synth_dataset(request) -> Store: """For now, these are synthetic datasets stored in the cloud.""" extra_prefix = request.config.getoption("--icechunk-prefix") + where = request.config.getoption("--where") ds = request.param + if where == "local" and ds.skip_local: + pytest.skip() # for some reason, this gets run multiple times so we apply the prefix repeatedly # if we don't catch that :( - ds.storage_config = ds.storage_config.with_extra( - prefix=extra_prefix, force_idempotent=True - ) + ds.storage_config = ds.storage_config.with_overwrite( + **TEST_BUCKETS[where] + ).with_extra(prefix=extra_prefix, force_idempotent=True) + if ds.setupfn is None: + # these datasets aren't automatically set up + # so skip if the data haven't been written yet. + try: + ds.store() + except ValueError as e: + pytest.skip(reason=str(e)) return ds @@ -61,3 +79,10 @@ def pytest_addoption(parser): for this icechunk version at that URI. True by default. """, ) + + parser.addoption( + "--where", + action="store", + help="Where to run icechunk benchmarks? [local|s3|gcs].", + default="local", + ) diff --git a/icechunk-python/benchmarks/create_era5.py b/icechunk-python/benchmarks/create_era5.py index 5f9d3d8b..3ff5fe45 100644 --- a/icechunk-python/benchmarks/create_era5.py +++ b/icechunk-python/benchmarks/create_era5.py @@ -3,113 +3,257 @@ # 1. just create-deepak-env v0.1.0a12 # 2. conda activate icechunk-v0.1.0a12 # 3. python benchmarks/create-era5.py + import argparse import datetime -import logging -import warnings +import math +import random +from enum import StrEnum, auto +from typing import Any -import helpers -from datasets import ERA5, Dataset +import humanize +import pandas as pd from packaging.version import Version +import dask import icechunk as ic import xarray as xr +import zarr +from benchmarks import helpers +from benchmarks.datasets import Dataset, IngestDataset +from dask.diagnostics import ProgressBar from icechunk.xarray import to_icechunk -logger = logging.getLogger("icechunk-bench") -logger.setLevel(logging.INFO) -console_handler = logging.StreamHandler() -logger.addHandler(console_handler) +logger = helpers.setup_logger() + +ICECHUNK_FORMAT = f"v{ic.spec_version():02d}" +ZARR_KWARGS = dict(zarr_format=3, consolidated=False) + + +class Mode(StrEnum): + APPEND = auto() + CREATE = auto() + OVERWRITE = auto() + VERIFY = auto() + + +ERA5_WB = IngestDataset( + name="ERA5-WB", + prefix="era5_weatherbench2", + source_uri="gs://weatherbench2/datasets/era5/1959-2023_01_10-full_37-1h-0p25deg-chunk-1.zarr", + engine="zarr", + read_chunks={"time": 24 * 3, "level": 1}, + write_chunks={"time": 1, "level": 1, "latitude": 721, "longitude": 1440}, + group="1x721x1440", + arrays=["2m_temperature", "10m_u_component_of_wind", "10m_v_component_of_wind"], +) + + +def verify(dataset: Dataset, *, ingest: IngestDataset, seed: int | None = None): + random.seed(seed) + + format = ICECHUNK_FORMAT + prefix = f"{format}/" + dataset.storage_config = dataset.storage_config.with_extra(prefix=prefix) + repo = ic.Repository.open(dataset.storage) + session = repo.readonly_session(branch="main") + instore = xr.open_dataset( + session.store, group=dataset.group, engine="zarr", chunks=None, consolidated=False + ) + time = pd.Timestamp(random.choice(instore.time.data.tolist())) + logger.info(f"Verifying {ingest.name} for {seed=!r}, {time=!r}") + actual = instore.sel(time=time) + + ds = ingest.open_dataset(chunks=None) + expected = ds[list(instore.data_vars)].sel(time=time) + + # I add global attrs, don't compare those + expected.attrs.clear() + actual.attrs.clear() + # TODO: Parallelize the compare in `assert_identical` upstream + with ProgressBar(): + actual, expected = dask.compute(actual, expected) + # assert (expected == actual).all().to_array().all() + xr.testing.assert_identical(expected, actual) + logger.info("Successfully verified!") -# @coiled.function -def write_era5(dataset: Dataset, *, ref, arrays_to_write): + +def write( + dataset: Dataset, + *, + ingest: IngestDataset, + mode: Mode, + nyears: int | None = None, + arrays_to_write: list[str] | None = None, + extra_attrs: dict[str, str] | None = None, + initialize_all_vars: bool = False, + dry_run: bool = False, +) -> None: """ - 1. We write all the metadata and coordinate arrays to make a "big" snapshot. - 2. We only write a few arrays to save time. + dataset: Dataset t write + arrays_to_write: list[str], + extra_attrs: any attributes to add + initialize_all_vars: whether to write all coordinate arrays, and metadata for ALL data_vars. + + Usually, initialize_all_vars=True for benchmarks, but not for the "public dataset". + For benchmarks, + 1. We write all the metadata and coordinate arrays to make a "big" snapshot. + 2. We only write a few arrays to save time. """ import coiled import distributed - SELECTOR = {"time": slice(5 * 365 * 24)} - chunk_shape = {"time": 1, "level": 1, "latitude": 721, "longitude": 1440} - zarr_kwargs = dict(group=dataset.group, zarr_format=3, consolidated=False) - - with warnings.catch_warnings(): - warnings.simplefilter("ignore", category=UserWarning) - ds = xr.open_zarr( - "gs://weatherbench2/datasets/era5/1959-2023_01_10-full_37-1h-0p25deg-chunk-1.zarr", - chunks={"time": 24 * 3, "level": 1}, - ).drop_encoding() - for v in ds: - ds[v].encoding["chunks"] = tuple(chunk_shape[dim] for dim in ds[v].dims) - - towrite = ds[arrays_to_write].isel(SELECTOR) - towrite.attrs["written_arrays"] = " ".join(towrite.data_vars) - towrite.attrs["icechunk_commit"] = helpers.get_commit(ref) - towrite.attrs["icechunk_ref"] = ref + SELECTOR = {"time": slice(nyears * 365 * 24) if nyears is not None else slice(None)} + if mode in [Mode.CREATE, Mode.OVERWRITE]: + write_mode = "w" + elif mode is Mode.APPEND: + write_mode = "a" + + ic_kwargs = dict(group=dataset.group, mode=write_mode) + + ds = ingest.open_dataset() + if arrays_to_write is not None: + towrite = ds[arrays_to_write].isel(SELECTOR) + else: + towrite = ds.isel(SELECTOR) towrite.attrs["selector"] = str(SELECTOR) + towrite.attrs.update(extra_attrs or {}) + for v in towrite: + towrite[v].encoding["chunks"] = tuple( + ingest.write_chunks[dim] for dim in ds[v].dims + ) + + nchunks = tuple( + math.prod((var.sizes[dim] // ingest.write_chunks[dim] + 1) for dim in var.dims) + for _, var in towrite.data_vars.items() + ) + logger.info( + f"Size: {humanize.naturalsize(towrite.nbytes)}, " + f"Total nchunks= {humanize.intcomma(sum(nchunks))}, " + f"per array: {[humanize.intcomma(i) for i in nchunks]}" + ) repo = ic.Repository.open(dataset.storage) - logger.info("Initializing dataset.") - session = repo.writable_session("main") - ds.to_zarr(session.store, compute=False, **zarr_kwargs) - session.commit("initialized dataset") - logger.info("Finished initializing dataset.") + if dry_run: + print("Dry run. Exiting") + return + ckwargs = dataset.storage_config.get_coiled_kwargs() session = repo.writable_session("main") - # FIXME: use name - # # name=f"earthmover/{ref}", - with coiled.Cluster(n_workers=(4, 200), worker_cpu=2) as cluster: - client = distributed.Client(cluster) + with coiled.Cluster( + name=f"icechunk-ingest-{ICECHUNK_FORMAT}-{ingest.name}", + shutdown_on_close=False, + n_workers=(4, 200), + worker_cpu=2, + workspace=ckwargs["workspace"], + region=ckwargs["region"], + ) as cluster: + # https://docs.coiled.io/user_guide/clusters/environ.html + cluster.send_private_envs(dataset.storage_config.env_vars) + client = distributed.Client(cluster) # type: ignore[no-untyped-call] print(client) - with distributed.performance_report( - f"reports/era5-ingest-{ref}-{datetime.datetime.now()}.html" + with distributed.performance_report( # type: ignore[no-untyped-call] + f"reports/{ingest.name}-ingest-{dataset.storage_config.store}-{ICECHUNK_FORMAT}-{datetime.datetime.now()}.html" ): - logger.info(f"Started writing {arrays_to_write=}.") - to_icechunk( - towrite, session=session, region="auto", **zarr_kwargs, split_every=32 - ) + logger.info(f"Started writing {tuple(towrite.data_vars)}.") + with zarr.config.set({"async.concurrency": 24}): + to_icechunk(towrite, session=session, **ic_kwargs, split_every=32) session.commit("ingest!") - logger.info(f"Finished writing {arrays_to_write=}.") + logger.info(f"Finished writing {tuple(towrite.data_vars)}.") -def setup_era5_weatherbench2( - dataset: Dataset, *, ref: str, arrays_to_write: list[str] +def setup_dataset( + dataset: Dataset, + *, + ingest: IngestDataset, + mode: Mode, + dry_run: bool = False, + **kwargs: Any, ) -> None: - commit = helpers.get_commit(ref) - logger.info(f"Writing ERA5 for {ref=}, {commit=}, {arrays_to_write=}") - prefix = f"benchmarks/{ref}_{commit}/" + # commit = helpers.get_commit(ref) + format = ICECHUNK_FORMAT + logger.info(f"Writing {ingest.name} for {format}, {kwargs=}") + prefix = f"{format}/" dataset.storage_config = dataset.storage_config.with_extra(prefix=prefix) - dataset.create() - write_era5(dataset, ref=ref, arrays_to_write=arrays_to_write) + if mode is Mode.CREATE: + logger.info("Creating new repository") + repo = dataset.create(clear=True) + logger.info("Initializing root group") + session = repo.writable_session("main") + zarr.open_group(session.store, mode="w-") + session.commit("initialized root group") + logger.info("Initialized root group") + + write( + dataset, + ingest=ingest, + mode=mode, + initialize_all_vars=False, + dry_run=dry_run, + **kwargs, + ) def get_version() -> str: version = Version(ic.__version__) - if "a" in version.pre: + if version.pre is not None and "a" in version.pre: return f"icechunk-v{version.base_version}-alpha.{version.pre[1]}" else: - raise NotImplementedError + return f"icechunk-v{version.base_version}" if __name__ == "__main__": helpers.assert_cwd_is_icechunk_python() parser = argparse.ArgumentParser() - # parser.add_argument("ref", help="ref to run ingest for") + parser.add_argument("store", help="object store to write to") + parser.add_argument( + "--mode", help="'create'/'overwrite'/'append'/'verify'", default="append" + ) + parser.add_argument( + "--nyears", help="number of years to write (from start)", default=None, type=int + ) + parser.add_argument("--dry-run", action="store_true", help="dry run/?", default=False) + parser.add_argument( + "--append", action="store_true", help="append or create?", default=False + ) + parser.add_argument("--arrays", help="arrays to write", nargs="+", default=[]) + parser.add_argument("--seed", help="random seed for verify", default=None, type=int) parser.add_argument( - "--arrays", - help="arrays to write", - nargs="+", - default=[ - "2m_temperature", - "10m_u_component_of_wind", - "10m_v_component_of_wind", - "boundary_layer_height", - ], + "--debug", help="write to debug bucket?", default=False, action="store_true" ) + args = parser.parse_args() - setup_era5_weatherbench2(ERA5, ref=get_version(), arrays_to_write=args.arrays) + if args.mode == "create": + mode = Mode.CREATE + elif args.mode == "append": + mode = Mode.APPEND + elif args.mode == "overwrite": + mode = Mode.OVERWRITE + elif args.mode == "verify": + mode = Mode.VERIFY + else: + raise ValueError( + f"mode must be one of ['create', 'overwrite', 'append', 'verify']. Received {args.mode=!r}" + ) + + ingest = ERA5_WB + dataset = ingest.make_dataset(store=args.store, debug=args.debug) + logger.info(ingest) + logger.info(dataset) + logger.info(args) + ds = ingest.open_dataset() + if mode is Mode.VERIFY: + verify(dataset, ingest=ingest, seed=args.seed) + else: + setup_dataset( + dataset, + ingest=ingest, + nyears=args.nyears, + mode=mode, + arrays_to_write=args.arrays or ingest.arrays, + dry_run=args.dry_run, + ) diff --git a/icechunk-python/benchmarks/datasets.py b/icechunk-python/benchmarks/datasets.py index 897d231e..4e6a24af 100644 --- a/icechunk-python/benchmarks/datasets.py +++ b/icechunk-python/benchmarks/datasets.py @@ -1,42 +1,111 @@ import datetime import time +import warnings from collections.abc import Callable from dataclasses import dataclass, field from functools import partial -from typing import Any, Self +from typing import Any, Literal, Self, TypeAlias import fsspec import numpy as np +import platformdirs import icechunk as ic import xarray as xr import zarr +from benchmarks.helpers import get_coiled_kwargs, rdms, setup_logger rng = np.random.default_rng(seed=123) +Store: TypeAlias = Literal["s3", "gcs", "az", "tigris"] +PUBLIC_DATA_BUCKET = "icechunk-public-data" +ZARR_KWARGS = dict(zarr_format=3, consolidated=False) + +CONSTRUCTORS = { + "s3": ic.s3_storage, + "gcs": ic.gcs_storage, + "tigris": ic.tigris_storage, + "local": ic.local_filesystem_storage, +} +TEST_BUCKETS = { + "s3": dict(store="s3", bucket="icechunk-test", region="us-east-1"), + "gcs": dict(store="gcs", bucket="icechunk-test-gcp", region="us-east1"), + # "tigris": dict( + # store="tigris", bucket="deepak-private-bucket" + "-test", region="iad" + # ), + "tigris": dict(store="tigris", bucket="icechunk-test", region="iad"), + "local": dict(store="local", bucket=platformdirs.site_cache_dir()), +} +BUCKETS = { + "s3": dict(store="s3", bucket=PUBLIC_DATA_BUCKET, region="us-east-1"), + "gcs": dict(store="gcs", bucket=PUBLIC_DATA_BUCKET + "-gcs", region="us-east1"), + "tigris": dict(store="tigris", bucket=PUBLIC_DATA_BUCKET + "-tigris", region="iad"), +} + +logger = setup_logger() + + +def tigris_credentials() -> tuple[str, str]: + import boto3 + + session = boto3.Session() + creds = session.get_credentials() + return {"access_key_id": creds.access_key, "secret_access_key": creds.secret_key} + @dataclass class StorageConfig: """wrapper that allows us to config the prefix for a ref.""" - constructor: Callable - config: Any + store: str | None = None + config: dict[str, Any] = field(default_factory=dict) bucket: str | None = None prefix: str | None = None - path: str | None = None + region: str | None = None + + @property + def path(self) -> str: + if self.store != "local": + raise ValueError(f"can't grab path for {self.store=!r}") + return f"{self.bucket}/{self.prefix}" def create(self) -> ic.Storage: + if self.store is None: + raise ValueError("StorageConfig.store is None!") kwargs = {} - if self.bucket is not None: - kwargs["bucket"] = self.bucket - if self.prefix is not None: - kwargs["prefix"] = self.prefix - if self.path is not None: + if self.store == "local": kwargs["path"] = self.path - return self.constructor(config=self.config, **kwargs) + else: + if self.bucket is not None: + kwargs["bucket"] = self.bucket + if self.prefix is not None: + kwargs["prefix"] = self.prefix + if self.region is not None and self.store not in ["gcs"]: + kwargs["region"] = self.region + if self.store == "tigris": + kwargs.update(tigris_credentials()) + return CONSTRUCTORS[self.store](**self.config, **kwargs) + + def with_overwrite( + self, + *, + store: str | None = None, + bucket: str | None = None, + region: str | None = None, + ) -> Self: + return type(self)( + store=store if store is not None else self.store, + bucket=bucket if bucket is not None else self.bucket, + region=region if region is not None else self.region, + prefix=self.prefix, + config=self.config, + ) def with_extra( - self, *, prefix: str | None = None, force_idempotent: bool = False + self, + *, + prefix: str | None = None, + force_idempotent: bool = False, ) -> Self: if self.prefix is not None: if force_idempotent and self.prefix.startswith(prefix): @@ -45,33 +114,40 @@ def with_extra( else: new_prefix = None - if self.path is not None: - if force_idempotent and self.path.startswith(prefix): - return self - new_path = (prefix or "") + self.path - else: - new_path = None return type(self)( - constructor=self.constructor, + store=self.store, bucket=self.bucket, prefix=new_prefix, - path=new_path, + region=self.region, config=self.config, ) + @property + def env_vars(self) -> dict[str, str]: + # if self.store == "tigris": + # # https://www.tigrisdata.com/docs/iam/#create-an-access-key + # return {"AWS_ENDPOINT_URL_IAM": "https://fly.iam.storage.tigris.dev"} + return {} + + @property + def protocol(self) -> str: + if self.store in ("s3", "tigris"): + protocol = "s3" + elif self.store == "gcs": + protocol = "gcs" + else: + protocol = "file" + return protocol + def clear_uri(self) -> str: """URI to clear when re-creating data from scratch.""" - if self.constructor == ic.Storage.new_s3: - protocol = "s3://" + if self.store == "local": + return f"{self.protocol}://{self.path}" else: - protocol = "" + return f"{self.protocol}://{self.bucket}/{self.prefix}" - if self.bucket is not None: - return f"{protocol}{self.bucket}/{self.prefix}" - elif self.path is not None: - return self.path - else: - raise NotImplementedError("I don't know what to do here.") + def get_coiled_kwargs(self) -> str: + return get_coiled_kwargs(store=self.store, region=self.region) @dataclass @@ -81,17 +157,9 @@ class Dataset: """ storage_config: StorageConfig - # data variable to load in `time_xarray_read_chunks` - load_variables: list[str] - # Passed to .isel for `time_xarray_read_chunks` - chunk_selector: dict[str, Any] - # name of (coordinate) variable used for testing "time to first byte" - first_byte_variable: str | None # core useful group path used to open an Xarray Dataset group: str | None = None - # function used to construct the dataset prior to read benchmarks - setupfn: Callable | None = None - _storage: ic.Storage | None = field(default=None, init=False) + _storage: ic.Storage | None = field(default=None, init=False, repr=False) @property def storage(self) -> ic.Storage: @@ -99,19 +167,24 @@ def storage(self) -> ic.Storage: self._storage = self.storage_config.create() return self._storage - def create(self) -> ic.Repository: - clear_uri = self.storage_config.clear_uri() - if clear_uri is None: - raise NotImplementedError - if not clear_uri.startswith("s3://"): - raise NotImplementedError( - f"Only S3 URIs supported at the moment. Received {clear_uri}" - ) - fs = fsspec.filesystem("s3") - try: - fs.rm(f"{clear_uri}", recursive=True) - except FileNotFoundError: - pass + def create(self, clear: bool = False) -> ic.Repository: + if clear: + clear_uri = self.storage_config.clear_uri() + if clear_uri is None: + raise NotImplementedError + if self.storage_config.protocol not in ["file", "s3", "gcs"]: + warnings.warn( + f"Only clearing of GCS, S3-compatible URIs supported at the moment. Received {clear_uri!r}", + RuntimeWarning, + stacklevel=2, + ) + else: + fs = fsspec.filesystem(self.storage_config.protocol) + try: + logger.info(f"Clearing prefix: {clear_uri!r}") + fs.rm(clear_uri, recursive=True) + except FileNotFoundError: + pass return ic.Repository.create(self.storage) @property @@ -119,6 +192,25 @@ def store(self) -> ic.IcechunkStore: repo = ic.Repository.open(self.storage) return repo.readonly_session(branch="main").store + +@dataclass(kw_only=True) +class BenchmarkDataset(Dataset): + # data variable to load in `time_xarray_read_chunks` + load_variables: list[str] | None = None + # Passed to .isel for `time_xarray_read_chunks` + chunk_selector: dict[str, Any] | None = None + # name of (coordinate) variable used for testing "time to first byte" + first_byte_variable: str | None + # function used to construct the dataset prior to read benchmarks + setupfn: Callable | None = None + # whether to skip this one on local runs + skip_local: bool = False + + def create(self, clear: bool = True): + if clear is not True: + raise ValueError("clear *must* be true for benchmark datasets.") + return super().create(clear=True) + def setup(self, force: bool = False) -> None: """ force: if True, recreate from scratch. If False, try opening the store, @@ -140,6 +232,36 @@ def setup(self, force: bool = False) -> None: self.setupfn(self) +@dataclass(kw_only=True) +class IngestDataset: + name: str + source_uri: str + group: str + prefix: str + write_chunks: dict[str, int] + arrays: list[str] + engine: str | None = None + read_chunks: dict[str, int] | None = None + + def open_dataset(self, chunks=None, **kwargs: Any) -> xr.Dataset: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", category=UserWarning) + return xr.open_dataset( + self.source_uri, + chunks=chunks or self.read_chunks, + engine=self.engine, + **kwargs, + ).drop_encoding() + + def make_dataset(self, *, store: str, debug: bool) -> Dataset: + buckets = BUCKETS if not debug else TEST_BUCKETS + extra_prefix = f"_{rdms()}" if debug else "" + storage_config = StorageConfig( + prefix=self.prefix + extra_prefix, **buckets[store] + ) + return Dataset(storage_config=storage_config, group=self.group) + + def setup_synthetic_gb_dataset( dataset: Dataset, chunk_shape: tuple[int, ...], @@ -167,7 +289,7 @@ def setup_era5_single(dataset: Dataset): # FIXME: move to earthmover-sample-data url = "https://nsf-ncar-era5.s3.amazonaws.com/e5.oper.an.pl/194106/e5.oper.an.pl.128_060_pv.ll025sc.1941060100_1941060123.nc" - print(f"Reading {url}") + logger.info(f"Reading {url}") tic = time.time() ds = xr.open_dataset( # using pooch means we download only once on a local machine @@ -178,7 +300,7 @@ def setup_era5_single(dataset: Dataset): engine="h5netcdf", ) ds = ds.drop_encoding().load() - print(f"Loaded data in {time.time() - tic} seconds") + logger.info(f"Loaded data in {time.time() - tic} seconds") repo = dataset.create() session = repo.writable_session("main") @@ -186,63 +308,117 @@ def setup_era5_single(dataset: Dataset): encoding = { "PV": {"compressors": [zarr.codecs.ZstdCodec()], "chunks": (1, 1, 721, 1440)} } - print("Writing data...") + logger.info("Writing data...") ds.to_zarr( session.store, mode="w", zarr_format=3, consolidated=False, encoding=encoding ) - print(f"Wrote data in {time.time() - tic} seconds") + logger.info(f"Wrote data in {time.time() - tic} seconds") session.commit(f"wrote data at {datetime.datetime.now(datetime.UTC)}") -# TODO: passing Storage directly is nice, but doesn't let us add an extra prefix. -ERA5 = Dataset( - storage_config=StorageConfig( - constructor=ic.Storage.new_s3, - bucket="icechunk-test", - prefix="era5-weatherbench", - config=ic.S3Options(), - ), +def setup_ingest_for_benchmarks(dataset: Dataset, *, ingest: IngestDataset) -> None: + """ + For benchmarks, we + 1. add a specific prefix. + 2. always write the metadata for the WHOLE dataset + 3. then append a small subset of data for a few arrays + """ + from benchmarks.create_era5 import Mode, write + + repo = dataset.create() + ds = ingest.open_dataset() + logger.info("Initializing dataset for benchmarks..") + session = repo.writable_session("main") + ds.to_zarr( + session.store, compute=False, mode="w-", group=dataset.group, **ZARR_KWARGS + ) + session.commit("initialized dataset") + logger.info("Finished initializing dataset.") + + if ingest.arrays: + attrs = { + "written_arrays": " ".join(ingest.arrays), + } + write( + dataset, + ingest=ingest, + mode=Mode.APPEND, + extra_attrs=attrs, + arrays_to_write=ingest.arrays, + initialize_all_vars=False, + ) + + +def setup_era5(*args, **kwargs): + from benchmarks.create_era5 import setup_for_benchmarks + + return setup_for_benchmarks(*args, **kwargs, arrays_to_write=[]) + + +ERA5_ARCO_INGEST = IngestDataset( + name="ERA5-ARCO", + prefix="era5_arco", + source_uri="gs://gcp-public-data-arco-era5/ar/full_37-1h-0p25deg-chunk-1.zarr-v3", + engine="zarr", + read_chunks={"time": 72 * 24, "level": 1}, + write_chunks={"time": 1, "level": 1, "latitude": 721, "longitude": 1440}, + group="1x721x1440", + arrays=[], +) + +ERA5 = BenchmarkDataset( + # weatherbench2 data - 5 years + skip_local=False, + storage_config=StorageConfig(prefix="era5-weatherbench"), load_variables=["2m_temperature"], chunk_selector={"time": 1}, first_byte_variable="latitude", group="1x721x1440", # don't set setupfn here so we don't run a really expensive job # by mistake + # setupfn=partial(setup_ingest_for_benchmarks, ingest=ERA5_WB), ) -ERA5_SINGLE = Dataset( - storage_config=StorageConfig( - constructor=ic.Storage.new_s3, - bucket="icechunk-test", - prefix="perf-era5-single", - config=ic.S3Options(), - ), +ERA5_ARCO = BenchmarkDataset( + skip_local=False, + storage_config=StorageConfig(prefix="era5-arco"), + first_byte_variable="latitude", + group="1x721x1440", + setupfn=partial(setup_ingest_for_benchmarks, ingest=ERA5_ARCO_INGEST), +) + +# ERA5_LARGE = BenchmarkDataset( +# skip_local=True, +# storage_config=StorageConfig( +# bucket="icechunk-public-data", prefix="era5-weatherbench2" +# ), +# load_variables=["2m_temperature"], +# chunk_selector={"time": 1}, +# first_byte_variable="latitude", +# group="1x721x1440", +# # don't set setupfn here so we don't run a really expensive job +# # by mistake +# ) + +ERA5_SINGLE = BenchmarkDataset( + # Single NCAR AWS PDS ERA5 netCDF + storage_config=StorageConfig(prefix="perf-era5-single"), load_variables=["PV"], chunk_selector={"time": 1}, first_byte_variable="latitude", setupfn=setup_era5_single, ) -GB_128MB_CHUNKS = Dataset( - storage_config=StorageConfig( - constructor=ic.Storage.new_s3, - bucket="icechunk-test", - prefix="gb-128mb-chunks", - config=ic.S3Options(), - ), +GB_128MB_CHUNKS = BenchmarkDataset( + storage_config=StorageConfig(prefix="gb-128mb-chunks"), load_variables=["array"], chunk_selector={}, first_byte_variable=None, setupfn=partial(setup_synthetic_gb_dataset, chunk_shape=(64, 512, 512)), ) -GB_8MB_CHUNKS = Dataset( - storage_config=StorageConfig( - constructor=ic.Storage.new_s3, - bucket="icechunk-test", - prefix="gb-8mb-chunks", - config=ic.S3Options(), - ), +GB_8MB_CHUNKS = BenchmarkDataset( + storage_config=StorageConfig(prefix="gb-8mb-chunks"), load_variables=["array"], chunk_selector={}, first_byte_variable=None, @@ -250,12 +426,12 @@ def setup_era5_single(dataset: Dataset): ) # TODO -GPM_IMERG_VIRTUAL = Dataset( +GPM_IMERG_VIRTUAL = BenchmarkDataset( storage_config=StorageConfig( - constructor=ic.Storage.new_s3, + store="s3", bucket="earthmover-icechunk-us-west-2", prefix="nasa-impact/GPM_3IMERGHH.07-virtual-1998", - config=ic.S3Options(), + region="us-west-2", # access_key_id=access_key_id, # secret_access_key=secret, # session_token=session_token, diff --git a/icechunk-python/benchmarks/helpers.py b/icechunk-python/benchmarks/helpers.py index 9737af67..35c7141a 100644 --- a/icechunk-python/benchmarks/helpers.py +++ b/icechunk-python/benchmarks/helpers.py @@ -1,7 +1,49 @@ +import logging import os import subprocess +def setup_logger(): + logger = logging.getLogger("icechunk-bench") + logger.setLevel(logging.INFO) + console_handler = logging.StreamHandler() + logger.addHandler(console_handler) + logger.handlers = logger.handlers[:1] # make idempotent + return logger + + +def get_coiled_kwargs(*, store: str, region: str | None = None) -> str: + COILED_VM_TYPES = { + # TODO: think about these + "s3": "m5.4xlarge", + "gcs": "n2-standard-16", + "tigris": "m5.4xlarge", + } + DEFAULT_REGIONS = { + "s3": "us-east-1", + "gcs": "us-east1", + "tigris": "us-east-1", + "az": "eastus", + } + WORKSPACES = { + "s3": "earthmover-devs", + "tigris": "earthmover-devs", + "gcs": "earthmover-devs-gcp", + "az": "earthmover-devs-azure", + } + TIGRIS_REGIONS = {"iad": "us-east-1"} + + if region is None: + region = DEFAULT_REGIONS[store] + else: + region = TIGRIS_REGIONS[region] if store == "tigris" else region + return { + "workspace": WORKSPACES[store], + "region": region, + "vm_type": COILED_VM_TYPES[store], + } + + def assert_cwd_is_icechunk_python(): CURRENTDIR = os.getcwd() if not CURRENTDIR.endswith("icechunk-python"): @@ -10,7 +52,14 @@ def assert_cwd_is_icechunk_python(): ) -def get_commit(ref: str) -> str: +def get_full_commit(ref: str) -> str: return subprocess.run( ["git", "rev-parse", ref], capture_output=True, text=True, check=True - ).stdout.strip()[:8] + ).stdout.strip() + + +def rdms() -> str: + import random + import string + + return "".join(random.sample(string.ascii_lowercase, k=8)) diff --git a/icechunk-python/benchmarks/most_recent.sh b/icechunk-python/benchmarks/most_recent.sh new file mode 100644 index 00000000..bfb30fbc --- /dev/null +++ b/icechunk-python/benchmarks/most_recent.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env sh + +echo $(ls -t ./.benchmarks/**/* | head -n 1) +pytest-benchmark compare --group=group,func,param --sort=fullname --columns=median --name=normal `ls -t ./.benchmarks/**/* | head -n 1` diff --git a/icechunk-python/benchmarks/runner.py b/icechunk-python/benchmarks/runner.py index 3a0c369c..1980ea02 100644 --- a/icechunk-python/benchmarks/runner.py +++ b/icechunk-python/benchmarks/runner.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 # helper script to run and save benchmarks against named refs. # AKA a shitty version of asv's env management +# FIXME: +# 1. The Icechunk Spec Version is taken from the running env. This is wrong :( import argparse import glob @@ -12,162 +14,276 @@ import tqdm import tqdm.contrib.concurrent -from helpers import assert_cwd_is_icechunk_python, get_commit +from helpers import ( + assert_cwd_is_icechunk_python, + get_coiled_kwargs, + get_full_commit, + setup_logger, +) + +logger = setup_logger() PIP_OPTIONS = "--disable-pip-version-check -q" +PYTEST_OPTIONS = "-v --durations 10 --rootdir=benchmarks --tb=line" TMP = tempfile.gettempdir() CURRENTDIR = os.getcwd() + assert_cwd_is_icechunk_python() def get_benchmark_deps(filepath: str) -> str: + """needed since + 1. benchmark deps may have changed in the meantime. + 2. we can't specify optional extras when installing from a subdirectory + https://pip.pypa.io/en/stable/topics/vcs-support/#url-fragments + """ with open(filepath, mode="rb") as f: data = tomllib.load(f) - return " ".join(data["project"]["optional-dependencies"].get("benchmark", "")) + return ( + " ".join(data["project"]["optional-dependencies"].get("benchmark", "")) + + " " + + " ".join(data["project"]["optional-dependencies"].get("test", "")) + ) class Runner: - activate: str = "source .venv/bin/activate" + bench_store_dir = None - def __init__(self, ref: str): + def __init__(self, *, ref: str, where: str) -> None: self.ref = ref - self.commit = get_commit(ref) - suffix = f"{self.ref}_{self.commit}" - self.base = f"{TMP}/icechunk-bench-{suffix}" - self.cwd = f"{TMP}/icechunk-bench-{suffix}/icechunk" - self.pycwd = f"{TMP}/icechunk-bench-{suffix}/icechunk/icechunk-python" + self.full_commit = get_full_commit(ref) + self.commit = self.full_commit[:8] + self.where = where - def initialize(self) -> None: - ref = self.ref + @property + def pip_github_url(self) -> str: + # optional extras cannot be specified here, "not guaranteed to work" + # https://pip.pypa.io/en/stable/topics/vcs-support/#url-fragments + return f"git+https://github.com/earth-mover/icechunk.git@{self.full_commit}#subdirectory=icechunk-python" - deps = get_benchmark_deps(f"{CURRENTDIR}/pyproject.toml") - kwargs = dict(cwd=self.cwd, check=True) - pykwargs = dict(cwd=self.pycwd, check=True) + @property + def prefix(self) -> str: + # try: + # return f"v{ic.spec_version():02d}" + # except AttributeError: + return f"{self.ref}_{self.commit}" - print(f"checking out {ref} to {self.base}") - subprocess.run(["mkdir", self.base], check=False) - # TODO: copy the local one instead to save time? - subprocess.run( - ["git", "clone", "-q", "git@github.com:earth-mover/icechunk"], - cwd=self.base, - check=False, - ) - subprocess.run(["git", "checkout", "-q", ref], **kwargs) - subprocess.run(["python3", "-m", "venv", ".venv"], cwd=self.pycwd, check=True) - subprocess.run( - [ - "maturin", - "build", - "-q", - "--release", - "--out", - "dist", - "--find-interpreter", - ], - **pykwargs, - ) - # This is quite ugly but is the only way I can figure out to force pip - # to install the wheel we just built - subprocess.run( - f"{self.activate} " - f"&& pip install {PIP_OPTIONS} icechunk[test]" - f"&& pip install {PIP_OPTIONS} {deps}" - f"&& pip uninstall -y icechunk" - f"&& pip install -v icechunk --no-index --find-links=dist", - shell=True, - **pykwargs, - ) + @property + def ref_commit(self) -> str: + return f"{self.ref}_{self.commit}" - def setup(self, force: bool): - print(f"setup_benchmarks for {self.ref} / {self.commit}") - subprocess.run(["cp", "-r", "benchmarks", f"{self.pycwd}"], check=True) + def sync_benchmarks_folder(self) -> None: + """Sync the benchmarks folder over to the cwd.""" + raise NotImplementedError + + def execute(cmd: str) -> None: + """Execute a command""" + raise NotImplementedError + + def initialize(self) -> None: + """Builds virtual envs etc.""" + self.sync_benchmarks_folder() + + def setup(self, *, force: bool): + """Creates datasets for read benchmarks.""" + logger.info(f"setup_benchmarks for {self.ref} / {self.commit}") cmd = ( - f"pytest -q --durations 10 -nauto " - "-m setup_benchmarks --force-setup={force} " - f"--icechunk-prefix=benchmarks/{self.ref}_{self.commit}/ " + f"pytest {PYTEST_OPTIONS} -nauto " + f"-m setup_benchmarks --force-setup={force} " + f"--where={self.where} " + f"--icechunk-prefix=benchmarks/{self.prefix}/ " "benchmarks/" ) - subprocess.run( - f"{self.activate} && {cmd}", cwd=self.pycwd, check=True, shell=True - ) + logger.info(cmd) + self.execute(cmd, check=True) def run(self, *, pytest_extra: str = "") -> None: - print(f"running benchmarks for {self.ref} / {self.commit}") - - subprocess.run(["cp", "-r", "benchmarks", f"{self.pycwd}"], check=True) + """Actually runs the benchmarks.""" + logger.info(f"running benchmarks for {self.ref} / {self.commit}") # shorten the name so `pytest-benchmark compare` is readable - clean_ref = ref.removeprefix("icechunk-v0.1.0-alph") + clean_ref = self.ref.removeprefix("icechunk-v0.1.0-alph") + assert self.bench_store_dir is not None # Note: .benchmarks is the default location for pytest-benchmark cmd = ( - f"pytest -q --durations 10 " - f"--benchmark-storage={CURRENTDIR}/.benchmarks " - f"--benchmark-save={clean_ref}_{self.commit} " - f"--icechunk-prefix=benchmarks/{ref}_{self.commit}/ " - f"{pytest_extra} " + f"pytest {pytest_extra} " + f"--benchmark-storage={self.bench_store_dir}/.benchmarks " + f"--benchmark-save={clean_ref}_{self.commit}_{self.where} " + f"--where={self.where} " + f"--icechunk-prefix=benchmarks/{self.prefix}/ " + f"{PYTEST_OPTIONS} " "benchmarks/" ) print(cmd) + self.execute(cmd, check=False) + + +class LocalRunner(Runner): + activate: str = "source .venv/bin/activate" + bench_store_dir = CURRENTDIR + + def __init__(self, *, ref: str, where: str): + super().__init__(ref=ref, where=where) + suffix = self.ref_commit + self.base = f"{TMP}/icechunk-bench-{suffix}" + self.cwd = f"{TMP}/icechunk-bench-{suffix}/icechunk" + self.pycwd = f"{TMP}/icechunk-bench-{suffix}/icechunk/icechunk-python" + + def sync_benchmarks_folder(self): + subprocess.run(["cp", "-r", "benchmarks", f"{self.pycwd}"], check=True) + + def execute(self, cmd: str, **kwargs) -> None: # don't stop if benchmarks fail + subprocess.run(f"{self.activate} && {cmd}", cwd=self.pycwd, shell=True, **kwargs) + + def initialize(self) -> None: + logger.info(f"Running initialize for {self.ref} in {self.base}") + + deps = get_benchmark_deps(f"{CURRENTDIR}/pyproject.toml") + subprocess.run(["mkdir", "-p", self.pycwd], check=False) + subprocess.run(["python3", "-m", "venv", ".venv"], cwd=self.pycwd, check=True) + cmd = f"pip install {PIP_OPTIONS} {self.pip_github_url} {deps}" + self.execute(cmd, check=True) + super().initialize() + + def run(self, *, pytest_extra: str = "") -> None: + super().run(pytest_extra=pytest_extra) + if len(refs) > 1: + files = sorted( + glob.glob("./.benchmarks/**/*.json", recursive=True), + key=os.path.getmtime, + reverse=True, + )[-len(refs) :] + # TODO: Use `just` here when we figure that out. + subprocess.run( + [ + "pytest-benchmark", + "compare", + "--group=group,func,param", + "--sort=fullname", + "--columns=median", + "--name=normal", + *files, + ] + ) + + +class CoiledRunner(Runner): + bench_store_dir = "." + + def get_coiled_run_args(self) -> tuple[str]: + ckwargs = self.get_coiled_kwargs() + return ( + "coiled", + "run", + "--interactive", + "--name", + f"icebench-{self.commit}", # cluster name + "--keepalive", + "10m", + f"--workspace={ckwargs['workspace']}", # cloud + f"--vm-type={ckwargs['vm_type']}", + f"--software={ckwargs['software']}", + f"--region={ckwargs['region']}", + ) + + def get_coiled_kwargs(self): + COILED_SOFTWARE = { + "icechunk-v0.1.0-alpha.1": "icechunk-alpha-release", + "icechunk-v0.1.0-alpha.12": "icechunk-alpha-12", + } + + # using the default region here + kwargs = get_coiled_kwargs(store=self.where) + kwargs["software"] = COILED_SOFTWARE.get( + self.ref, f"icechunk-bench-{self.commit}" + ) + return kwargs + + def initialize(self) -> None: + import coiled + + deps = get_benchmark_deps(f"{CURRENTDIR}/pyproject.toml").split(" ") + + ckwargs = self.get_coiled_kwargs() + # repeated calls are a no-op! + coiled.create_software_environment( + name=ckwargs["software"], + workspace=ckwargs["workspace"], + conda={ + "channels": ["conda-forge"], + "dependencies": ["rust", "python=3.12", "pip"], + }, + pip=[self.pip_github_url, "coiled", *deps], + ) + super().initialize() + + def execute(self, cmd, **kwargs) -> None: + subprocess.run([*self.get_coiled_run_args(), cmd], **kwargs) + + def sync_benchmarks_folder(self) -> None: subprocess.run( - f"{self.activate} && {cmd}", shell=True, cwd=self.pycwd, check=False + [ + *self.get_coiled_run_args(), + "--file", + "benchmarks/", + "ls -alh ./.benchmarks/", + ], + check=True, ) + def run(self, *, pytest_extra: str = "") -> None: + super().run(pytest_extra=pytest_extra) + # This prints to screen but we could upload to a bucket in here. + self.execute("sh benchmarks/most_recent.sh") + -def init_for_ref(ref: str, force_setup: bool): - runner = Runner(ref) +def init_for_ref(runner: Runner): runner.initialize() - runner.setup(force=force_setup) if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("refs", help="refs to run benchmarks for", nargs="+") parser.add_argument("--pytest", help="passed to pytest", default="") + parser.add_argument("--where", help="where to run? [local|s3|gcs]", default="local") + parser.add_argument( + "--skip-setup", + help="skip setup step, useful for benchmarks that don't need data", + action="store_true", + default=False, + ) parser.add_argument( "--force-setup", help="forced recreation of datasets?", type=bool, default=False ) args = parser.parse_args() refs = args.refs - # refs = [ - # # "0.1.0-alpha.2-python", # first release - # "icechunk-v0.1.0-alpha.8", - # # concurrent chunk fetch - # # list_dir reimplemented - # # "icechunk-v0.1.0-alpha.10", - # # metadata file download performance - # # "icechunk-v0.1.0-alpha.11", - # # concurrently download bytes - # "icechunk-v0.1.0-alpha.12", - # # "main", - # ] - - tqdm.contrib.concurrent.process_map( - partial(init_for_ref, force_setup=args.force_setup), refs - ) - # For debugging - # for ref in refs: - # init_for_ref(ref, force_setup=args.force_setup) - for ref in tqdm.tqdm(refs): - runner = Runner(ref) + if args.where == "local": + runner_cls = LocalRunner + else: + runner_cls = CoiledRunner + + runners = tuple(runner_cls(ref=ref, where=args.where) for ref in refs) + + # we can only initialize in parallel since the two refs may have the same spec version. + tqdm.contrib.concurrent.process_map(partial(init_for_ref), runners) + + if not args.skip_setup: + for runner in runners: + runner.setup(force=args.force_setup) + + for runner in tqdm.tqdm(runners): runner.run(pytest_extra=args.pytest) - if len(refs) > 1: - files = sorted(glob.glob("./.benchmarks/**/*.json", recursive=True))[-len(refs) :] - # TODO: Use `just` here when we figure that out. - subprocess.run( - [ - "pytest-benchmark", - "compare", - "--group=group,func,param", - "--sort=fullname", - "--columns=median", - "--name=normal", - *files, - ] - ) + +# Compare wish-list: +# 1. skip differences < X% +# 2. groupby +# 3. better names in summary table +# 4. Compare across object stores; same object store & compare across versions +# 5. Compare icechunk vs plain Zarr diff --git a/icechunk-python/benchmarks/test_benchmark_reads.py b/icechunk-python/benchmarks/test_benchmark_reads.py index 2499f2b6..b1ba9e6e 100644 --- a/icechunk-python/benchmarks/test_benchmark_reads.py +++ b/icechunk-python/benchmarks/test_benchmark_reads.py @@ -40,12 +40,19 @@ def test_time_create_store(synth_dataset: Dataset, benchmark) -> None: def test_time_getsize_key(synth_dataset: Dataset, benchmark) -> None: from zarr.core.sync import sync + if synth_dataset.load_variables is None: + pytest.skip() + store = synth_dataset.store @benchmark def fn(): for array in synth_dataset.load_variables: - key = f"{synth_dataset.group or ''}/{array}/zarr.json" + if group := synth_dataset.group is not None: + prefix = f"{group}/" + else: + prefix = "" + key = f"{prefix}{array}/zarr.json" sync(store.getsize(key)) @@ -98,6 +105,8 @@ def fn(): @pytest.mark.benchmark(group="xarray-read", min_rounds=2) def test_time_xarray_read_chunks(synth_dataset: Dataset, benchmark) -> None: """128MB vs 8MB chunks. should see a difference.""" + if synth_dataset.load_variables is None: + pytest.skip() # TODO: switch out concurrency "ideal_request_size" ds = xr.open_zarr( synth_dataset.store, group=synth_dataset.group, chunks=None, consolidated=False diff --git a/icechunk-python/benchmarks/test_benchmark_writes.py b/icechunk-python/benchmarks/test_benchmark_writes.py index 5954b1e3..c2881448 100644 --- a/icechunk-python/benchmarks/test_benchmark_writes.py +++ b/icechunk-python/benchmarks/test_benchmark_writes.py @@ -8,7 +8,7 @@ from benchmarks.tasks import Executor, write from icechunk import Repository, RepositoryConfig, local_filesystem_storage -NUM_CHUNK_REFS = 20_000 +NUM_CHUNK_REFS = 10_000 NUM_VIRTUAL_CHUNK_REFS = 100_000 diff --git a/icechunk-python/examples/mpwrite.py b/icechunk-python/examples/mpwrite.py new file mode 100644 index 00000000..a86f8a3a --- /dev/null +++ b/icechunk-python/examples/mpwrite.py @@ -0,0 +1,48 @@ +# An example of using multiprocessing to write to an Icechunk dataset + +import tempfile +from concurrent.futures import ProcessPoolExecutor + +import xarray as xr +from icechunk import Repository, Session, local_filesystem_storage +from icechunk.distributed import merge_sessions + + +def write_timestamp(*, itime: int, session: Session) -> Session: + # pass a list to isel to preserve the time dimension + ds = xr.tutorial.open_dataset("rasm").isel(time=[itime]) + # region="auto" tells Xarray to infer which "region" of the output arrays to write to. + ds.to_zarr(session.store, region="auto", consolidated=False) + return session + + +if __name__ == "__main__": + ds = xr.tutorial.open_dataset("rasm").isel(time=slice(24)) + repo = Repository.create(local_filesystem_storage(tempfile.mkdtemp())) + session = repo.writable_session("main") + + chunks = {1 if dim == "time" else ds.sizes[dim] for dim in ds.Tair.dims} + ds.to_zarr( + session.store, compute=False, encoding={"Tair": {"chunks": chunks}}, mode="w" + ) + # this commit is optional, but may be useful in your workflow + session.commit("initialize store") + + session = repo.writable_session("main") + with ProcessPoolExecutor() as executor: + # opt-in to successful pickling of a writable session + with session.allow_pickling(): + # submit the writes + futures = [ + executor.submit(write_timestamp, itime=i, session=session) + for i in range(ds.sizes["time"]) + ] + # grab the Session objects from each individual write task + sessions = [f.result() for f in futures] + + # manually merge the remote sessions in to the local session + session = merge_sessions(session, *sessions) + session.commit("finished writes") + + ondisk = xr.open_zarr(repo.readonly_session("main").store, consolidated=False) + xr.testing.assert_identical(ds, ondisk) diff --git a/icechunk-python/notebooks/demo-dummy-data.ipynb b/icechunk-python/notebooks/demo-dummy-data.ipynb index 9ed89e57..444847dd 100644 --- a/icechunk-python/notebooks/demo-dummy-data.ipynb +++ b/icechunk-python/notebooks/demo-dummy-data.ipynb @@ -335,7 +335,7 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": null, "id": "d904f719-98cf-4f51-8e9a-1631dcb3fcba", "metadata": {}, "outputs": [ @@ -348,7 +348,7 @@ } ], "source": [ - "session = repo.readonly_session(snapshot=first_commit)\n", + "session = repo.readonly_session(snapshot_id=first_commit)\n", "root_group = zarr.open_group(session.store, mode=\"r\")\n", "\n", "try:\n", diff --git a/icechunk-python/notebooks/version-control.ipynb b/icechunk-python/notebooks/version-control.ipynb index 74c44cf0..abde76db 100644 --- a/icechunk-python/notebooks/version-control.ipynb +++ b/icechunk-python/notebooks/version-control.ipynb @@ -242,7 +242,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "id": "e785d9a1-36ec-4207-b334-20e0a68e3ac8", "metadata": {}, "outputs": [ @@ -258,7 +258,7 @@ } ], "source": [ - "session = repo.readonly_session(snapshot=first_commit)\n", + "session = repo.readonly_session(snapshot_id=first_commit)\n", "root_group = zarr.open_group(store=session.store, mode=\"r\")\n", "dict(root_group.attrs)" ] diff --git a/icechunk-python/pyproject.toml b/icechunk-python/pyproject.toml index 38740a5d..2ffdeb9f 100644 --- a/icechunk-python/pyproject.toml +++ b/icechunk-python/pyproject.toml @@ -4,6 +4,7 @@ build-backend = "maturin" [project] name = "icechunk" +description = "Icechunk Python" requires-python = ">=3.11" classifiers = [ "Programming Language :: Rust", @@ -13,18 +14,12 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", ] -license = { text = "Apache-2.0" } +readme = "../README.md" +license = "Apache-2.0" dynamic = ["version"] +authors = [{ name = "Earthmover", email = "info@earthmover.io" }] -dependencies = ["zarr>=3"] - -[tool.poetry] -name = "icechunk" -version = "0.1.0" -description = "Icechunk Python" -authors = ["Earthmover "] -readme = "../README.md" -packages = [{ include = "icechunk", from = "python" }] +dependencies = ["zarr>=3,!=3.0.3"] [project.optional-dependencies] test = [ @@ -43,14 +38,18 @@ test = [ "hypothesis", "pandas-stubs", "boto3-stubs[s3]", + "termcolor", ] benchmark = [ "pytest-benchmark[histogram]", "pytest-xdist", "s3fs", + "gcsfs", "h5netcdf", "pooch", "tqdm", + "humanize", + "platformdirs", ] [tool.maturin] @@ -78,10 +77,6 @@ filterwarnings = [ "ignore:Unused async fixture loop scope:pytest.PytestWarning", ] -[tool.pyright] -venvPath = "." -venv = ".venv" - [tool.mypy] python_version = "3.11" strict = true diff --git a/icechunk-python/python/icechunk/__init__.py b/icechunk-python/python/icechunk/__init__.py index 8a1b288e..449bf768 100644 --- a/icechunk-python/python/icechunk/__init__.py +++ b/icechunk-python/python/icechunk/__init__.py @@ -13,6 +13,8 @@ ConflictSolver, ConflictType, Credentials, + Diff, + GcsBearerCredential, GcsCredentials, GcsStaticCredentials, GCSummary, @@ -32,7 +34,10 @@ StorageSettings, VersionSelection, VirtualChunkContainer, + VirtualChunkSpec, __version__, + initialize_logs, + spec_version, ) from icechunk.credentials import ( AnyAzureCredential, @@ -47,6 +52,7 @@ containers_credentials, gcs_credentials, gcs_from_env_credentials, + gcs_refreshable_credentials, gcs_static_credentials, s3_anonymous_credentials, s3_credentials, @@ -89,7 +95,9 @@ "ConflictSolver", "ConflictType", "Credentials", + "Diff", "GCSummary", + "GcsBearerCredential", "GcsCredentials", "GcsStaticCredentials", "IcechunkError", @@ -112,6 +120,7 @@ "StorageSettings", "VersionSelection", "VirtualChunkContainer", + "VirtualChunkSpec", "__version__", "azure_credentials", "azure_from_env_credentials", @@ -120,17 +129,37 @@ "containers_credentials", "gcs_credentials", "gcs_from_env_credentials", + "gcs_refreshable_credentials", "gcs_static_credentials", "gcs_storage", "in_memory_storage", + "initialize_logs", "local_filesystem_storage", + "print_debug_info", "s3_anonymous_credentials", "s3_credentials", - "s3_credentials", "s3_from_env_credentials", "s3_refreshable_credentials", "s3_static_credentials", "s3_storage", "s3_store", + "spec_version", "tigris_storage", ] + + +def print_debug_info() -> None: + import platform + from importlib import import_module + + print(f"platform: {platform.platform()}") + print(f"python: {platform.python_version()}") + print(f"icechunk: {__version__}") + for package in ["zarr", "numcodecs", "xarray", "virtualizarr"]: + try: + print(f"{package}: {import_module(package).__version__}") + except ModuleNotFoundError: + continue + + +initialize_logs() diff --git a/icechunk-python/python/icechunk/_icechunk_python.pyi b/icechunk-python/python/icechunk/_icechunk_python.pyi index 794d7059..0b926e31 100644 --- a/icechunk-python/python/icechunk/_icechunk_python.pyi +++ b/icechunk-python/python/icechunk/_icechunk_python.pyi @@ -5,13 +5,28 @@ from enum import Enum from typing import Any class S3Options: + """Options for accessing an S3-compatible storage backend""" def __init__( self, region: str | None = None, endpoint_url: str | None = None, allow_http: bool = False, anonymous: bool = False, - ) -> None: ... + ) -> None: + """ + Create a new `S3Options` object + + Parameters + ---------- + region: str | None + Optional, the region to use for the storage backend. + endpoint_url: str | None + Optional, the endpoint URL to use for the storage backend. + allow_http: bool + Whether to allow HTTP requests to the storage backend. + anonymous: bool + Whether to use anonymous credentials to the storage backend. When `True`, the s3 requests will not be signed. + """ class ObjectStoreConfig: class InMemory: @@ -46,37 +61,173 @@ AnyObjectStoreConfig = ( ) class VirtualChunkContainer: + """A virtual chunk container is a configuration that allows Icechunk to read virtual references from a storage backend. + + Attributes + ---------- + name: str + The name of the virtual chunk container. + url_prefix: str + The prefix of urls that will use this containers configuration for reading virtual references. + store: ObjectStoreConfig + The storage backend to use for the virtual chunk container. + """ + name: str url_prefix: str store: ObjectStoreConfig - def __init__(self, name: str, url_prefix: str, store: AnyObjectStoreConfig): ... + def __init__(self, name: str, url_prefix: str, store: AnyObjectStoreConfig): + """ + Create a new `VirtualChunkContainer` object + + Parameters + ---------- + name: str + The name of the virtual chunk container. + url_prefix: str + The prefix of urls that will use this containers configuration for reading virtual references. + store: ObjectStoreConfig + The storage backend to use for the virtual chunk container. + """ + +class VirtualChunkSpec: + """The specification for a virtual chunk reference.""" + @property + def index(self) -> list[int]: + """The chunk index, in chunk coordinates space""" + ... + @property + def location(self) -> str: + """The URL to the virtual chunk data, something like 's3://bucket/foo.nc'""" + ... + @property + def offset(self) -> int: + """The chunk offset within the pointed object, in bytes""" + ... + @property + def length(self) -> int: + """The length of the chunk in bytes""" + ... + @property + def etag_checksum(self) -> str | None: + """Optional object store e-tag for the containing object. + + Icechunk will refuse to serve data from this chunk if the etag has changed. + """ + ... + @property + def last_updated_at_checksum(self) -> datetime.datetime | None: + """Optional timestamp for the containing object. + + Icechunk will refuse to serve data from this chunk if it has been modified in object store after this time. + """ + ... + + def __init__( + self, + index: list[int], + location: str, + offset: int, + length: int, + etag_checksum: str | None = None, + last_updated_at_checksum: datetime.datetime | None = None, + ) -> None: ... class CompressionAlgorithm(Enum): - """Enum for selecting the compression algorithm used by Icechunk to write its metadata files""" + """Enum for selecting the compression algorithm used by Icechunk to write its metadata files + + Attributes + ---------- + Zstd: int + The Zstd compression algorithm. + """ Zstd = 0 def __init__(self) -> None: ... @staticmethod - def default() -> CompressionAlgorithm: ... + def default() -> CompressionAlgorithm: + """ + The default compression algorithm used by Icechunk to write its metadata files. + + Returns + ------- + CompressionAlgorithm + The default compression algorithm. + """ + ... class CompressionConfig: """Configuration for how Icechunk compresses its metadata files""" def __init__( self, algorithm: CompressionAlgorithm | None = None, level: int | None = None - ) -> None: ... + ) -> None: + """ + Create a new `CompressionConfig` object + + Parameters + ---------- + algorithm: CompressionAlgorithm | None + The compression algorithm to use. + level: int | None + The compression level to use. + """ + ... @property - def algorithm(self) -> CompressionAlgorithm | None: ... + def algorithm(self) -> CompressionAlgorithm | None: + """ + The compression algorithm used by Icechunk to write its metadata files. + + Returns + ------- + CompressionAlgorithm | None + The compression algorithm used by Icechunk to write its metadata files. + """ + ... @algorithm.setter - def algorithm(self, value: CompressionAlgorithm | None) -> None: ... + def algorithm(self, value: CompressionAlgorithm | None) -> None: + """ + Set the compression algorithm used by Icechunk to write its metadata files. + + Parameters + ---------- + value: CompressionAlgorithm | None + The compression algorithm to use. + """ + ... @property - def level(self) -> int | None: ... + def level(self) -> int | None: + """ + The compression level used by Icechunk to write its metadata files. + + Returns + ------- + int | None + The compression level used by Icechunk to write its metadata files. + """ + ... @level.setter - def level(self, value: int | None) -> None: ... + def level(self, value: int | None) -> None: + """ + Set the compression level used by Icechunk to write its metadata files. + + Parameters + ---------- + value: int | None + The compression level to use. + """ + ... @staticmethod - def default() -> CompressionConfig: ... + def default() -> CompressionConfig: + """ + The default compression configuration used by Icechunk to write its metadata files. + + Returns + ------- + CompressionConfig + """ class CachingConfig: """Configuration for how Icechunk caches its metadata files""" @@ -88,29 +239,133 @@ class CachingConfig: num_transaction_changes: int | None = None, num_bytes_attributes: int | None = None, num_bytes_chunks: int | None = None, - ) -> None: ... + ) -> None: + """ + Create a new `CachingConfig` object + + Parameters + ---------- + num_snapshot_nodes: int | None + The number of snapshot nodes to cache. + num_chunk_refs: int | None + The number of chunk references to cache. + num_transaction_changes: int | None + The number of transaction changes to cache. + num_bytes_attributes: int | None + The number of bytes of attributes to cache. + num_bytes_chunks: int | None + The number of bytes of chunks to cache. + """ @property - def num_snapshot_nodes(self) -> int | None: ... + def num_snapshot_nodes(self) -> int | None: + """ + The number of snapshot nodes to cache. + + Returns + ------- + int | None + The number of snapshot nodes to cache. + """ + ... @num_snapshot_nodes.setter - def num_snapshot_nodes(self, value: int | None) -> None: ... + def num_snapshot_nodes(self, value: int | None) -> None: + """ + Set the number of snapshot nodes to cache. + + Parameters + ---------- + value: int | None + The number of snapshot nodes to cache. + """ + ... @property - def num_chunk_refs(self) -> int | None: ... + def num_chunk_refs(self) -> int | None: + """ + The number of chunk references to cache. + + Returns + ------- + int | None + The number of chunk references to cache. + """ + ... @num_chunk_refs.setter - def num_chunk_refs(self, value: int | None) -> None: ... + def num_chunk_refs(self, value: int | None) -> None: + """ + Set the number of chunk references to cache. + + Parameters + ---------- + value: int | None + The number of chunk references to cache. + """ + ... @property - def num_transaction_changes(self) -> int | None: ... + def num_transaction_changes(self) -> int | None: + """ + The number of transaction changes to cache. + + Returns + ------- + int | None + The number of transaction changes to cache. + """ + ... @num_transaction_changes.setter - def num_transaction_changes(self, value: int | None) -> None: ... + def num_transaction_changes(self, value: int | None) -> None: + """ + Set the number of transaction changes to cache. + + Parameters + ---------- + value: int | None + The number of transaction changes to cache. + """ + ... @property - def num_bytes_attributes(self) -> int | None: ... + def num_bytes_attributes(self) -> int | None: + """ + The number of bytes of attributes to cache. + + Returns + ------- + int | None + The number of bytes of attributes to cache. + """ + ... @num_bytes_attributes.setter - def num_bytes_attributes(self, value: int | None) -> None: ... + def num_bytes_attributes(self, value: int | None) -> None: + """ + Set the number of bytes of attributes to cache. + + Parameters + ---------- + value: int | None + The number of bytes of attributes to cache. + """ + ... @property - def num_bytes_chunks(self) -> int | None: ... + def num_bytes_chunks(self) -> int | None: + """ + The number of bytes of chunks to cache. + + Returns + ------- + int | None + The number of bytes of chunks to cache. + """ + ... @num_bytes_chunks.setter - def num_bytes_chunks(self, value: int | None) -> None: ... - @staticmethod - def default() -> CachingConfig: ... + def num_bytes_chunks(self, value: int | None) -> None: + """ + Set the number of bytes of chunks to cache. + + Parameters + ---------- + value: int | None + The number of bytes of chunks to cache. + """ + ... class ManifestPreloadCondition: """Configuration for conditions under which manifests will preload on session creation""" @@ -167,15 +422,62 @@ class ManifestPreloadConfig: self, max_total_refs: int | None = None, preload_if: ManifestPreloadCondition | None = None, - ) -> None: ... + ) -> None: + """ + Create a new `ManifestPreloadConfig` object + + Parameters + ---------- + max_total_refs: int | None + The maximum number of references to preload. + preload_if: ManifestPreloadCondition | None + The condition under which manifests will be preloaded. + """ + ... @property - def max_total_refs(self) -> int | None: ... + def max_total_refs(self) -> int | None: + """ + The maximum number of references to preload. + + Returns + ------- + int | None + The maximum number of references to preload. + """ + ... @max_total_refs.setter - def max_total_refs(self, value: int | None) -> None: ... + def max_total_refs(self, value: int | None) -> None: + """ + Set the maximum number of references to preload. + + Parameters + ---------- + value: int | None + The maximum number of references to preload. + """ + ... @property - def preload_if(self) -> ManifestPreloadCondition | None: ... + def preload_if(self) -> ManifestPreloadCondition | None: + """ + The condition under which manifests will be preloaded. + + Returns + ------- + ManifestPreloadCondition | None + The condition under which manifests will be preloaded. + """ + ... @preload_if.setter - def preload_if(self, value: ManifestPreloadCondition | None) -> None: ... + def preload_if(self, value: ManifestPreloadCondition | None) -> None: + """ + Set the condition under which manifests will be preloaded. + + Parameters + ---------- + value: ManifestPreloadCondition | None + The condition under which manifests will be preloaded. + """ + ... class ManifestConfig: """Configuration for how Icechunk manifests""" @@ -183,11 +485,38 @@ class ManifestConfig: def __init__( self, preload: ManifestPreloadConfig | None = None, - ) -> None: ... + ) -> None: + """ + Create a new `ManifestConfig` object + + Parameters + ---------- + preload: ManifestPreloadConfig | None + The configuration for how Icechunk manifests will be preloaded. + """ + ... @property - def preload(self) -> ManifestPreloadConfig | None: ... + def preload(self) -> ManifestPreloadConfig | None: + """ + The configuration for how Icechunk manifests will be preloaded. + + Returns + ------- + ManifestPreloadConfig | None + The configuration for how Icechunk manifests will be preloaded. + """ + ... @preload.setter - def preload(self, value: ManifestPreloadConfig | None) -> None: ... + def preload(self, value: ManifestPreloadConfig | None) -> None: + """ + Set the configuration for how Icechunk manifests will be preloaded. + + Parameters + ---------- + value: ManifestPreloadConfig | None + The configuration for how Icechunk manifests will be preloaded. + """ + ... class StorageConcurrencySettings: """Configuration for how Icechunk uses its Storage instance""" @@ -196,24 +525,120 @@ class StorageConcurrencySettings: self, max_concurrent_requests_for_object: int | None = None, ideal_concurrent_request_size: int | None = None, - ) -> None: ... + ) -> None: + """ + Create a new `StorageConcurrencySettings` object + + Parameters + ---------- + max_concurrent_requests_for_object: int | None + The maximum number of concurrent requests for an object. + ideal_concurrent_request_size: int | None + The ideal concurrent request size. + """ + ... @property - def max_concurrent_requests_for_object(self) -> int | None: ... + def max_concurrent_requests_for_object(self) -> int | None: + """ + The maximum number of concurrent requests for an object. + + Returns + ------- + int | None + The maximum number of concurrent requests for an object. + """ + ... @max_concurrent_requests_for_object.setter - def max_concurrent_requests_for_object(self, value: int | None) -> None: ... + def max_concurrent_requests_for_object(self, value: int | None) -> None: + """ + Set the maximum number of concurrent requests for an object. + + Parameters + ---------- + value: int | None + The maximum number of concurrent requests for an object. + """ + ... @property - def ideal_concurrent_request_size(self) -> int | None: ... + def ideal_concurrent_request_size(self) -> int | None: + """ + The ideal concurrent request size. + + Returns + ------- + int | None + The ideal concurrent request size. + """ + ... @ideal_concurrent_request_size.setter - def ideal_concurrent_request_size(self, value: int | None) -> None: ... + def ideal_concurrent_request_size(self, value: int | None) -> None: + """ + Set the ideal concurrent request size. + + Parameters + ---------- + value: int | None + The ideal concurrent request size. + """ + ... class StorageSettings: """Configuration for how Icechunk uses its Storage instance""" - def __init__(self, concurrency: StorageConcurrencySettings | None = None) -> None: ... + def __init__( + self, + concurrency: StorageConcurrencySettings | None = None, + unsafe_use_conditional_create: bool | None = None, + unsafe_use_conditional_update: bool | None = None, + unsafe_use_metadata: bool | None = None, + ) -> None: + """ + Create a new `StorageSettings` object + + Parameters + ---------- + concurrency: StorageConcurrencySettings | None + The configuration for how Icechunk uses its Storage instance. + + unsafe_use_conditional_update: bool | None + If set to False, Icechunk loses some of its consistency guarantees. + This is only useful in object stores that don't support the feature. + Use it at your own risk. + + unsafe_use_conditional_create: bool | None + If set to False, Icechunk loses some of its consistency guarantees. + This is only useful in object stores that don't support the feature. + Use at your own risk. + + unsafe_use_metadata: bool | None + Don't write metadata fields in Icechunk files. + This is only useful in object stores that don't support the feature. + Use at your own risk. + """ + ... + @property + def concurrency(self) -> StorageConcurrencySettings | None: + """ + The configuration for how much concurrency Icechunk store uses + + Returns + ------- + StorageConcurrencySettings | None + The configuration for how Icechunk uses its Storage instance. + """ + + @property + def unsafe_use_conditional_update(self) -> bool | None: + """True if Icechunk will use conditional PUT operations for updates in the object store""" + ... @property - def concurrency(self) -> StorageConcurrencySettings | None: ... - @concurrency.setter - def concurrency(self, value: StorageConcurrencySettings | None) -> None: ... + def unsafe_use_conditional_create(self) -> bool | None: + """True if Icechunk will use conditional PUT operations for creation in the object store""" + ... + @property + def unsafe_use_metadata(self) -> bool | None: + """True if Icechunk will write object metadata in the object store""" + ... class RepositoryConfig: """Configuration for an Icechunk repository""" @@ -221,61 +646,279 @@ class RepositoryConfig: def __init__( self, inline_chunk_threshold_bytes: int | None = None, - unsafe_overwrite_refs: bool | None = None, get_partial_values_concurrency: int | None = None, compression: CompressionConfig | None = None, caching: CachingConfig | None = None, storage: StorageSettings | None = None, virtual_chunk_containers: dict[str, VirtualChunkContainer] | None = None, manifest: ManifestConfig | None = None, - ) -> None: ... + ) -> None: + """ + Create a new `RepositoryConfig` object + + Parameters + ---------- + inline_chunk_threshold_bytes: int | None + The maximum size of a chunk that will be stored inline in the repository. + get_partial_values_concurrency: int | None + The number of concurrent requests to make when getting partial values from storage. + compression: CompressionConfig | None + The compression configuration for the repository. + caching: CachingConfig | None + The caching configuration for the repository. + storage: StorageSettings | None + The storage configuration for the repository. + virtual_chunk_containers: dict[str, VirtualChunkContainer] | None + The virtual chunk containers for the repository. + manifest: ManifestConfig | None + The manifest configuration for the repository. + """ + ... @staticmethod - def default() -> RepositoryConfig: ... + def default() -> RepositoryConfig: + """Create a default repository config instance""" + ... @property - def inline_chunk_threshold_bytes(self) -> int | None: ... + def inline_chunk_threshold_bytes(self) -> int | None: + """ + The maximum size of a chunk that will be stored inline in the repository. Chunks larger than this size will be written to storage. + """ + ... @inline_chunk_threshold_bytes.setter - def inline_chunk_threshold_bytes(self, value: int | None) -> None: ... - @property - def unsafe_overwrite_refs(self) -> bool | None: ... - @unsafe_overwrite_refs.setter - def unsafe_overwrite_refs(self, value: bool | None) -> None: ... + def inline_chunk_threshold_bytes(self, value: int | None) -> None: + """ + Set the maximum size of a chunk that will be stored inline in the repository. Chunks larger than this size will be written to storage. + """ + ... @property - def get_partial_values_concurrency(self) -> int | None: ... + def get_partial_values_concurrency(self) -> int | None: + """ + The number of concurrent requests to make when getting partial values from storage. + + Returns + ------- + int | None + The number of concurrent requests to make when getting partial values from storage. + """ + ... @get_partial_values_concurrency.setter - def get_partial_values_concurrency(self, value: int | None) -> None: ... + def get_partial_values_concurrency(self, value: int | None) -> None: + """ + Set the number of concurrent requests to make when getting partial values from storage. + + Parameters + ---------- + value: int | None + The number of concurrent requests to make when getting partial values from storage. + """ + ... @property - def compression(self) -> CompressionConfig | None: ... + def compression(self) -> CompressionConfig | None: + """ + The compression configuration for the repository. + + Returns + ------- + CompressionConfig | None + The compression configuration for the repository. + """ + ... @compression.setter - def compression(self, value: CompressionConfig | None) -> None: ... + def compression(self, value: CompressionConfig | None) -> None: + """ + Set the compression configuration for the repository. + + Parameters + ---------- + value: CompressionConfig | None + The compression configuration for the repository. + """ + ... @property - def caching(self) -> CachingConfig | None: ... + def caching(self) -> CachingConfig | None: + """ + The caching configuration for the repository. + + Returns + ------- + CachingConfig | None + The caching configuration for the repository. + """ + ... @caching.setter - def caching(self, value: CachingConfig | None) -> None: ... + def caching(self, value: CachingConfig | None) -> None: + """ + Set the caching configuration for the repository. + + Parameters + ---------- + value: CachingConfig | None + The caching configuration for the repository. + """ + ... @property - def storage(self) -> StorageSettings | None: ... + def storage(self) -> StorageSettings | None: + """ + The storage configuration for the repository. + + Returns + ------- + StorageSettings | None + The storage configuration for the repository. + """ + ... @storage.setter - def storage(self, value: StorageSettings | None) -> None: ... + def storage(self, value: StorageSettings | None) -> None: + """ + Set the storage configuration for the repository. + + Parameters + ---------- + value: StorageSettings | None + The storage configuration for the repository. + """ + ... @property - def manifest(self) -> ManifestConfig | None: ... + def manifest(self) -> ManifestConfig | None: + """ + The manifest configuration for the repository. + + Returns + ------- + ManifestConfig | None + The manifest configuration for the repository. + """ + ... @manifest.setter - def manifest(self, value: ManifestConfig | None) -> None: ... + def manifest(self, value: ManifestConfig | None) -> None: + """ + Set the manifest configuration for the repository. + + Parameters + ---------- + value: ManifestConfig | None + The manifest configuration for the repository. + """ + ... + @property + def virtual_chunk_containers(self) -> dict[str, VirtualChunkContainer] | None: + """ + The virtual chunk containers for the repository. + + Returns + ------- + dict[str, VirtualChunkContainer] | None + The virtual chunk containers for the repository. + """ + ... + def get_virtual_chunk_container(self, name: str) -> VirtualChunkContainer | None: + """ + Get the virtual chunk container for the repository associated with the given name. + + Parameters + ---------- + name: str + The name of the virtual chunk container to get. + + Returns + ------- + VirtualChunkContainer | None + The virtual chunk container for the repository associated with the given name. + """ + ... + def set_virtual_chunk_container(self, cont: VirtualChunkContainer) -> None: + """ + Set the virtual chunk container for the repository. + + Parameters + ---------- + cont: VirtualChunkContainer + The virtual chunk container to set. + """ + ... + def clear_virtual_chunk_containers(self) -> None: + """ + Clear all virtual chunk containers from the repository. + """ + ... + +class Diff: + """The result of comparing two snapshots""" + @property + def new_groups(self) -> set[str]: + """ + The groups that were added to the target ref. + """ + ... + @property + def new_arrays(self) -> set[str]: + """ + The arrays that were added to the target ref. + """ + ... + @property + def deleted_groups(self) -> set[str]: + """ + The groups that were deleted in the target ref. + """ + ... + @property + def deleted_arrays(self) -> set[str]: + """ + The arrays that were deleted in the target ref. + """ + ... + @property + def updated_user_attributes(self) -> set[str]: + """ + The nodes that had user attributes updated in the target ref. + """ + ... + @property + def updated_zarr_metadata(self) -> set[str]: + """ + The nodes that had zarr metadata updated in the target ref. + """ + ... @property - def virtual_chunk_containers(self) -> dict[str, VirtualChunkContainer] | None: ... - def get_virtual_chunk_container(self, name: str) -> VirtualChunkContainer | None: ... - def set_virtual_chunk_container(self, cont: VirtualChunkContainer) -> None: ... - def clear_virtual_chunk_containers(self) -> None: ... + def updated_chunks(self) -> dict[str, int]: + """ + The chunks that had data updated in the target ref. + """ + ... class GCSummary: + """Summarizes the results of a garbage collection operation on an icechunk repo""" @property - def chunks_deleted(self) -> int: ... + def chunks_deleted(self) -> int: + """ + How many chunks were deleted. + """ + ... @property - def manifests_deleted(self) -> int: ... + def manifests_deleted(self) -> int: + """ + How many manifests were deleted. + """ + ... @property - def snapshots_deleted(self) -> int: ... + def snapshots_deleted(self) -> int: + """ + How many snapshots were deleted. + """ + ... @property - def attributes_deleted(self) -> int: ... + def attributes_deleted(self) -> int: + """ + How many attributes were deleted. + """ + ... @property - def transaction_logs_deleted(self) -> int: ... + def transaction_logs_deleted(self) -> int: + """ + How many transaction logs were deleted. + """ + ... class PyRepository: @classmethod @@ -304,24 +947,20 @@ class PyRepository: ) -> PyRepository: ... @staticmethod def exists(storage: Storage) -> bool: ... + @classmethod + def from_bytes(cls, data: bytes) -> PyRepository: ... + def as_bytes(self) -> bytes: ... @staticmethod def fetch_config(storage: Storage) -> RepositoryConfig | None: ... def save_config(self) -> None: ... def config(self) -> RepositoryConfig: ... def storage(self) -> Storage: ... - def ancestry( - self, - *, - branch: str | None = None, - tag: str | None = None, - snapshot: str | None = None, - ) -> list[SnapshotInfo]: ... def async_ancestry( self, *, branch: str | None = None, tag: str | None = None, - snapshot: str | None = None, + snapshot_id: str | None = None, ) -> AsyncIterator[SnapshotInfo]: ... def create_branch(self, branch: str, snapshot_id: str) -> None: ... def list_branches(self) -> set[str]: ... @@ -332,15 +971,31 @@ class PyRepository: def create_tag(self, tag: str, snapshot_id: str) -> None: ... def list_tags(self) -> set[str]: ... def lookup_tag(self, tag: str) -> str: ... + def diff( + self, + from_branch: str | None = None, + from_tag: str | None = None, + from_snapshot_id: str | None = None, + to_branch: str | None = None, + to_tag: str | None = None, + to_snapshot_id: str | None = None, + ) -> Diff: ... def readonly_session( self, - *, branch: str | None = None, + *, tag: str | None = None, - snapshot: str | None = None, + snapshot_id: str | None = None, + as_of: datetime.datetime | None = None, ) -> PySession: ... def writable_session(self, branch: str) -> PySession: ... - def expire_snapshots(self, older_than: datetime.datetime) -> set[str]: ... + def expire_snapshots( + self, + older_than: datetime.datetime, + *, + delete_expired_branches: bool = False, + delete_expired_tags: bool = False, + ) -> set[str]: ... def garbage_collect( self, delete_object_older_than: datetime.datetime ) -> GCSummary: ... @@ -358,6 +1013,7 @@ class PySession: def branch(self) -> str | None: ... @property def has_uncommitted_changes(self) -> bool: ... + def status(self) -> Diff: ... def discard_changes(self) -> None: ... def all_virtual_chunk_locations(self) -> list[str]: ... def chunk_coordinates( @@ -403,6 +1059,12 @@ class PyStore: checksum: str | datetime.datetime | None = None, validate_container: bool = False, ) -> None: ... + def set_virtual_refs( + self, + array_path: str, + chunks: list[VirtualChunkSpec], + validate_containers: bool, + ) -> list[tuple[int, ...]] | None: ... async def delete(self, key: str) -> None: ... async def delete_dir(self, prefix: str) -> None: ... @property @@ -416,6 +1078,7 @@ class PyStore: def list_prefix(self, prefix: str) -> PyAsyncStringGenerator: ... def list_dir(self, prefix: str) -> PyAsyncStringGenerator: ... async def getsize(self, key: str) -> int: ... + async def getsize_prefix(self, prefix: str) -> int: ... class PyAsyncStringGenerator(AsyncGenerator[str, None], metaclass=abc.ABCMeta): def __aiter__(self) -> PyAsyncStringGenerator: ... @@ -455,6 +1118,19 @@ class PyAsyncSnapshotGenerator(AsyncGenerator[SnapshotInfo, None], metaclass=abc async def __anext__(self) -> SnapshotInfo: ... class S3StaticCredentials: + """Credentials for an S3 storage backend + + Attributes: + access_key_id: str + The access key ID to use for authentication. + secret_access_key: str + The secret access key to use for authentication. + session_token: str | None + The session token to use for authentication. + expires_after: datetime.datetime | None + Optional, the expiration time of the credentials. + """ + access_key_id: str secret_access_key: str session_token: str | None @@ -466,19 +1142,53 @@ class S3StaticCredentials: secret_access_key: str, session_token: str | None = None, expires_after: datetime.datetime | None = None, - ): ... + ): + """ + Create a new `S3StaticCredentials` object + + Parameters + ---------- + access_key_id: str + The access key ID to use for authentication. + secret_access_key: str + The secret access key to use for authentication. + session_token: str | None + Optional, the session token to use for authentication. + expires_after: datetime.datetime | None + Optional, the expiration time of the credentials. + """ + ... class S3Credentials: + """Credentials for an S3 storage backend""" class FromEnv: + """Uses credentials from environment variables""" def __init__(self) -> None: ... class Anonymous: + """Does not sign requests, useful for public buckets""" def __init__(self) -> None: ... class Static: + """Uses s3 credentials without expiration + + Parameters + ---------- + credentials: S3StaticCredentials + The credentials to use for authentication. + """ def __init__(self, credentials: S3StaticCredentials) -> None: ... class Refreshable: + """Allows for an outside authority to pass in a function that can be used to provide credentials. + + This is useful for credentials that have an expiration time, or are otherwise not known ahead of time. + + Parameters + ---------- + pickled_function: bytes + The pickled function to use to provide credentials. + """ def __init__(self, pickled_function: bytes) -> None: ... AnyS3Credential = ( @@ -488,39 +1198,131 @@ AnyS3Credential = ( | S3Credentials.Refreshable ) +class GcsBearerCredential: + """Credentials for a google cloud storage backend + + This is a bearer token that has an expiration time. + """ + + bearer: str + expires_after: datetime.datetime | None + + def __init__( + self, bearer: str, *, expires_after: datetime.datetime | None = None + ) -> None: + """Create a GcsBearerCredential object + + Parameters + ---------- + bearer: str + The bearer token to use for authentication. + expires_after: datetime.datetime | None + The expiration time of the bearer token. + """ + class GcsStaticCredentials: + """Credentials for a google cloud storage backend""" class ServiceAccount: + """Credentials for a google cloud storage backend using a service account json file + + Parameters + ---------- + path: str + The path to the service account json file. + """ def __init__(self, path: str) -> None: ... class ServiceAccountKey: + """Credentials for a google cloud storage backend using a a serialized service account key + + Parameters + ---------- + key: str + The serialized service account key. + """ def __init__(self, key: str) -> None: ... class ApplicationCredentials: + """Credentials for a google cloud storage backend using application default credentials + + Parameters + ---------- + path: str + The path to the application default credentials (ADC) file. + """ def __init__(self, path: str) -> None: ... + class BearerToken: + """Credentials for a google cloud storage backend using a bearer token + + Parameters + ---------- + token: str + The bearer token to use for authentication. + """ + def __init__(self, token: str) -> None: ... + AnyGcsStaticCredential = ( GcsStaticCredentials.ServiceAccount | GcsStaticCredentials.ServiceAccountKey | GcsStaticCredentials.ApplicationCredentials + | GcsStaticCredentials.BearerToken ) class GcsCredentials: + """Credentials for a google cloud storage backend + + This can be used to authenticate with a google cloud storage backend. + """ class FromEnv: + """Uses credentials from environment variables""" def __init__(self) -> None: ... class Static: + """Uses gcs credentials without expiration""" def __init__(self, credentials: AnyGcsStaticCredential) -> None: ... -AnyGcsCredential = GcsCredentials.FromEnv | GcsCredentials.Static + class Refreshable: + """Allows for an outside authority to pass in a function that can be used to provide credentials. + + This is useful for credentials that have an expiration time, or are otherwise not known ahead of time. + """ + def __init__(self, pickled_function: bytes) -> None: ... + +AnyGcsCredential = ( + GcsCredentials.FromEnv | GcsCredentials.Static | GcsCredentials.Refreshable +) class AzureStaticCredentials: + """Credentials for an azure storage backend""" class AccessKey: + """Credentials for an azure storage backend using an access key + + Parameters + ---------- + key: str + The access key to use for authentication. + """ def __init__(self, key: str) -> None: ... class SasToken: + """Credentials for an azure storage backend using a shared access signature token + + Parameters + ---------- + token: str + The shared access signature token to use for authentication. + """ def __init__(self, token: str) -> None: ... class BearerToken: + """Credentials for an azure storage backend using a bearer token + + Parameters + ---------- + token: str + The bearer token to use for authentication. + """ def __init__(self, token: str) -> None: ... AnyAzureStaticCredential = ( @@ -530,10 +1332,16 @@ AnyAzureStaticCredential = ( ) class AzureCredentials: + """Credentials for an azure storage backend + + This can be used to authenticate with an azure storage backend. + """ class FromEnv: + """Uses credentials from environment variables""" def __init__(self) -> None: ... class Static: + """Uses azure credentials without expiration""" def __init__(self, credentials: AnyAzureStaticCredential) -> None: ... AnyAzureCredential = AzureCredentials.FromEnv | AzureCredentials.Static @@ -575,6 +1383,14 @@ class Storage: credentials: AnyS3Credential | None = None, ) -> Storage: ... @classmethod + def new_s3_object_store( + cls, + config: S3Options, + bucket: str, + prefix: str | None, + credentials: AnyS3Credential | None = None, + ) -> Storage: ... + @classmethod def new_tigris( cls, config: S3Options, @@ -598,16 +1414,28 @@ class Storage: @classmethod def new_azure_blob( cls, + account: str, container: str, prefix: str, credentials: AnyAzureCredential | None = None, *, config: dict[str, str] | None = None, ) -> Storage: ... + def __repr__(self) -> str: ... def default_settings(self) -> StorageSettings: ... class VersionSelection(Enum): - """Enum for selecting the which version of a conflict""" + """Enum for selecting the which version of a conflict + + Attributes + ---------- + Fail: int + Fail the rebase operation + UseOurs: int + Use the version from the source store + UseTheirs: int + Use the version from the target store + """ Fail = 0 UseOurs = 1 @@ -627,7 +1455,6 @@ class BasicConflictSolver(ConflictSolver): This conflict solver allows for simple configuration of resolution behavior for conflicts that may occur during a rebase operation. It will attempt to resolve a limited set of conflicts based on the configuration options provided. - - When a user attribute conflict is encountered, the behavior is determined by the `on_user_attributes_conflict` option - When a chunk conflict is encountered, the behavior is determined by the `on_chunk_conflict` option - When an array is deleted that has been updated, `fail_on_delete_of_updated_array` will determine whether to fail the rebase operation - When a group is deleted that has been updated, `fail_on_delete_of_updated_group` will determine whether to fail the rebase operation @@ -636,15 +1463,14 @@ class BasicConflictSolver(ConflictSolver): def __init__( self, *, - on_user_attributes_conflict: VersionSelection = VersionSelection.UseOurs, on_chunk_conflict: VersionSelection = VersionSelection.UseOurs, fail_on_delete_of_updated_array: bool = False, fail_on_delete_of_updated_group: bool = False, ) -> None: """Create a BasicConflictSolver object with the given configuration options - Parameters: - on_user_attributes_conflict: VersionSelection - The behavior to use when a user attribute conflict is encountered, by default VersionSelection.use_ours() + + Parameters + ---------- on_chunk_conflict: VersionSelection The behavior to use when a chunk conflict is encountered, by default VersionSelection.use_theirs() fail_on_delete_of_updated_array: bool @@ -703,36 +1529,70 @@ class PyConflictError(IcechunkError): __version__: str class ConflictType(Enum): - """Type of conflict detected""" - - NewNodeConflictsWithExistingNode = 1 - NewNodeInInvalidGroup = 2 - ZarrMetadataDoubleUpdate = 3 - ZarrMetadataUpdateOfDeletedArray = 4 - UserAttributesDoubleUpdate = 5 - UserAttributesUpdateOfDeletedNode = 6 - ChunkDoubleUpdate = 7 - ChunksUpdatedInDeletedArray = 8 - ChunksUpdatedInUpdatedArray = 9 - DeleteOfUpdatedArray = 10 - DeleteOfUpdatedGroup = 11 + """Type of conflict detected + + Attributes: + NewNodeConflictsWithExistingNode: int + A new node conflicts with an existing node + NewNodeInInvalidGroup: tuple[int] + A new node is in an invalid group + ZarrMetadataDoubleUpdate: tuple[int] + A zarr metadata update conflicts with an existing zarr metadata update + ZarrMetadataUpdateOfDeletedArray: tuple[int] + A zarr metadata update is attempted on a deleted array + ZarrMetadataUpdateOfDeletedGroup: tuple[int] + A zarr metadata update is attempted on a deleted group + ChunkDoubleUpdate: tuple[int] + A chunk update conflicts with an existing chunk update + ChunksUpdatedInDeletedArray: tuple[int] + Chunks are updated in a deleted array + ChunksUpdatedInUpdatedArray: tuple[int] + Chunks are updated in an updated array + DeleteOfUpdatedArray: tuple[int] + A delete is attempted on an updated array + DeleteOfUpdatedGroup: tuple[int] + A delete is attempted on an updated group + """ + + NewNodeConflictsWithExistingNode = (1,) + NewNodeInInvalidGroup = (2,) + ZarrMetadataDoubleUpdate = (3,) + ZarrMetadataUpdateOfDeletedArray = (4,) + ZarrMetadataUpdateOfDeletedGroup = (5,) + ChunkDoubleUpdate = (6,) + ChunksUpdatedInDeletedArray = (7,) + ChunksUpdatedInUpdatedArray = (8,) + DeleteOfUpdatedArray = (9,) + DeleteOfUpdatedGroup = (10,) class Conflict: """A conflict detected between snapshots""" @property def conflict_type(self) -> ConflictType: - """The type of conflict detected""" + """The type of conflict detected + + Returns: + ConflictType: The type of conflict detected + """ ... @property def path(self) -> str: - """The path of the node that caused the conflict""" + """The path of the node that caused the conflict + + Returns: + str: The path of the node that caused the conflict + """ ... @property def conflicted_chunks(self) -> list[list[int]] | None: - """If the conflict is a chunk conflict, this will return the list of chunk indices that are in conflict""" + """If the conflict is a chunk conflict, this will return the list of chunk indices that are in conflict + + Returns: + list[list[int]] | None: The list of chunk indices that are in conflict + """ ... class RebaseFailedData: @@ -745,7 +1605,11 @@ class RebaseFailedData: @property def conflicts(self) -> list[Conflict]: - """The conflicts that occurred during the rebase operation""" + """The conflicts that occurred during the rebase operation + + Returns: + list[Conflict]: The conflicts that occurred during the rebase operation + """ ... class PyRebaseFailedError(IcechunkError): @@ -753,3 +1617,20 @@ class PyRebaseFailedError(IcechunkError): args: tuple[RebaseFailedData] ... + +def initialize_logs() -> None: + """ + Initialize the logging system for the library. + + This should be called before any other Icechunk functions are called. + """ + ... + +def spec_version() -> int: + """ + The version of the Icechunk specification that the library is compatible with. + + Returns: + int: The version of the Icechunk specification that the library is compatible with + """ + ... diff --git a/icechunk-python/python/icechunk/credentials.py b/icechunk-python/python/icechunk/credentials.py index 7d647265..165bff2a 100644 --- a/icechunk-python/python/icechunk/credentials.py +++ b/icechunk-python/python/icechunk/credentials.py @@ -6,6 +6,7 @@ AzureCredentials, AzureStaticCredentials, Credentials, + GcsBearerCredential, GcsCredentials, GcsStaticCredentials, S3Credentials, @@ -23,9 +24,12 @@ GcsStaticCredentials.ServiceAccount | GcsStaticCredentials.ServiceAccountKey | GcsStaticCredentials.ApplicationCredentials + | GcsStaticCredentials.BearerToken ) -AnyGcsCredential = GcsCredentials.FromEnv | GcsCredentials.Static +AnyGcsCredential = ( + GcsCredentials.FromEnv | GcsCredentials.Static | GcsCredentials.Refreshable +) AnyAzureStaticCredential = ( AzureStaticCredentials.AccessKey @@ -35,6 +39,7 @@ AnyAzureCredential = AzureCredentials.FromEnv | AzureCredentials.Static + AnyCredential = Credentials.S3 | Credentials.Gcs | Credentials.Azure @@ -178,6 +183,7 @@ def gcs_static_credentials( service_account_file: str | None = None, service_account_key: str | None = None, application_credentials: str | None = None, + bearer_token: str | None = None, ) -> AnyGcsStaticCredential: """Create static credentials Google Cloud Storage object store.""" if service_account_file is not None: @@ -186,9 +192,18 @@ def gcs_static_credentials( return GcsStaticCredentials.ServiceAccountKey(service_account_key) if application_credentials is not None: return GcsStaticCredentials.ApplicationCredentials(application_credentials) + if bearer_token is not None: + return GcsStaticCredentials.BearerToken(bearer_token) raise ValueError("Conflicting arguments to gcs_static_credentials function") +def gcs_refreshable_credentials( + get_credentials: Callable[[], GcsBearerCredential], +) -> GcsCredentials.Refreshable: + """Create refreshable credentials for Google Cloud Storage object store.""" + return GcsCredentials.Refreshable(pickle.dumps(get_credentials)) + + def gcs_from_env_credentials() -> GcsCredentials.FromEnv: """Instruct Google Cloud Storage object store to fetch credentials from the operative system environment.""" return GcsCredentials.FromEnv() @@ -199,7 +214,9 @@ def gcs_credentials( service_account_file: str | None = None, service_account_key: str | None = None, application_credentials: str | None = None, + bearer_token: str | None = None, from_env: bool | None = None, + get_credentials: Callable[[], GcsBearerCredential] | None = None, ) -> AnyGcsCredential: """Create credentials Google Cloud Storage object store. @@ -209,6 +226,7 @@ def gcs_credentials( service_account_file is None and service_account_key is None and application_credentials is None + and bearer_token is None ): return gcs_from_env_credentials() @@ -216,15 +234,20 @@ def gcs_credentials( service_account_file is not None or service_account_key is not None or application_credentials is not None + or bearer_token is not None ) and (from_env is None or not from_env): return GcsCredentials.Static( gcs_static_credentials( service_account_file=service_account_file, service_account_key=service_account_key, application_credentials=application_credentials, + bearer_token=bearer_token, ) ) + if get_credentials is not None: + return gcs_refreshable_credentials(get_credentials) + raise ValueError("Conflicting arguments to gcs_credentials function") diff --git a/icechunk-python/python/icechunk/repository.py b/icechunk-python/python/icechunk/repository.py index 13cc6b23..2dae5926 100644 --- a/icechunk-python/python/icechunk/repository.py +++ b/icechunk-python/python/icechunk/repository.py @@ -1,8 +1,9 @@ import datetime -from collections.abc import AsyncIterator -from typing import Self +from collections.abc import AsyncIterator, Iterator +from typing import Self, cast from icechunk._icechunk_python import ( + Diff, GCSummary, PyRepository, RepositoryConfig, @@ -139,6 +140,16 @@ def exists(storage: Storage) -> bool: """ return PyRepository.exists(storage) + def __getstate__(self) -> object: + return { + "_repository": self._repository.as_bytes(), + } + + def __setstate__(self, state: object) -> None: + if not isinstance(state, dict): + raise ValueError("Invalid repository state") + self._repository = PyRepository.from_bytes(state["_repository"]) + @staticmethod def fetch_config(storage: Storage) -> RepositoryConfig | None: """ @@ -195,8 +206,8 @@ def ancestry( *, branch: str | None = None, tag: str | None = None, - snapshot: str | None = None, - ) -> list[SnapshotInfo]: + snapshot_id: str | None = None, + ) -> Iterator[SnapshotInfo]: """ Get the ancestry of a snapshot. @@ -206,7 +217,7 @@ def ancestry( The branch to get the ancestry of. tag : str, optional The tag to get the ancestry of. - snapshot : str, optional + snapshot_id : str, optional The snapshot ID to get the ancestry of. Returns @@ -218,14 +229,22 @@ def ancestry( ----- Only one of the arguments can be specified. """ - return self._repository.ancestry(branch=branch, tag=tag, snapshot=snapshot) + + # the returned object is both an Async and Sync iterator + res = cast( + Iterator[SnapshotInfo], + self._repository.async_ancestry( + branch=branch, tag=tag, snapshot_id=snapshot_id + ), + ) + return res def async_ancestry( self, *, branch: str | None = None, tag: str | None = None, - snapshot: str | None = None, + snapshot_id: str | None = None, ) -> AsyncIterator[SnapshotInfo]: """ Get the ancestry of a snapshot. @@ -236,7 +255,7 @@ def async_ancestry( The branch to get the ancestry of. tag : str, optional The tag to get the ancestry of. - snapshot : str, optional + snapshot_id : str, optional The snapshot ID to get the ancestry of. Returns @@ -248,7 +267,9 @@ def async_ancestry( ----- Only one of the arguments can be specified. """ - return self._repository.async_ancestry(branch=branch, tag=tag, snapshot=snapshot) + return self._repository.async_ancestry( + branch=branch, tag=tag, snapshot_id=snapshot_id + ) def create_branch(self, branch: str, snapshot_id: str) -> None: """ @@ -329,7 +350,7 @@ def delete_branch(self, branch: str) -> None: """ self._repository.delete_branch(branch) - def delete_tag(self, branch: str) -> None: + def delete_tag(self, tag: str) -> None: """ Delete a tag. @@ -342,7 +363,7 @@ def delete_tag(self, branch: str) -> None: ------- None """ - self._repository.delete_tag(branch) + self._repository.delete_tag(tag) def create_tag(self, tag: str, snapshot_id: str) -> None: """ @@ -388,12 +409,45 @@ def lookup_tag(self, tag: str) -> str: """ return self._repository.lookup_tag(tag) - def readonly_session( + def diff( self, *, + from_branch: str | None = None, + from_tag: str | None = None, + from_snapshot_id: str | None = None, + to_branch: str | None = None, + to_tag: str | None = None, + to_snapshot_id: str | None = None, + ) -> Diff: + """ + Compute an overview of the operations executed from version `from` to version `to`. + + Both versions, `from` and `to`, must be identified. Identification can be done using a branch, tag or snapshot id. + The styles used to identify the `from` and `to` versions can be different. + + The `from` version must be a member of the `ancestry` of `to`. + + Returns + ------- + Diff + The operations executed between the two versions + """ + return self._repository.diff( + from_branch=from_branch, + from_tag=from_tag, + from_snapshot_id=from_snapshot_id, + to_branch=to_branch, + to_tag=to_tag, + to_snapshot_id=to_snapshot_id, + ) + + def readonly_session( + self, branch: str | None = None, + *, tag: str | None = None, - snapshot: str | None = None, + snapshot_id: str | None = None, + as_of: datetime.datetime | None = None, ) -> Session: """ Create a read-only session. @@ -408,8 +462,11 @@ def readonly_session( If provided, the branch to create the session on. tag : str, optional If provided, the tag to create the session on. - snapshot : str, optional + snapshot_id : str, optional If provided, the snapshot ID to create the session on. + as_of: datetime.datetime, optional + When combined with the branch argument, it will open the session at the last + snapshot that is at or before this datetime Returns ------- @@ -421,7 +478,9 @@ def readonly_session( Only one of the arguments can be specified. """ return Session( - self._repository.readonly_session(branch=branch, tag=tag, snapshot=snapshot) + self._repository.readonly_session( + branch=branch, tag=tag, snapshot_id=snapshot_id, as_of=as_of + ) ) def writable_session(self, branch: str) -> Session: @@ -445,7 +504,13 @@ def writable_session(self, branch: str) -> Session: """ return Session(self._repository.writable_session(branch)) - def expire_snapshots(self, older_than: datetime.datetime) -> set[str]: + def expire_snapshots( + self, + older_than: datetime.datetime, + *, + delete_expired_branches: bool = False, + delete_expired_tags: bool = False, + ) -> set[str]: """Expire all snapshots older than a threshold. This processes snapshots found by navigating all references in @@ -456,6 +521,10 @@ def expire_snapshots(self, older_than: datetime.datetime) -> set[str]: available for garbage collection, they could still be pointed by ether refs. + If delete_expired_* is set to True, branches or tags that, after the + expiration process, point to expired snapshots directly, will be + deleted. + Warning: this is an administrative operation, it should be run carefully. The repository can still operate concurrently while `expire_snapshots` runs, but other readers can get inconsistent diff --git a/icechunk-python/python/icechunk/session.py b/icechunk-python/python/icechunk/session.py index a158a236..64ad3202 100644 --- a/icechunk-python/python/icechunk/session.py +++ b/icechunk-python/python/icechunk/session.py @@ -6,6 +6,7 @@ Conflict, ConflictErrorData, ConflictSolver, + Diff, RebaseFailedData, ) from icechunk._icechunk_python import PyConflictError, PyRebaseFailedError, PySession @@ -179,6 +180,17 @@ def has_uncommitted_changes(self) -> bool: """ return self._session.has_uncommitted_changes + def status(self) -> Diff: + """ + Compute an overview of the current session changes + + Returns + ------- + Diff + The operations executed in the current session but still not committed. + """ + return self._session.status() + def discard_changes(self) -> None: """ When the session is writable, discard any uncommitted changes. diff --git a/icechunk-python/python/icechunk/storage.py b/icechunk-python/python/icechunk/storage.py index 3ac3d2b2..70e87ee3 100644 --- a/icechunk-python/python/icechunk/storage.py +++ b/icechunk-python/python/icechunk/storage.py @@ -2,6 +2,7 @@ from datetime import datetime from icechunk._icechunk_python import ( + GcsBearerCredential, ObjectStoreConfig, S3Options, S3StaticCredentials, @@ -99,6 +100,7 @@ def s3_storage( get_credentials: Callable[[], S3StaticCredentials] | None Use this function to get and refresh object store credentials """ + credentials = s3_credentials( access_key_id=access_key_id, secret_access_key=secret_access_key, @@ -117,6 +119,38 @@ def s3_storage( ) +def s3_object_store_storage( + *, + bucket: str, + prefix: str | None, + region: str | None = None, + endpoint_url: str | None = None, + allow_http: bool = False, + access_key_id: str | None = None, + secret_access_key: str | None = None, + session_token: str | None = None, + expires_after: datetime | None = None, + anonymous: bool | None = None, + from_env: bool | None = None, +) -> Storage: + credentials = s3_credentials( + access_key_id=access_key_id, + secret_access_key=secret_access_key, + session_token=session_token, + expires_after=expires_after, + anonymous=anonymous, + from_env=from_env, + get_credentials=None, + ) + options = S3Options(region=region, endpoint_url=endpoint_url, allow_http=allow_http) + return Storage.new_s3_object_store( + config=options, + bucket=bucket, + prefix=prefix, + credentials=credentials, + ) + + def tigris_storage( *, bucket: str, @@ -186,8 +220,10 @@ def gcs_storage( service_account_file: str | None = None, service_account_key: str | None = None, application_credentials: str | None = None, + bearer_token: str | None = None, from_env: bool | None = None, config: dict[str, str] | None = None, + get_credentials: Callable[[], GcsBearerCredential] | None = None, ) -> Storage: """Create a Storage instance that saves data in Google Cloud Storage object store. @@ -199,12 +235,18 @@ def gcs_storage( The prefix within the bucket that is the root directory of the repository from_env: bool | None Fetch credentials from the operative system environment + bearer_token: str | None + The bearer token to use for the object store + get_credentials: Callable[[], GcsBearerCredential] | None + Use this function to get and refresh object store credentials """ credentials = gcs_credentials( service_account_file=service_account_file, service_account_key=service_account_key, application_credentials=application_credentials, + bearer_token=bearer_token, from_env=from_env, + get_credentials=get_credentials, ) return Storage.new_gcs( bucket=bucket, @@ -216,6 +258,7 @@ def gcs_storage( def azure_storage( *, + account: str, container: str, prefix: str, access_key: str | None = None, @@ -228,6 +271,8 @@ def azure_storage( Parameters ---------- + account: str + The account to which the caller must have access privileges container: str The container where the repository will store its data prefix: str @@ -248,6 +293,7 @@ def azure_storage( from_env=from_env, ) return Storage.new_azure_blob( + account=account, container=container, prefix=prefix, credentials=credentials, diff --git a/icechunk-python/python/icechunk/store.py b/icechunk-python/python/icechunk/store.py index 8bb758c3..538fd464 100644 --- a/icechunk-python/python/icechunk/store.py +++ b/icechunk-python/python/icechunk/store.py @@ -2,7 +2,7 @@ from datetime import datetime from typing import TYPE_CHECKING, Any -from icechunk._icechunk_python import PyStore +from icechunk._icechunk_python import PyStore, VirtualChunkSpec from zarr.abc.store import ( ByteRequest, OffsetByteRequest, @@ -247,6 +247,34 @@ def set_virtual_ref( key, location, offset, length, checksum, validate_container ) + def set_virtual_refs( + self, + array_path: str, + chunks: list[VirtualChunkSpec], + *, + validate_containers: bool = False, + ) -> list[tuple[int, ...]] | None: + """Store multiple virtual references for the same array. + + Parameters + ---------- + array_path : str + The path to the array inside the Zarr store. Example: "/groupA/groupB/outputs/my-array" + chunks : list[VirtualChunkSpec], + The list of virtula chunks to add + validate_containers: bool + If set to true, ignore virtual references for locations that don't match any existing virtual chunk container + + + Returns + ------- + list[tuple[int, ...]] | None + + If all virtual references where successfully updated, it returns None. + If there were validation errors, it returns the chunk indices of all failed references. + """ + return self._store.set_virtual_refs(array_path, chunks, validate_containers) + async def delete(self, key: str) -> None: """Remove a key from the store @@ -261,7 +289,7 @@ async def delete_dir(self, prefix: str) -> None: Parameters ---------- - key : str + prefix : str """ return await self._store.delete_dir(prefix) @@ -348,3 +376,6 @@ def list_dir(self, prefix: str) -> AsyncIterator[str]: async def getsize(self, key: str) -> int: return await self._store.getsize(key) + + async def getsize_prefix(self, prefix: str) -> int: + return await self._store.getsize_prefix(prefix) diff --git a/icechunk-python/src/config.rs b/icechunk-python/src/config.rs index dfd542db..d1e392a7 100644 --- a/icechunk-python/src/config.rs +++ b/icechunk-python/src/config.rs @@ -12,9 +12,10 @@ use std::{ use icechunk::{ config::{ AzureCredentials, AzureStaticCredentials, CachingConfig, CompressionAlgorithm, - CompressionConfig, Credentials, CredentialsFetcher, GcsCredentials, - GcsStaticCredentials, ManifestConfig, ManifestPreloadCondition, - ManifestPreloadConfig, S3Credentials, S3Options, S3StaticCredentials, + CompressionConfig, Credentials, GcsBearerCredential, GcsCredentials, + GcsCredentialsFetcher, GcsStaticCredentials, ManifestConfig, + ManifestPreloadCondition, ManifestPreloadConfig, S3Credentials, + S3CredentialsFetcher, S3Options, S3StaticCredentials, }, storage::{self, ConcurrencySettings}, virtual_chunks::VirtualChunkContainer, @@ -87,13 +88,13 @@ impl PyS3StaticCredentials { r#"S3StaticCredentials(access_key_id="{ak}", secret_access_key="{sk}", session_token={st}, expires_after={ea})"#, ak = self.access_key_id.as_str(), sk = self.secret_access_key.as_str(), - st = format_option_string(self.session_token.as_ref()), + st = format_option(self.session_token.as_ref()), ea = format_option(self.expires_after.as_ref().map(datetime_repr)) ) } } -fn format_option_to_string(o: Option) -> String { +pub(crate) fn format_option_to_string(o: Option) -> String { match o.as_ref() { None => "None".to_string(), Some(s) => s.to_string(), @@ -107,13 +108,6 @@ fn format_option<'a, T: AsRef + 'a>(o: Option) -> String { } } -pub(crate) fn format_option_string<'a, T: AsRef + 'a>(o: Option) -> String { - match o.as_ref() { - None => "None".to_string(), - Some(s) => format!(r#""{}""#, s.as_ref()), - } -} - fn format_bool(b: bool) -> &'static str { match b { true => "True", @@ -155,7 +149,7 @@ impl PythonCredentialsFetcher { } #[async_trait] #[typetag::serde] -impl CredentialsFetcher for PythonCredentialsFetcher { +impl S3CredentialsFetcher for PythonCredentialsFetcher { async fn get(&self) -> Result { Python::with_gil(|py| { let pickle_module = PyModule::import(py, "pickle")?; @@ -168,6 +162,21 @@ impl CredentialsFetcher for PythonCredentialsFetcher { } } +#[async_trait] +#[typetag::serde] +impl GcsCredentialsFetcher for PythonCredentialsFetcher { + async fn get(&self) -> Result { + Python::with_gil(|py| { + let pickle_module = PyModule::import(py, "pickle")?; + let loads_function = pickle_module.getattr("loads")?; + let fetcher = loads_function.call1((self.pickled_function.clone(),))?; + let creds: PyGcsBearerCredential = fetcher.call0()?.extract()?; + Ok(creds.into()) + }) + .map_err(|e: PyErr| e.to_string()) + } +} + #[pyclass(name = "S3Credentials")] #[derive(Clone, Debug)] pub enum PyS3Credentials { @@ -198,6 +207,7 @@ pub enum PyGcsStaticCredentials { ServiceAccount(String), ServiceAccountKey(String), ApplicationCredentials(String), + BearerToken(String), } impl From for GcsStaticCredentials { @@ -212,15 +222,50 @@ impl From for GcsStaticCredentials { PyGcsStaticCredentials::ApplicationCredentials(path) => { GcsStaticCredentials::ApplicationCredentials(path.into()) } + PyGcsStaticCredentials::BearerToken(token) => { + GcsStaticCredentials::BearerToken(GcsBearerCredential { + bearer: token, + expires_after: None, + }) + } } } } +#[pyclass(name = "GcsBearerCredential")] +#[derive(Clone, Debug)] +pub struct PyGcsBearerCredential { + pub bearer: String, + pub expires_after: Option>, +} + +#[pymethods] +impl PyGcsBearerCredential { + #[new] + #[pyo3(signature = (bearer, *, expires_after = None))] + pub fn new(bearer: String, expires_after: Option>) -> Self { + PyGcsBearerCredential { bearer, expires_after } + } +} + +impl From for GcsBearerCredential { + fn from(value: PyGcsBearerCredential) -> Self { + GcsBearerCredential { bearer: value.bearer, expires_after: value.expires_after } + } +} + +impl From for PyGcsBearerCredential { + fn from(value: GcsBearerCredential) -> Self { + PyGcsBearerCredential { bearer: value.bearer, expires_after: value.expires_after } + } +} + #[pyclass(name = "GcsCredentials")] #[derive(Clone, Debug)] pub enum PyGcsCredentials { FromEnv(), Static(PyGcsStaticCredentials), + Refreshable(Vec), } impl From for GcsCredentials { @@ -228,6 +273,11 @@ impl From for GcsCredentials { match value { PyGcsCredentials::FromEnv() => GcsCredentials::FromEnv, PyGcsCredentials::Static(creds) => GcsCredentials::Static(creds.into()), + PyGcsCredentials::Refreshable(pickled_function) => { + GcsCredentials::Refreshable(Arc::new(PythonCredentialsFetcher { + pickled_function, + })) + } } } } @@ -320,8 +370,8 @@ impl PyS3Options { // TODO: escape format!( r#"S3Options(region={region}, endpoint_url={url}, allow_http={http}, anonymous={anon})"#, - region = format_option_string(self.region.as_ref()), - url = format_option_string(self.endpoint_url.as_ref()), + region = format_option(self.region.as_ref()), + url = format_option(self.endpoint_url.as_ref()), http = format_bool(self.allow_http), anon = format_bool(self.anonymous), ) @@ -650,6 +700,12 @@ fn storage_concurrency_settings_repr(s: &PyStorageConcurrencySettings) -> String pub struct PyStorageSettings { #[pyo3(get, set)] pub concurrency: Option>, + #[pyo3(get, set)] + pub unsafe_use_conditional_update: Option, + #[pyo3(get, set)] + pub unsafe_use_conditional_create: Option, + #[pyo3(get, set)] + pub unsafe_use_metadata: Option, } impl From for PyStorageSettings { @@ -660,6 +716,9 @@ impl From for PyStorageSettings { Py::new(py, Into::::into(c)) .expect("Cannot create instance of StorageConcurrencySettings") }), + unsafe_use_conditional_create: value.unsafe_use_conditional_create, + unsafe_use_conditional_update: value.unsafe_use_conditional_update, + unsafe_use_metadata: value.unsafe_use_metadata, }) } } @@ -668,6 +727,9 @@ impl From<&PyStorageSettings> for storage::Settings { fn from(value: &PyStorageSettings) -> Self { Python::with_gil(|py| Self { concurrency: value.concurrency.as_ref().map(|c| (&*c.borrow(py)).into()), + unsafe_use_conditional_create: value.unsafe_use_conditional_create, + unsafe_use_conditional_update: value.unsafe_use_conditional_update, + unsafe_use_metadata: value.unsafe_use_metadata, }) } } @@ -684,10 +746,20 @@ impl Eq for PyStorageSettings {} #[pymethods] impl PyStorageSettings { - #[pyo3(signature = ( concurrency=None))] + #[pyo3(signature = ( concurrency=None, unsafe_use_conditional_create=None, unsafe_use_conditional_update=None, unsafe_use_metadata=None))] #[new] - pub fn new(concurrency: Option>) -> Self { - Self { concurrency } + pub fn new( + concurrency: Option>, + unsafe_use_conditional_create: Option, + unsafe_use_conditional_update: Option, + unsafe_use_metadata: Option, + ) -> Self { + Self { + concurrency, + unsafe_use_conditional_create, + unsafe_use_metadata, + unsafe_use_conditional_update, + } } pub fn __repr__(&self) -> String { @@ -699,7 +771,13 @@ impl PyStorageSettings { }), }; - format!(r#"StorageSettings(concurrency={conc})"#, conc = inner) + format!( + r#"StorageSettings(concurrency={conc}, unsafe_use_conditional_create={cr}, unsafe_use_conditional_update={up}, unsafe_use_metadata={me})"#, + conc = inner, + cr = format_option(self.unsafe_use_conditional_create.map(format_bool)), + up = format_option(self.unsafe_use_conditional_update.map(format_bool)), + me = format_option(self.unsafe_use_metadata.map(format_bool)) + ) } } @@ -912,8 +990,6 @@ pub struct PyRepositoryConfig { #[pyo3(get, set)] pub inline_chunk_threshold_bytes: Option, #[pyo3(get, set)] - pub unsafe_overwrite_refs: Option, - #[pyo3(get, set)] pub get_partial_values_concurrency: Option, #[pyo3(get, set)] pub compression: Option>, @@ -939,7 +1015,6 @@ impl From<&PyRepositoryConfig> for RepositoryConfig { fn from(value: &PyRepositoryConfig) -> Self { Python::with_gil(|py| Self { inline_chunk_threshold_bytes: value.inline_chunk_threshold_bytes, - unsafe_overwrite_refs: value.unsafe_overwrite_refs, get_partial_values_concurrency: value.get_partial_values_concurrency, compression: value.compression.as_ref().map(|c| (&*c.borrow(py)).into()), caching: value.caching.as_ref().map(|c| (&*c.borrow(py)).into()), @@ -957,7 +1032,6 @@ impl From for PyRepositoryConfig { #[allow(clippy::expect_used)] Python::with_gil(|py| Self { inline_chunk_threshold_bytes: value.inline_chunk_threshold_bytes, - unsafe_overwrite_refs: value.unsafe_overwrite_refs, get_partial_values_concurrency: value.get_partial_values_concurrency, compression: value.compression.map(|c| { Py::new(py, Into::::into(c)) @@ -992,11 +1066,10 @@ impl PyRepositoryConfig { } #[new] - #[pyo3(signature = (inline_chunk_threshold_bytes = None, unsafe_overwrite_refs = None, get_partial_values_concurrency = None, compression = None, caching = None, storage = None, virtual_chunk_containers = None, manifest = None))] + #[pyo3(signature = (inline_chunk_threshold_bytes = None, get_partial_values_concurrency = None, compression = None, caching = None, storage = None, virtual_chunk_containers = None, manifest = None))] #[allow(clippy::too_many_arguments)] pub fn new( inline_chunk_threshold_bytes: Option, - unsafe_overwrite_refs: Option, get_partial_values_concurrency: Option, compression: Option>, caching: Option>, @@ -1006,7 +1079,6 @@ impl PyRepositoryConfig { ) -> Self { Self { inline_chunk_threshold_bytes, - unsafe_overwrite_refs, get_partial_values_concurrency, compression, caching, @@ -1072,9 +1144,8 @@ impl PyRepositoryConfig { })); // TODO: virtual chunk containers format!( - r#"RepositoryConfig(inline_chunk_threshold_bytes={inl}, unsafe_overwrite_refs={uns}, get_partial_values_concurrency={partial}, compression={comp}, caching={caching}, storage={storage}, manifest={manifest})"#, + r#"RepositoryConfig(inline_chunk_threshold_bytes={inl}, get_partial_values_concurrency={partial}, compression={comp}, caching={caching}, storage={storage}, manifest={manifest})"#, inl = format_option_to_string(self.inline_chunk_threshold_bytes), - uns = format_option(self.unsafe_overwrite_refs.map(format_bool)), partial = format_option_to_string(self.get_partial_values_concurrency), comp = comp, caching = caching, @@ -1111,6 +1182,32 @@ impl PyStorage { Ok(PyStorage(storage)) } + #[pyo3(signature = ( config, bucket, prefix, credentials=None))] + #[classmethod] + pub fn new_s3_object_store( + _cls: &Bound<'_, PyType>, + py: Python<'_>, + config: &PyS3Options, + bucket: String, + prefix: Option, + credentials: Option, + ) -> PyResult { + py.allow_threads(move || { + pyo3_async_runtimes::tokio::get_runtime().block_on(async move { + let storage = icechunk::storage::new_s3_object_store_storage( + config.into(), + bucket, + prefix, + credentials.map(|cred| cred.into()), + ) + .await + .map_err(PyIcechunkStoreError::StorageError)?; + + Ok(PyStorage(storage)) + }) + }) + } + #[pyo3(signature = ( config, bucket, prefix, credentials=None))] #[classmethod] pub fn new_tigris( @@ -1132,60 +1229,91 @@ impl PyStorage { } #[classmethod] - pub fn new_in_memory(_cls: &Bound<'_, PyType>) -> PyResult { - let storage = icechunk::storage::new_in_memory_storage() - .map_err(PyIcechunkStoreError::StorageError)?; - - Ok(PyStorage(storage)) + pub fn new_in_memory(_cls: &Bound<'_, PyType>, py: Python<'_>) -> PyResult { + py.allow_threads(move || { + pyo3_async_runtimes::tokio::get_runtime().block_on(async move { + let storage = icechunk::storage::new_in_memory_storage() + .await + .map_err(PyIcechunkStoreError::StorageError)?; + + Ok(PyStorage(storage)) + }) + }) } #[classmethod] pub fn new_local_filesystem( _cls: &Bound<'_, PyType>, + py: Python<'_>, path: PathBuf, ) -> PyResult { - let storage = icechunk::storage::new_local_filesystem_storage(&path) - .map_err(PyIcechunkStoreError::StorageError)?; - - Ok(PyStorage(storage)) + py.allow_threads(move || { + pyo3_async_runtimes::tokio::get_runtime().block_on(async move { + let storage = icechunk::storage::new_local_filesystem_storage(&path) + .await + .map_err(PyIcechunkStoreError::StorageError)?; + + Ok(PyStorage(storage)) + }) + }) } - #[staticmethod] + #[classmethod] #[pyo3(signature = (bucket, prefix, credentials=None, *, config=None))] pub fn new_gcs( + _cls: &Bound<'_, PyType>, + py: Python<'_>, bucket: String, prefix: Option, credentials: Option, config: Option>, ) -> PyResult { - let storage = icechunk::storage::new_gcs_storage( - bucket, - prefix, - credentials.map(|cred| cred.into()), - config, - ) - .map_err(PyIcechunkStoreError::StorageError)?; - - Ok(PyStorage(storage)) + py.allow_threads(move || { + pyo3_async_runtimes::tokio::get_runtime().block_on(async move { + let storage = icechunk::storage::new_gcs_storage( + bucket, + prefix, + credentials.map(|cred| cred.into()), + config, + ) + .await + .map_err(PyIcechunkStoreError::StorageError)?; + + Ok(PyStorage(storage)) + }) + }) } - #[staticmethod] - #[pyo3(signature = (container, prefix, credentials=None, *, config=None))] + #[classmethod] + #[pyo3(signature = (account, container, prefix, credentials=None, *, config=None))] pub fn new_azure_blob( + _cls: &Bound<'_, PyType>, + py: Python<'_>, + account: String, container: String, prefix: String, credentials: Option, config: Option>, ) -> PyResult { - let storage = icechunk::storage::new_azure_blob_storage( - container, - prefix, - credentials.map(|cred| cred.into()), - config, - ) - .map_err(PyIcechunkStoreError::StorageError)?; + py.allow_threads(move || { + pyo3_async_runtimes::tokio::get_runtime().block_on(async move { + let storage = icechunk::storage::new_azure_blob_storage( + account, + container, + Some(prefix), + credentials.map(|cred| cred.into()), + config, + ) + .await + .map_err(PyIcechunkStoreError::StorageError)?; + + Ok(PyStorage(storage)) + }) + }) + } - Ok(PyStorage(storage)) + pub fn __repr__(&self) -> String { + format!("{}", self.0) } pub fn default_settings(&self) -> PyStorageSettings { diff --git a/icechunk-python/src/conflicts.rs b/icechunk-python/src/conflicts.rs index 13d3702e..af169384 100644 --- a/icechunk-python/src/conflicts.rs +++ b/icechunk-python/src/conflicts.rs @@ -14,13 +14,12 @@ pub enum PyConflictType { NewNodeInInvalidGroup = 2, ZarrMetadataDoubleUpdate = 3, ZarrMetadataUpdateOfDeletedArray = 4, - UserAttributesDoubleUpdate = 5, - UserAttributesUpdateOfDeletedNode = 6, - ChunkDoubleUpdate = 7, - ChunksUpdatedInDeletedArray = 8, - ChunksUpdatedInUpdatedArray = 9, - DeleteOfUpdatedArray = 10, - DeleteOfUpdatedGroup = 11, + ZarrMetadataUpdateOfDeletedGroup = 5, + ChunkDoubleUpdate = 6, + ChunksUpdatedInDeletedArray = 7, + ChunksUpdatedInUpdatedArray = 8, + DeleteOfUpdatedArray = 9, + DeleteOfUpdatedGroup = 10, } impl Display for PyConflictType { @@ -31,13 +30,12 @@ impl Display for PyConflictType { } PyConflictType::NewNodeInInvalidGroup => "New node in invalid group", PyConflictType::ZarrMetadataDoubleUpdate => "Zarr metadata double update", + PyConflictType::ZarrMetadataUpdateOfDeletedGroup => { + "Zarr metadata update of deleted group" + } PyConflictType::ZarrMetadataUpdateOfDeletedArray => { "Zarr metadata update of deleted array" } - PyConflictType::UserAttributesDoubleUpdate => "User attributes double update", - PyConflictType::UserAttributesUpdateOfDeletedNode => { - "User attributes update of deleted node" - } PyConflictType::ChunkDoubleUpdate => "Chunk double update", PyConflictType::ChunksUpdatedInDeletedArray => { "Chunks updated in deleted array" @@ -108,13 +106,8 @@ impl From<&Conflict> for PyConflict { path: path.to_string(), conflicted_chunks: None, }, - Conflict::UserAttributesDoubleUpdate { path, node_id: _ } => PyConflict { - conflict_type: PyConflictType::UserAttributesDoubleUpdate, - path: path.to_string(), - conflicted_chunks: None, - }, - Conflict::UserAttributesUpdateOfDeletedNode(path) => PyConflict { - conflict_type: PyConflictType::UserAttributesUpdateOfDeletedNode, + Conflict::ZarrMetadataUpdateOfDeletedGroup(path) => PyConflict { + conflict_type: PyConflictType::ZarrMetadataUpdateOfDeletedGroup, path: path.to_string(), conflicted_chunks: None, }, @@ -188,9 +181,8 @@ pub struct PyBasicConflictSolver; #[pymethods] impl PyBasicConflictSolver { #[new] - #[pyo3(signature = (*, on_user_attributes_conflict=PyVersionSelection::UseOurs, on_chunk_conflict=PyVersionSelection::UseOurs, fail_on_delete_of_updated_array = false, fail_on_delete_of_updated_group = false))] + #[pyo3(signature = (*, on_chunk_conflict=PyVersionSelection::UseOurs, fail_on_delete_of_updated_array = false, fail_on_delete_of_updated_group = false))] fn new( - on_user_attributes_conflict: PyVersionSelection, on_chunk_conflict: PyVersionSelection, fail_on_delete_of_updated_array: bool, fail_on_delete_of_updated_group: bool, @@ -198,7 +190,6 @@ impl PyBasicConflictSolver { ( Self, PyConflictSolver(Arc::new(BasicConflictSolver { - on_user_attributes_conflict: on_user_attributes_conflict.into(), on_chunk_conflict: on_chunk_conflict.into(), fail_on_delete_of_updated_array, fail_on_delete_of_updated_group, diff --git a/icechunk-python/src/errors.rs b/icechunk-python/src/errors.rs index 83a1cd71..d04fa06c 100644 --- a/icechunk-python/src/errors.rs +++ b/icechunk-python/src/errors.rs @@ -1,9 +1,12 @@ -use std::convert::Infallible; - use icechunk::{ - format::IcechunkFormatError, ops::gc::GCError, repository::RepositoryError, - session::SessionError, store::StoreError, StorageError, + format::IcechunkFormatError, + ops::gc::GCError, + repository::RepositoryError, + session::{SessionError, SessionErrorKind}, + store::{StoreError, StoreErrorKind}, + StorageError, }; +use miette::{Diagnostic, GraphicalReportHandler}; use pyo3::{ create_exception, exceptions::{PyKeyError, PyValueError}, @@ -20,44 +23,45 @@ use crate::conflicts::PyConflict; /// So for now we just use the extra operation to get the coercion instead of manually mapping /// the errors where this is returned from a python class #[allow(clippy::enum_variant_names)] -#[derive(Debug, Error)] +#[derive(Debug, Error, Diagnostic)] #[allow(dead_code)] pub(crate) enum PyIcechunkStoreError { - #[error("storage error: {0}")] + #[error(transparent)] StorageError(StorageError), - #[error("store error: {0}")] + #[error(transparent)] StoreError(StoreError), - #[error("repository error: {0}")] + #[error(transparent)] RepositoryError(#[from] RepositoryError), #[error("session error: {0}")] SessionError(SessionError), - #[error("icechunk format error: {0}")] + #[error(transparent)] IcechunkFormatError(#[from] IcechunkFormatError), - #[error("Expiration or garbage collection error: {0}")] + #[error(transparent)] GCError(#[from] GCError), #[error("{0}")] PyKeyError(String), #[error("{0}")] PyValueError(String), - #[error("{0}")] + #[error(transparent)] PyError(#[from] PyErr), #[error("{0}")] UnkownError(String), } -impl From for PyIcechunkStoreError { - fn from(_: Infallible) -> Self { - PyIcechunkStoreError::UnkownError("Infallible".to_string()) - } -} - impl From for PyIcechunkStoreError { fn from(error: StoreError) -> Self { match error { - StoreError::NotFound(e) => PyIcechunkStoreError::PyKeyError(e.to_string()), - StoreError::SessionError(SessionError::NodeNotFound { path, message: _ }) => { - PyIcechunkStoreError::PyKeyError(format!("{}", path)) + StoreError { kind: StoreErrorKind::NotFound(e), .. } => { + PyIcechunkStoreError::PyKeyError(e.to_string()) } + StoreError { + kind: + StoreErrorKind::SessionError(SessionErrorKind::NodeNotFound { + path, + message: _, + }), + .. + } => PyIcechunkStoreError::PyKeyError(format!("{}", path)), _ => PyIcechunkStoreError::StoreError(error), } } @@ -66,9 +70,10 @@ impl From for PyIcechunkStoreError { impl From for PyIcechunkStoreError { fn from(error: SessionError) -> Self { match error { - SessionError::NodeNotFound { path, message: _ } => { - PyIcechunkStoreError::PyKeyError(format!("{}", path)) - } + SessionError { + kind: SessionErrorKind::NodeNotFound { path, message: _ }, + .. + } => PyIcechunkStoreError::PyKeyError(format!("{}", path)), _ => PyIcechunkStoreError::SessionError(error), } } @@ -77,16 +82,16 @@ impl From for PyIcechunkStoreError { impl From for PyErr { fn from(error: PyIcechunkStoreError) -> Self { match error { - PyIcechunkStoreError::SessionError(SessionError::Conflict { - expected_parent, - actual_parent, + PyIcechunkStoreError::SessionError(SessionError { + kind: SessionErrorKind::Conflict { expected_parent, actual_parent }, + .. }) => PyConflictError::new_err(PyConflictErrorData { expected_parent: expected_parent.map(|s| s.to_string()), actual_parent: actual_parent.map(|s| s.to_string()), }), - PyIcechunkStoreError::SessionError(SessionError::RebaseFailed { - snapshot, - conflicts, + PyIcechunkStoreError::SessionError(SessionError { + kind: SessionErrorKind::RebaseFailed { snapshot, conflicts }, + .. }) => PyRebaseFailedError::new_err(PyRebaseFailedData { snapshot: snapshot.to_string(), conflicts: conflicts.iter().map(PyConflict::from).collect(), @@ -94,7 +99,15 @@ impl From for PyErr { PyIcechunkStoreError::PyKeyError(e) => PyKeyError::new_err(e), PyIcechunkStoreError::PyValueError(e) => PyValueError::new_err(e), PyIcechunkStoreError::PyError(err) => err, - _ => IcechunkError::new_err(error.to_string()), + error => { + let mut buf = String::new(); + let message = + match GraphicalReportHandler::new().render_report(&mut buf, &error) { + Ok(_) => buf, + Err(_) => error.to_string(), + }; + IcechunkError::new_err(message) + } } } } diff --git a/icechunk-python/src/lib.rs b/icechunk-python/src/lib.rs index 6def166f..151b02b5 100644 --- a/icechunk-python/src/lib.rs +++ b/icechunk-python/src/lib.rs @@ -6,13 +6,16 @@ mod session; mod store; mod streams; +use std::env; + use config::{ PyAzureCredentials, PyAzureStaticCredentials, PyCachingConfig, - PyCompressionAlgorithm, PyCompressionConfig, PyCredentials, PyGcsCredentials, - PyGcsStaticCredentials, PyManifestConfig, PyManifestPreloadCondition, - PyManifestPreloadConfig, PyObjectStoreConfig, PyRepositoryConfig, PyS3Credentials, - PyS3Options, PyS3StaticCredentials, PyStorage, PyStorageConcurrencySettings, - PyStorageSettings, PyVirtualChunkContainer, PythonCredentialsFetcher, + PyCompressionAlgorithm, PyCompressionConfig, PyCredentials, PyGcsBearerCredential, + PyGcsCredentials, PyGcsStaticCredentials, PyManifestConfig, + PyManifestPreloadCondition, PyManifestPreloadConfig, PyObjectStoreConfig, + PyRepositoryConfig, PyS3Credentials, PyS3Options, PyS3StaticCredentials, PyStorage, + PyStorageConcurrencySettings, PyStorageSettings, PyVirtualChunkContainer, + PythonCredentialsFetcher, }; use conflicts::{ PyBasicConflictSolver, PyConflict, PyConflictDetector, PyConflictSolver, @@ -22,10 +25,26 @@ use errors::{ IcechunkError, PyConflictError, PyConflictErrorData, PyRebaseFailedData, PyRebaseFailedError, }; +use icechunk::{format::format_constants::SpecVersionBin, initialize_tracing}; use pyo3::prelude::*; -use repository::{PyGCSummary, PyRepository, PySnapshotInfo}; +use repository::{PyDiff, PyGCSummary, PyRepository, PySnapshotInfo}; use session::PySession; -use store::PyStore; +use store::{PyStore, VirtualChunkSpec}; + +#[pyfunction] +fn initialize_logs() -> PyResult<()> { + if env::var("ICECHUNK_NO_LOGS").is_err() { + initialize_tracing() + } + Ok(()) +} + +#[pyfunction] +/// The spec version that this version of the Icechunk library +/// uses to write metadata files +fn spec_version() -> u8 { + SpecVersionBin::current() as u8 +} /// The icechunk Python module implemented in Rust. #[pymodule] @@ -44,6 +63,7 @@ fn _icechunk_python(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; @@ -61,6 +81,10 @@ fn _icechunk_python(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(initialize_logs, m)?)?; + m.add_function(wrap_pyfunction!(spec_version, m)?)?; // Exceptions m.add("IcechunkError", py.get_type::())?; diff --git a/icechunk-python/src/repository.rs b/icechunk-python/src/repository.rs index f98af50d..8321d2c6 100644 --- a/icechunk-python/src/repository.rs +++ b/icechunk-python/src/repository.rs @@ -1,5 +1,6 @@ use std::{ - collections::{BTreeSet, HashMap, HashSet}, + borrow::Cow, + collections::{BTreeMap, BTreeSet, HashMap, HashSet}, sync::Arc, }; @@ -11,10 +12,11 @@ use icechunk::{ config::Credentials, format::{ snapshot::{SnapshotInfo, SnapshotProperties}, + transaction_log::Diff, SnapshotId, }, - ops::gc::{expire, garbage_collect, GCConfig, GCSummary}, - repository::{RepositoryError, VersionInfo}, + ops::gc::{expire, garbage_collect, ExpiredRefAction, GCConfig, GCSummary}, + repository::{RepositoryErrorKind, VersionInfo}, Repository, }; use pyo3::{ @@ -27,7 +29,7 @@ use tokio::sync::{Mutex, RwLock}; use crate::{ config::{ - datetime_repr, format_option_string, PyCredentials, PyRepositoryConfig, + datetime_repr, format_option_to_string, PyCredentials, PyRepositoryConfig, PyStorage, PyStorageSettings, }, errors::PyIcechunkStoreError, @@ -175,13 +177,142 @@ impl PySnapshotInfo { format!( r#"SnapshotInfo(id="{id}", parent_id={parent}, written_at={at}, message="{message}")"#, id = self.id, - parent = format_option_string(self.parent_id.as_ref()), + parent = format_option_to_string(self.parent_id.as_ref()), at = datetime_repr(&self.written_at), message = self.message.chars().take(10).collect::() + "...", ) } } +#[pyclass(name = "Diff", eq)] +#[derive(Debug, PartialEq, Eq, Default)] +pub struct PyDiff { + #[pyo3(get)] + pub new_groups: BTreeSet, + #[pyo3(get)] + pub new_arrays: BTreeSet, + #[pyo3(get)] + pub deleted_groups: BTreeSet, + #[pyo3(get)] + pub deleted_arrays: BTreeSet, + #[pyo3(get)] + pub updated_groups: BTreeSet, + #[pyo3(get)] + pub updated_arrays: BTreeSet, + #[pyo3(get)] + // A Vec instead of a set to avoid issues with list not being hashable in python + pub updated_chunks: BTreeMap>>, +} + +impl From for PyDiff { + fn from(value: Diff) -> Self { + let new_groups = + value.new_groups.into_iter().map(|path| path.to_string()).collect(); + let new_arrays = + value.new_arrays.into_iter().map(|path| path.to_string()).collect(); + let deleted_groups = + value.deleted_groups.into_iter().map(|path| path.to_string()).collect(); + let deleted_arrays = + value.deleted_arrays.into_iter().map(|path| path.to_string()).collect(); + let updated_groups = + value.updated_groups.into_iter().map(|path| path.to_string()).collect(); + let updated_arrays = + value.updated_arrays.into_iter().map(|path| path.to_string()).collect(); + let updated_chunks = value + .updated_chunks + .into_iter() + .map(|(k, v)| { + let path = k.to_string(); + let map = v.into_iter().map(|idx| idx.0).collect(); + (path, map) + }) + .collect(); + + PyDiff { + new_groups, + new_arrays, + deleted_groups, + deleted_arrays, + updated_groups, + updated_arrays, + updated_chunks, + } + } +} + +#[pymethods] +impl PyDiff { + pub fn __repr__(&self) -> String { + let mut res = String::new(); + use std::fmt::Write; + + if !self.new_groups.is_empty() { + res.push_str("Groups created:\n"); + for g in self.new_groups.iter() { + writeln!(res, " {}", g).unwrap(); + } + res.push('\n'); + } + if !self.new_arrays.is_empty() { + res.push_str("Arrays created:\n"); + for g in self.new_arrays.iter() { + writeln!(res, " {}", g).unwrap(); + } + res.push('\n'); + } + + if !self.updated_groups.is_empty() { + res.push_str("Group definitions updated:\n"); + for g in self.updated_groups.iter() { + writeln!(res, " {}", g).unwrap(); + } + res.push('\n'); + } + + if !self.updated_arrays.is_empty() { + res.push_str("Array definitions updated:\n"); + for g in self.updated_arrays.iter() { + writeln!(res, " {}", g).unwrap(); + } + res.push('\n'); + } + + if !self.deleted_groups.is_empty() { + res.push_str("Groups deleted:\n"); + for g in self.deleted_groups.iter() { + writeln!(res, " {}", g).unwrap(); + } + res.push('\n'); + } + + if !self.deleted_arrays.is_empty() { + res.push_str("Arrays deleted:\n"); + for g in self.deleted_arrays.iter() { + writeln!(res, " {}", g).unwrap(); + } + res.push('\n'); + } + + if !self.updated_chunks.is_empty() { + res.push_str("Chunks updated:\n"); + for (path, chunks) in self.updated_chunks.iter() { + writeln!(res, " {}:", path).unwrap(); + let coords = chunks + .iter() + .map(|idx| format!(" [{}]", idx.iter().join(", "))) + .take(10) + .join("\n"); + res.push_str(coords.as_str()); + res.push('\n'); + if chunks.len() > 10 { + writeln!(res, " ... {} more", chunks.len() - 10).unwrap(); + } + } + } + res + } +} + #[pyclass(name = "GCSummary", eq)] #[derive(Debug, PartialEq, Eq, Default)] pub struct PyGCSummary { @@ -333,6 +464,29 @@ impl PyRepository { }) } + #[classmethod] + fn from_bytes( + _cls: Bound<'_, PyType>, + py: Python<'_>, + bytes: Vec, + ) -> PyResult { + // This is a compute intensive task, we need to release the Gil + py.allow_threads(move || { + let repository = Repository::from_bytes(bytes) + .map_err(PyIcechunkStoreError::RepositoryError)?; + Ok(Self(Arc::new(repository))) + }) + } + + fn as_bytes(&self, py: Python<'_>) -> PyResult> { + // This is a compute intensive task, we need to release the Gil + py.allow_threads(move || { + let bytes = + self.0.as_bytes().map_err(PyIcechunkStoreError::RepositoryError)?; + Ok(Cow::Owned(bytes)) + }) + } + #[staticmethod] fn fetch_config( py: Python<'_>, @@ -375,46 +529,19 @@ impl PyRepository { PyStorage(Arc::clone(self.0.storage())) } - #[pyo3(signature = (*, branch = None, tag = None, snapshot = None))] - pub fn ancestry( - &self, - py: Python<'_>, - branch: Option, - tag: Option, - snapshot: Option, - ) -> PyResult> { - // This function calls block_on, so we need to allow other thread python to make progress - py.allow_threads(move || { - let version = args_to_version_info(branch, tag, snapshot)?; - - // TODO: this holds everything in memory - pyo3_async_runtimes::tokio::get_runtime().block_on(async move { - let ancestry = self - .0 - .ancestry(&version) - .await - .map_err(PyIcechunkStoreError::RepositoryError)? - .map_ok(Into::::into) - .try_collect::>() - .await - .map_err(PyIcechunkStoreError::RepositoryError)?; - Ok(ancestry) - }) - }) - } - - #[pyo3(signature = (*, branch = None, tag = None, snapshot = None))] + /// Returns an object that is both a sync and an async iterator + #[pyo3(signature = (*, branch = None, tag = None, snapshot_id = None))] pub fn async_ancestry( &self, py: Python<'_>, branch: Option, tag: Option, - snapshot: Option, + snapshot_id: Option, ) -> PyResult { let repo = Arc::clone(&self.0); // This function calls block_on, so we need to allow other thread python to make progress py.allow_threads(move || { - let version = args_to_version_info(branch, tag, snapshot)?; + let version = args_to_version_info(branch, tag, snapshot_id, None)?; let ancestry = pyo3_async_runtimes::tokio::get_runtime() .block_on(async move { repo.ancestry_arc(&version).await }) .map_err(PyIcechunkStoreError::RepositoryError)? @@ -441,9 +568,9 @@ impl PyRepository { // This function calls block_on, so we need to allow other thread python to make progress py.allow_threads(move || { let snapshot_id = SnapshotId::try_from(snapshot_id).map_err(|_| { - PyIcechunkStoreError::RepositoryError(RepositoryError::InvalidSnapshotId( - snapshot_id.to_owned(), - )) + PyIcechunkStoreError::RepositoryError( + RepositoryErrorKind::InvalidSnapshotId(snapshot_id.to_owned()).into(), + ) })?; pyo3_async_runtimes::tokio::get_runtime().block_on(async move { @@ -493,9 +620,9 @@ impl PyRepository { // This function calls block_on, so we need to allow other thread python to make progress py.allow_threads(move || { let snapshot_id = SnapshotId::try_from(snapshot_id).map_err(|_| { - PyIcechunkStoreError::RepositoryError(RepositoryError::InvalidSnapshotId( - snapshot_id.to_owned(), - )) + PyIcechunkStoreError::RepositoryError( + RepositoryErrorKind::InvalidSnapshotId(snapshot_id.to_owned()).into(), + ) })?; pyo3_async_runtimes::tokio::get_runtime().block_on(async move { @@ -543,9 +670,9 @@ impl PyRepository { // This function calls block_on, so we need to allow other thread python to make progress py.allow_threads(move || { let snapshot_id = SnapshotId::try_from(snapshot_id).map_err(|_| { - PyIcechunkStoreError::RepositoryError(RepositoryError::InvalidSnapshotId( - snapshot_id.to_owned(), - )) + PyIcechunkStoreError::RepositoryError( + RepositoryErrorKind::InvalidSnapshotId(snapshot_id.to_owned()).into(), + ) })?; pyo3_async_runtimes::tokio::get_runtime().block_on(async move { @@ -586,17 +713,46 @@ impl PyRepository { }) } - #[pyo3(signature = (*, branch = None, tag = None, snapshot = None))] + #[pyo3(signature = (*, from_branch=None, from_tag=None, from_snapshot_id=None, to_branch=None, to_tag=None, to_snapshot_id=None))] + #[allow(clippy::too_many_arguments)] + pub fn diff( + &self, + py: Python<'_>, + from_branch: Option, + from_tag: Option, + from_snapshot_id: Option, + to_branch: Option, + to_tag: Option, + to_snapshot_id: Option, + ) -> PyResult { + let from = args_to_version_info(from_branch, from_tag, from_snapshot_id, None)?; + let to = args_to_version_info(to_branch, to_tag, to_snapshot_id, None)?; + + // This function calls block_on, so we need to allow other thread python to make progress + py.allow_threads(move || { + pyo3_async_runtimes::tokio::get_runtime().block_on(async move { + let diff = self + .0 + .diff(&from, &to) + .await + .map_err(PyIcechunkStoreError::SessionError)?; + Ok(diff.into()) + }) + }) + } + + #[pyo3(signature = (*, branch = None, tag = None, snapshot_id = None, as_of = None))] pub fn readonly_session( &self, py: Python<'_>, branch: Option, tag: Option, - snapshot: Option, + snapshot_id: Option, + as_of: Option>, ) -> PyResult { // This function calls block_on, so we need to allow other thread python to make progress py.allow_threads(move || { - let version = args_to_version_info(branch, tag, snapshot)?; + let version = args_to_version_info(branch, tag, snapshot_id, as_of)?; let session = pyo3_async_runtimes::tokio::get_runtime().block_on(async move { self.0 @@ -624,10 +780,13 @@ impl PyRepository { }) } + #[pyo3(signature = (older_than, *, delete_expired_branches = false, delete_expired_tags = false))] pub fn expire_snapshots( &self, py: Python<'_>, older_than: DateTime, + delete_expired_branches: bool, + delete_expired_tags: bool, ) -> PyResult> { // This function calls block_on, so we need to allow other thread python to make progress py.allow_threads(move || { @@ -638,11 +797,25 @@ impl PyRepository { self.0.storage_settings(), self.0.asset_manager().clone(), older_than, + if delete_expired_branches { + ExpiredRefAction::Delete + } else { + ExpiredRefAction::Ignore + }, + if delete_expired_tags { + ExpiredRefAction::Delete + } else { + ExpiredRefAction::Ignore + }, ) .await .map_err(PyIcechunkStoreError::GCError)?; Ok::<_, PyIcechunkStoreError>( - result.iter().map(|id| id.to_string()).collect(), + result + .released_snapshots + .iter() + .map(|id| id.to_string()) + .collect(), ) })?; @@ -693,6 +866,7 @@ fn args_to_version_info( branch: Option, tag: Option, snapshot: Option, + as_of: Option>, ) -> PyResult { let n = [&branch, &tag, &snapshot].iter().filter(|r| !r.is_none()).count(); if n > 1 { @@ -701,15 +875,25 @@ fn args_to_version_info( )); } - if let Some(branch_name) = branch { - Ok(VersionInfo::BranchTipRef(branch_name)) + if as_of.is_some() && branch.is_none() { + return Err(PyValueError::new_err( + "as_of argument must be provided together with a branch name", + )); + } + + if let Some(branch) = branch { + if let Some(at) = as_of { + Ok(VersionInfo::AsOf { branch, at }) + } else { + Ok(VersionInfo::BranchTipRef(branch)) + } } else if let Some(tag_name) = tag { Ok(VersionInfo::TagRef(tag_name)) } else if let Some(snapshot_id) = snapshot { let snapshot_id = SnapshotId::try_from(snapshot_id.as_str()).map_err(|_| { - PyIcechunkStoreError::RepositoryError(RepositoryError::InvalidSnapshotId( - snapshot_id.to_owned(), - )) + PyIcechunkStoreError::RepositoryError( + RepositoryErrorKind::InvalidSnapshotId(snapshot_id.to_owned()).into(), + ) })?; Ok(VersionInfo::SnapshotId(snapshot_id)) diff --git a/icechunk-python/src/session.rs b/icechunk-python/src/session.rs index 13d17c10..7f8c0bf6 100644 --- a/icechunk-python/src/session.rs +++ b/icechunk-python/src/session.rs @@ -9,7 +9,7 @@ use tokio::sync::{Mutex, RwLock}; use crate::{ conflicts::PyConflictSolver, errors::{PyIcechunkStoreError, PyIcechunkStoreResult}, - repository::PySnapshotProperties, + repository::{PyDiff, PySnapshotProperties}, store::PyStore, streams::PyAsyncGenerator, }; @@ -73,6 +73,19 @@ impl PySession { py.allow_threads(move || self.0.blocking_read().has_uncommitted_changes()) } + pub fn status(&self, py: Python<'_>) -> PyResult { + // This is blocking function, we need to release the Gil + py.allow_threads(move || { + let session = self.0.blocking_read(); + + pyo3_async_runtimes::tokio::get_runtime().block_on(async move { + let res = + session.status().await.map_err(PyIcechunkStoreError::SessionError)?; + Ok(res.into()) + }) + }) + } + pub fn discard_changes(&self, py: Python<'_>) { // This is blocking function, we need to release the Gil py.allow_threads(move || { diff --git a/icechunk-python/src/store.rs b/icechunk-python/src/store.rs index 5cf67ea7..f3b1832a 100644 --- a/icechunk-python/src/store.rs +++ b/icechunk-python/src/store.rs @@ -6,15 +6,17 @@ use futures::{StreamExt, TryStreamExt}; use icechunk::{ format::{ manifest::{Checksum, SecondsSinceEpoch, VirtualChunkLocation, VirtualChunkRef}, - ChunkLength, ChunkOffset, + ChunkIndices, ChunkLength, ChunkOffset, Path, }, - store::StoreError, + storage::ETag, + store::{SetVirtualRefsResult, StoreError, StoreErrorKind}, Store, }; +use itertools::Itertools as _; use pyo3::{ exceptions::{PyKeyError, PyValueError}, prelude::*, - types::PyType, + types::{PyTuple, PyType}, }; use tokio::sync::Mutex; @@ -37,7 +39,7 @@ enum ChecksumArgument { impl From for Checksum { fn from(value: ChecksumArgument) -> Self { match value { - ChecksumArgument::String(etag) => Checksum::ETag(etag), + ChecksumArgument::String(etag) => Checksum::ETag(ETag(etag)), ChecksumArgument::Datetime(date_time) => { Checksum::LastModified(SecondsSinceEpoch(date_time.timestamp() as u32)) } @@ -45,6 +47,50 @@ impl From for Checksum { } } +#[pyclass] +#[derive(Clone, Debug)] +pub struct VirtualChunkSpec { + #[pyo3(get)] + index: Vec, + #[pyo3(get)] + location: String, + #[pyo3(get)] + offset: ChunkOffset, + #[pyo3(get)] + length: ChunkLength, + #[pyo3(get)] + etag_checksum: Option, + #[pyo3(get)] + last_updated_at_checksum: Option>, +} + +impl VirtualChunkSpec { + fn checksum(&self) -> Option { + self.etag_checksum + .as_ref() + .map(|etag| Checksum::ETag(ETag(etag.clone()))) + .or(self + .last_updated_at_checksum + .map(|t| Checksum::LastModified(SecondsSinceEpoch(t.timestamp() as u32)))) + } +} + +#[pymethods] +impl VirtualChunkSpec { + #[new] + #[pyo3(signature = (index, location, offset, length, etag_checksum = None, last_updated_at_checksum = None))] + fn new( + index: Vec, + location: String, + offset: ChunkOffset, + length: ChunkLength, + etag_checksum: Option, + last_updated_at_checksum: Option>, + ) -> Self { + Self { index, location, offset, length, etag_checksum, last_updated_at_checksum } + } +} + #[pyclass(name = "PyStore")] #[derive(Clone)] pub struct PyStore(pub Arc); @@ -151,7 +197,9 @@ impl PyStore { // from other types of errors, we use PyKeyError exception for that match data { Ok(data) => Ok(Vec::from(data)), - Err(StoreError::NotFound(_)) => Err(PyKeyError::new_err(key)), + Err(StoreError { kind: StoreErrorKind::NotFound(_), .. }) => { + Err(PyKeyError::new_err(key)) + } Err(err) => Err(PyIcechunkStoreError::StoreError(err).into()), } }) @@ -278,6 +326,60 @@ impl PyStore { }) } + fn set_virtual_refs( + &self, + py: Python<'_>, + array_path: String, + chunks: Vec, + validate_containers: bool, + ) -> PyIcechunkStoreResult>>> { + py.allow_threads(move || { + let store = Arc::clone(&self.0); + + let res = pyo3_async_runtimes::tokio::get_runtime().block_on(async move { + let vrefs = chunks.into_iter().map(|vcs| { + let checksum = vcs.checksum(); + let index = ChunkIndices(vcs.index); + let vref = VirtualChunkRef { + location: VirtualChunkLocation(vcs.location), + offset: vcs.offset, + length: vcs.length, + checksum, + }; + (index, vref) + }); + + let array_path = if !array_path.starts_with("/") { + format!("/{}", array_path) + } else { + array_path.to_string() + }; + + let path = Path::try_from(array_path).map_err(|e| { + PyValueError::new_err(format!("Invalid array path: {}", e)) + })?; + + let res = store + .set_virtual_refs(&path, validate_containers, vrefs) + .await + .map_err(PyIcechunkStoreError::from)?; + Ok::<_, PyIcechunkStoreError>(res) + })?; + + match res { + SetVirtualRefsResult::Done => Ok(None), + SetVirtualRefsResult::FailedRefs(vec) => Python::with_gil(|py| { + let res = vec + .into_iter() + .map(|ci| PyTuple::new(py, ci.0).map(|tup| tup.unbind())) + .try_collect()?; + + Ok(Some(res)) + }), + } + }) + } + fn delete<'py>( &'py self, py: Python<'py>, @@ -420,4 +522,19 @@ impl PyStore { Ok(size) }) } + + fn getsize_prefix<'py>( + &self, + py: Python<'py>, + prefix: String, + ) -> PyResult> { + let store = Arc::clone(&self.0); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let size = store + .getsize_prefix(prefix.as_str()) + .await + .map_err(PyIcechunkStoreError::from)?; + Ok(size) + }) + } } diff --git a/icechunk-python/src/streams.rs b/icechunk-python/src/streams.rs index f4b06408..2deae06a 100644 --- a/icechunk-python/src/streams.rs +++ b/icechunk-python/src/streams.rs @@ -1,7 +1,10 @@ use std::{pin::Pin, sync::Arc}; use futures::{Stream, StreamExt}; -use pyo3::{exceptions::PyStopAsyncIteration, prelude::*}; +use pyo3::{ + exceptions::{PyStopAsyncIteration, PyStopIteration}, + prelude::*, +}; use tokio::sync::Mutex; type PyObjectStream = Arc>> + Send>>>>; @@ -31,6 +34,10 @@ impl PyAsyncGenerator { slf } + fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + /// This is an anext implementation. /// /// Notable thing here is that we return PyResult>. @@ -62,4 +69,25 @@ impl PyAsyncGenerator { // of pyo3? pyo3_async_runtimes::tokio::future_into_py(py, future) } + + fn __next__<'py>( + slf: PyRefMut<'py, Self>, + py: Python<'py>, + ) -> PyResult> { + // Arc::clone is cheap, so we can clone the Arc here because we move into the + // future block + let stream = slf.stream.clone(); + + py.allow_threads(move || { + let next = pyo3_async_runtimes::tokio::get_runtime().block_on(async move { + let mut unlocked = stream.lock().await; + unlocked.next().await + }); + match next { + Some(Ok(val)) => Ok(Some(val)), + Some(Err(err)) => Err(err), + None => Err(PyStopIteration::new_err("The iterator is exhausted")), + } + }) + } } diff --git a/icechunk-python/tests/data/test-repo/chunks/2TF4E0AY7Y4V5081TANG b/icechunk-python/tests/data/test-repo/chunks/09HEW2P03CSMHFAZY7DG similarity index 100% rename from icechunk-python/tests/data/test-repo/chunks/2TF4E0AY7Y4V5081TANG rename to icechunk-python/tests/data/test-repo/chunks/09HEW2P03CSMHFAZY7DG diff --git a/icechunk-python/tests/data/test-repo/chunks/DV031EJC9785Q61Y8VHG b/icechunk-python/tests/data/test-repo/chunks/52H0E4NSPN8SVRK9EVGG similarity index 100% rename from icechunk-python/tests/data/test-repo/chunks/DV031EJC9785Q61Y8VHG rename to icechunk-python/tests/data/test-repo/chunks/52H0E4NSPN8SVRK9EVGG diff --git a/icechunk-python/tests/data/test-repo/chunks/HKZ28FFV4Q7D070R2BJG b/icechunk-python/tests/data/test-repo/chunks/DWQ75SDC624XF9H326RG similarity index 100% rename from icechunk-python/tests/data/test-repo/chunks/HKZ28FFV4Q7D070R2BJG rename to icechunk-python/tests/data/test-repo/chunks/DWQ75SDC624XF9H326RG diff --git a/icechunk-python/tests/data/test-repo/chunks/ZEEGCRCJ90NJZ58SFA60 b/icechunk-python/tests/data/test-repo/chunks/RW938N1KP2R4BHMW62QG similarity index 100% rename from icechunk-python/tests/data/test-repo/chunks/ZEEGCRCJ90NJZ58SFA60 rename to icechunk-python/tests/data/test-repo/chunks/RW938N1KP2R4BHMW62QG diff --git a/icechunk-python/tests/data/test-repo/config.yaml b/icechunk-python/tests/data/test-repo/config.yaml index 71b6f838..80e47459 100644 --- a/icechunk-python/tests/data/test-repo/config.yaml +++ b/icechunk-python/tests/data/test-repo/config.yaml @@ -1,22 +1,9 @@ inline_chunk_threshold_bytes: 12 -unsafe_overwrite_refs: null get_partial_values_concurrency: null compression: null caching: null storage: null virtual_chunk_containers: - s3: - name: s3 - url_prefix: s3:// - store: !s3_compatible - region: us-east-1 - endpoint_url: http://localhost:9000 - anonymous: false - allow_http: true - az: - name: az - url_prefix: az - store: !azure {} tigris: name: tigris url_prefix: tigris @@ -25,11 +12,24 @@ virtual_chunk_containers: endpoint_url: https://fly.storage.tigris.dev anonymous: false allow_http: false - gcs: - name: gcs - url_prefix: gcs - store: !gcs {} + az: + name: az + url_prefix: az + store: !azure {} file: name: file url_prefix: file store: !local_file_system '' + gcs: + name: gcs + url_prefix: gcs + store: !gcs {} + s3: + name: s3 + url_prefix: s3:// + store: !s3_compatible + region: us-east-1 + endpoint_url: http://localhost:9000 + anonymous: false + allow_http: true +manifest: null diff --git a/icechunk-python/tests/data/test-repo/manifests/3AS90VB6T0GSE4J67XX0 b/icechunk-python/tests/data/test-repo/manifests/3AS90VB6T0GSE4J67XX0 deleted file mode 100644 index 5f709f25..00000000 Binary files a/icechunk-python/tests/data/test-repo/manifests/3AS90VB6T0GSE4J67XX0 and /dev/null differ diff --git a/icechunk-python/tests/data/test-repo/manifests/5ZW0V1ZQXQ16804S897G b/icechunk-python/tests/data/test-repo/manifests/5ZW0V1ZQXQ16804S897G deleted file mode 100644 index b2f7c0ab..00000000 Binary files a/icechunk-python/tests/data/test-repo/manifests/5ZW0V1ZQXQ16804S897G and /dev/null differ diff --git a/icechunk-python/tests/data/test-repo/manifests/CMYVHDWMSTG9R25780YG b/icechunk-python/tests/data/test-repo/manifests/CMYVHDWMSTG9R25780YG new file mode 100644 index 00000000..1cd56eb3 Binary files /dev/null and b/icechunk-python/tests/data/test-repo/manifests/CMYVHDWMSTG9R25780YG differ diff --git a/icechunk-python/tests/data/test-repo/manifests/G3W2W8V6ZG09J6C21WE0 b/icechunk-python/tests/data/test-repo/manifests/G3W2W8V6ZG09J6C21WE0 new file mode 100644 index 00000000..a107d98a Binary files /dev/null and b/icechunk-python/tests/data/test-repo/manifests/G3W2W8V6ZG09J6C21WE0 differ diff --git a/icechunk-python/tests/data/test-repo/manifests/Q04J7QW5RQ8D17TPA10G b/icechunk-python/tests/data/test-repo/manifests/Q04J7QW5RQ8D17TPA10G new file mode 100644 index 00000000..d3ea1cd3 Binary files /dev/null and b/icechunk-python/tests/data/test-repo/manifests/Q04J7QW5RQ8D17TPA10G differ diff --git a/icechunk-python/tests/data/test-repo/manifests/R666SBH9YHZMB04ZMARG b/icechunk-python/tests/data/test-repo/manifests/R666SBH9YHZMB04ZMARG deleted file mode 100644 index c1bf6973..00000000 Binary files a/icechunk-python/tests/data/test-repo/manifests/R666SBH9YHZMB04ZMARG and /dev/null differ diff --git a/icechunk-python/tests/data/test-repo/manifests/SHTEAP8C784YMZSJKBM0 b/icechunk-python/tests/data/test-repo/manifests/SHTEAP8C784YMZSJKBM0 new file mode 100644 index 00000000..28a7e014 Binary files /dev/null and b/icechunk-python/tests/data/test-repo/manifests/SHTEAP8C784YMZSJKBM0 differ diff --git a/icechunk-python/tests/data/test-repo/manifests/STWYFSPWFCD62MQTDM20 b/icechunk-python/tests/data/test-repo/manifests/STWYFSPWFCD62MQTDM20 deleted file mode 100644 index 0abad3e1..00000000 Binary files a/icechunk-python/tests/data/test-repo/manifests/STWYFSPWFCD62MQTDM20 and /dev/null differ diff --git a/icechunk-python/tests/data/test-repo/refs/branch.main/ZZZZZZZW.json b/icechunk-python/tests/data/test-repo/refs/branch.main/ZZZZZZZW.json deleted file mode 100644 index affc0587..00000000 --- a/icechunk-python/tests/data/test-repo/refs/branch.main/ZZZZZZZW.json +++ /dev/null @@ -1 +0,0 @@ -{"snapshot":"FK0CX5JQH2DVDZ6PD6WG"} \ No newline at end of file diff --git a/icechunk-python/tests/data/test-repo/refs/branch.main/ZZZZZZZX.json b/icechunk-python/tests/data/test-repo/refs/branch.main/ZZZZZZZX.json deleted file mode 100644 index 563cc044..00000000 --- a/icechunk-python/tests/data/test-repo/refs/branch.main/ZZZZZZZX.json +++ /dev/null @@ -1 +0,0 @@ -{"snapshot":"KCR7ES7JPCBY23X6MY3G"} \ No newline at end of file diff --git a/icechunk-python/tests/data/test-repo/refs/branch.main/ZZZZZZZY.json b/icechunk-python/tests/data/test-repo/refs/branch.main/ZZZZZZZY.json deleted file mode 100644 index fe6793c8..00000000 --- a/icechunk-python/tests/data/test-repo/refs/branch.main/ZZZZZZZY.json +++ /dev/null @@ -1 +0,0 @@ -{"snapshot":"QY5JG2BWG2VPPDJR4JE0"} \ No newline at end of file diff --git a/icechunk-python/tests/data/test-repo/refs/branch.main/ZZZZZZZZ.json b/icechunk-python/tests/data/test-repo/refs/branch.main/ZZZZZZZZ.json deleted file mode 100644 index 0ba516a6..00000000 --- a/icechunk-python/tests/data/test-repo/refs/branch.main/ZZZZZZZZ.json +++ /dev/null @@ -1 +0,0 @@ -{"snapshot":"VNPWJSZWB9G990XV1V8G"} \ No newline at end of file diff --git a/icechunk-python/tests/data/test-repo/refs/branch.main/ref.json b/icechunk-python/tests/data/test-repo/refs/branch.main/ref.json new file mode 100644 index 00000000..8fa8aba9 --- /dev/null +++ b/icechunk-python/tests/data/test-repo/refs/branch.main/ref.json @@ -0,0 +1 @@ +{"snapshot":"NXH3M0HJ7EEJ0699DPP0"} \ No newline at end of file diff --git a/icechunk-python/tests/data/test-repo/refs/branch.my-branch/ZZZZZZZX.json b/icechunk-python/tests/data/test-repo/refs/branch.my-branch/ZZZZZZZX.json deleted file mode 100644 index 340ac458..00000000 --- a/icechunk-python/tests/data/test-repo/refs/branch.my-branch/ZZZZZZZX.json +++ /dev/null @@ -1 +0,0 @@ -{"snapshot":"G0BR0G9NKT75ZZS7BWWG"} \ No newline at end of file diff --git a/icechunk-python/tests/data/test-repo/refs/branch.my-branch/ZZZZZZZY.json b/icechunk-python/tests/data/test-repo/refs/branch.my-branch/ZZZZZZZY.json deleted file mode 100644 index 13219d1c..00000000 --- a/icechunk-python/tests/data/test-repo/refs/branch.my-branch/ZZZZZZZY.json +++ /dev/null @@ -1 +0,0 @@ -{"snapshot":"9W0W1DS2BKRV4MK2A2S0"} \ No newline at end of file diff --git a/icechunk-python/tests/data/test-repo/refs/branch.my-branch/ZZZZZZZZ.json b/icechunk-python/tests/data/test-repo/refs/branch.my-branch/ZZZZZZZZ.json deleted file mode 100644 index affc0587..00000000 --- a/icechunk-python/tests/data/test-repo/refs/branch.my-branch/ZZZZZZZZ.json +++ /dev/null @@ -1 +0,0 @@ -{"snapshot":"FK0CX5JQH2DVDZ6PD6WG"} \ No newline at end of file diff --git a/icechunk-python/tests/data/test-repo/refs/branch.my-branch/ref.json b/icechunk-python/tests/data/test-repo/refs/branch.my-branch/ref.json new file mode 100644 index 00000000..a4b2ffa4 --- /dev/null +++ b/icechunk-python/tests/data/test-repo/refs/branch.my-branch/ref.json @@ -0,0 +1 @@ +{"snapshot":"XDZ162T1TYBEJMK99NPG"} \ No newline at end of file diff --git a/icechunk-python/tests/data/test-repo/refs/tag.deleted/ref.json b/icechunk-python/tests/data/test-repo/refs/tag.deleted/ref.json index 13219d1c..b84c0bfc 100644 --- a/icechunk-python/tests/data/test-repo/refs/tag.deleted/ref.json +++ b/icechunk-python/tests/data/test-repo/refs/tag.deleted/ref.json @@ -1 +1 @@ -{"snapshot":"9W0W1DS2BKRV4MK2A2S0"} \ No newline at end of file +{"snapshot":"4QF8JA0YPDN51MHSSYVG"} \ No newline at end of file diff --git a/icechunk-python/tests/data/test-repo/refs/tag.it also works!/ref.json b/icechunk-python/tests/data/test-repo/refs/tag.it also works!/ref.json index 340ac458..a4b2ffa4 100644 --- a/icechunk-python/tests/data/test-repo/refs/tag.it also works!/ref.json +++ b/icechunk-python/tests/data/test-repo/refs/tag.it also works!/ref.json @@ -1 +1 @@ -{"snapshot":"G0BR0G9NKT75ZZS7BWWG"} \ No newline at end of file +{"snapshot":"XDZ162T1TYBEJMK99NPG"} \ No newline at end of file diff --git a/icechunk-python/tests/data/test-repo/refs/tag.it works!/ref.json b/icechunk-python/tests/data/test-repo/refs/tag.it works!/ref.json index 13219d1c..b84c0bfc 100644 --- a/icechunk-python/tests/data/test-repo/refs/tag.it works!/ref.json +++ b/icechunk-python/tests/data/test-repo/refs/tag.it works!/ref.json @@ -1 +1 @@ -{"snapshot":"9W0W1DS2BKRV4MK2A2S0"} \ No newline at end of file +{"snapshot":"4QF8JA0YPDN51MHSSYVG"} \ No newline at end of file diff --git a/icechunk-python/tests/data/test-repo/snapshots/4QF8JA0YPDN51MHSSYVG b/icechunk-python/tests/data/test-repo/snapshots/4QF8JA0YPDN51MHSSYVG new file mode 100644 index 00000000..14bba3bf Binary files /dev/null and b/icechunk-python/tests/data/test-repo/snapshots/4QF8JA0YPDN51MHSSYVG differ diff --git a/icechunk-python/tests/data/test-repo/snapshots/7XAF0Q905SH4938DN9CG b/icechunk-python/tests/data/test-repo/snapshots/7XAF0Q905SH4938DN9CG new file mode 100644 index 00000000..d5dc1dfb Binary files /dev/null and b/icechunk-python/tests/data/test-repo/snapshots/7XAF0Q905SH4938DN9CG differ diff --git a/icechunk-python/tests/data/test-repo/snapshots/9W0W1DS2BKRV4MK2A2S0 b/icechunk-python/tests/data/test-repo/snapshots/9W0W1DS2BKRV4MK2A2S0 deleted file mode 100644 index e37464ed..00000000 Binary files a/icechunk-python/tests/data/test-repo/snapshots/9W0W1DS2BKRV4MK2A2S0 and /dev/null differ diff --git a/icechunk-python/tests/data/test-repo/snapshots/FK0CX5JQH2DVDZ6PD6WG b/icechunk-python/tests/data/test-repo/snapshots/FK0CX5JQH2DVDZ6PD6WG deleted file mode 100644 index 9454635d..00000000 Binary files a/icechunk-python/tests/data/test-repo/snapshots/FK0CX5JQH2DVDZ6PD6WG and /dev/null differ diff --git a/icechunk-python/tests/data/test-repo/snapshots/G0BR0G9NKT75ZZS7BWWG b/icechunk-python/tests/data/test-repo/snapshots/G0BR0G9NKT75ZZS7BWWG deleted file mode 100644 index 8d4c7da9..00000000 Binary files a/icechunk-python/tests/data/test-repo/snapshots/G0BR0G9NKT75ZZS7BWWG and /dev/null differ diff --git a/icechunk-python/tests/data/test-repo/snapshots/GC4YVH5SKBPEZCENYQE0 b/icechunk-python/tests/data/test-repo/snapshots/GC4YVH5SKBPEZCENYQE0 new file mode 100644 index 00000000..53a1a52d Binary files /dev/null and b/icechunk-python/tests/data/test-repo/snapshots/GC4YVH5SKBPEZCENYQE0 differ diff --git a/icechunk-python/tests/data/test-repo/snapshots/KCR7ES7JPCBY23X6MY3G b/icechunk-python/tests/data/test-repo/snapshots/KCR7ES7JPCBY23X6MY3G deleted file mode 100644 index 863c0d8d..00000000 Binary files a/icechunk-python/tests/data/test-repo/snapshots/KCR7ES7JPCBY23X6MY3G and /dev/null differ diff --git a/icechunk-python/tests/data/test-repo/snapshots/NXH3M0HJ7EEJ0699DPP0 b/icechunk-python/tests/data/test-repo/snapshots/NXH3M0HJ7EEJ0699DPP0 new file mode 100644 index 00000000..7921d022 Binary files /dev/null and b/icechunk-python/tests/data/test-repo/snapshots/NXH3M0HJ7EEJ0699DPP0 differ diff --git a/icechunk-python/tests/data/test-repo/snapshots/P874YS3J196959RDHX7G b/icechunk-python/tests/data/test-repo/snapshots/P874YS3J196959RDHX7G new file mode 100644 index 00000000..5af21bb1 Binary files /dev/null and b/icechunk-python/tests/data/test-repo/snapshots/P874YS3J196959RDHX7G differ diff --git a/icechunk-python/tests/data/test-repo/snapshots/QY5JG2BWG2VPPDJR4JE0 b/icechunk-python/tests/data/test-repo/snapshots/QY5JG2BWG2VPPDJR4JE0 deleted file mode 100644 index 490b659e..00000000 Binary files a/icechunk-python/tests/data/test-repo/snapshots/QY5JG2BWG2VPPDJR4JE0 and /dev/null differ diff --git a/icechunk-python/tests/data/test-repo/snapshots/VNPWJSZWB9G990XV1V8G b/icechunk-python/tests/data/test-repo/snapshots/VNPWJSZWB9G990XV1V8G deleted file mode 100644 index e854673c..00000000 Binary files a/icechunk-python/tests/data/test-repo/snapshots/VNPWJSZWB9G990XV1V8G and /dev/null differ diff --git a/icechunk-python/tests/data/test-repo/snapshots/XDZ162T1TYBEJMK99NPG b/icechunk-python/tests/data/test-repo/snapshots/XDZ162T1TYBEJMK99NPG new file mode 100644 index 00000000..b4162290 Binary files /dev/null and b/icechunk-python/tests/data/test-repo/snapshots/XDZ162T1TYBEJMK99NPG differ diff --git a/icechunk-python/tests/data/test-repo/transactions/4QF8JA0YPDN51MHSSYVG b/icechunk-python/tests/data/test-repo/transactions/4QF8JA0YPDN51MHSSYVG new file mode 100644 index 00000000..b92c29da Binary files /dev/null and b/icechunk-python/tests/data/test-repo/transactions/4QF8JA0YPDN51MHSSYVG differ diff --git a/icechunk-python/tests/data/test-repo/transactions/7XAF0Q905SH4938DN9CG b/icechunk-python/tests/data/test-repo/transactions/7XAF0Q905SH4938DN9CG new file mode 100644 index 00000000..c3df8b96 Binary files /dev/null and b/icechunk-python/tests/data/test-repo/transactions/7XAF0Q905SH4938DN9CG differ diff --git a/icechunk-python/tests/data/test-repo/transactions/9W0W1DS2BKRV4MK2A2S0 b/icechunk-python/tests/data/test-repo/transactions/9W0W1DS2BKRV4MK2A2S0 deleted file mode 100644 index 5ca25ace..00000000 Binary files a/icechunk-python/tests/data/test-repo/transactions/9W0W1DS2BKRV4MK2A2S0 and /dev/null differ diff --git a/icechunk-python/tests/data/test-repo/transactions/FK0CX5JQH2DVDZ6PD6WG b/icechunk-python/tests/data/test-repo/transactions/FK0CX5JQH2DVDZ6PD6WG deleted file mode 100644 index 7b1c5423..00000000 Binary files a/icechunk-python/tests/data/test-repo/transactions/FK0CX5JQH2DVDZ6PD6WG and /dev/null differ diff --git a/icechunk-python/tests/data/test-repo/transactions/G0BR0G9NKT75ZZS7BWWG b/icechunk-python/tests/data/test-repo/transactions/G0BR0G9NKT75ZZS7BWWG deleted file mode 100644 index c900dd07..00000000 Binary files a/icechunk-python/tests/data/test-repo/transactions/G0BR0G9NKT75ZZS7BWWG and /dev/null differ diff --git a/icechunk-python/tests/data/test-repo/transactions/GC4YVH5SKBPEZCENYQE0 b/icechunk-python/tests/data/test-repo/transactions/GC4YVH5SKBPEZCENYQE0 new file mode 100644 index 00000000..efdbb166 Binary files /dev/null and b/icechunk-python/tests/data/test-repo/transactions/GC4YVH5SKBPEZCENYQE0 differ diff --git a/icechunk-python/tests/data/test-repo/transactions/KCR7ES7JPCBY23X6MY3G b/icechunk-python/tests/data/test-repo/transactions/KCR7ES7JPCBY23X6MY3G deleted file mode 100644 index 8fda193c..00000000 Binary files a/icechunk-python/tests/data/test-repo/transactions/KCR7ES7JPCBY23X6MY3G and /dev/null differ diff --git a/icechunk-python/tests/data/test-repo/transactions/NXH3M0HJ7EEJ0699DPP0 b/icechunk-python/tests/data/test-repo/transactions/NXH3M0HJ7EEJ0699DPP0 new file mode 100644 index 00000000..12daffcb Binary files /dev/null and b/icechunk-python/tests/data/test-repo/transactions/NXH3M0HJ7EEJ0699DPP0 differ diff --git a/icechunk-python/tests/data/test-repo/transactions/QY5JG2BWG2VPPDJR4JE0 b/icechunk-python/tests/data/test-repo/transactions/QY5JG2BWG2VPPDJR4JE0 deleted file mode 100644 index e8eea4ab..00000000 Binary files a/icechunk-python/tests/data/test-repo/transactions/QY5JG2BWG2VPPDJR4JE0 and /dev/null differ diff --git a/icechunk-python/tests/data/test-repo/transactions/XDZ162T1TYBEJMK99NPG b/icechunk-python/tests/data/test-repo/transactions/XDZ162T1TYBEJMK99NPG new file mode 100644 index 00000000..bcd46039 Binary files /dev/null and b/icechunk-python/tests/data/test-repo/transactions/XDZ162T1TYBEJMK99NPG differ diff --git a/icechunk-python/tests/test_can_read_old.py b/icechunk-python/tests/test_can_read_old.py index cf9efcb2..4931244c 100644 --- a/icechunk-python/tests/test_can_read_old.py +++ b/icechunk-python/tests/test_can_read_old.py @@ -177,7 +177,7 @@ async def test_icechunk_can_read_old_repo() -> None: "Repository initialized", ] assert [ - p.message for p in repo.ancestry(snapshot=main_snapshot) + p.message for p in repo.ancestry(snapshot_id=main_snapshot) ] == expected_main_history expected_branch_history = [ @@ -255,6 +255,25 @@ async def test_icechunk_can_read_old_repo() -> None: big_chunks = root["group1/big_chunks"] assert_array_equal(big_chunks[:], 42.0) + parents = list(repo.ancestry(branch="main")) + diff = repo.diff(to_branch="main", from_snapshot_id=parents[-2].id) + assert diff.new_groups == set() + assert diff.new_arrays == set() + assert set(diff.updated_chunks.keys()) == { + "/group1/big_chunks", + "/group1/small_chunks", + } + assert sorted(diff.updated_chunks["/group1/big_chunks"]) == sorted( + [[i, j] for i in range(2) for j in range(2)] + ) + assert sorted(diff.updated_chunks["/group1/small_chunks"]) == sorted( + [[i] for i in range(5)] + ) + assert diff.deleted_groups == set() + assert diff.deleted_arrays == set() + assert diff.updated_groups == set() + assert diff.updated_arrays == set() + if __name__ == "__main__": import asyncio diff --git a/icechunk-python/tests/test_commit_properties.py b/icechunk-python/tests/test_commit_properties.py index 18bc59e7..bfebcec0 100644 --- a/icechunk-python/tests/test_commit_properties.py +++ b/icechunk-python/tests/test_commit_properties.py @@ -27,7 +27,7 @@ def test_property_types() -> None: } snapshot_id = session.commit("some commit", props) - info = repo.ancestry(branch="main")[0] + info = next(iter(repo.ancestry(branch="main"))) assert info.message == "some commit" assert info.id == snapshot_id assert info.parent_id == parent_id diff --git a/icechunk-python/tests/test_concurrency.py b/icechunk-python/tests/test_concurrency.py index ab644778..bf6eb0a1 100644 --- a/icechunk-python/tests/test_concurrency.py +++ b/icechunk-python/tests/test_concurrency.py @@ -1,8 +1,17 @@ import asyncio +import concurrent.futures import random +import time +import uuid +from random import randrange +from threading import Event + +import pytest +from termcolor import colored import icechunk import zarr +from tests.conftest import write_chunks_to_minio N = 15 @@ -81,3 +90,172 @@ async def test_concurrency() -> None: for x in range(N): for y in range(N - 1): assert array[x, y] == x * y + + +@pytest.mark.filterwarnings("ignore:datetime.datetime.utcnow") +async def test_thread_concurrency() -> None: + """Run multiple threads doing different type of operations for SECONDS_TO_RUN seconds. + + The threads execute 5 types of operations: reads, native writes, virtual writes, deletes and lists. + + We launch THREADS threads of each type, and at the end we assert all operation types executed + a few times. + + The output prints a block character for each operation, colored according to the operation type. + The expectation is blocks of different colors should interleave. + """ + THREADS = 20 + SECONDS_TO_RUN = 1 + + prefix = str(uuid.uuid4()) + write_chunks_to_minio( + [ + (f"{prefix}/chunk-1", b"first"), + (f"{prefix}/chunk-2", b"second"), + ], + ) + + config = icechunk.RepositoryConfig.default() + store_config = icechunk.s3_store( + region="us-east-1", + endpoint_url="http://localhost:9000", + allow_http=True, + s3_compatible=True, + ) + container = icechunk.VirtualChunkContainer("s3", "s3://", store_config) + config.set_virtual_chunk_container(container) + config.inline_chunk_threshold_bytes = 0 + credentials = icechunk.containers_credentials( + s3=icechunk.s3_credentials(access_key_id="minio123", secret_access_key="minio123") + ) + + storage = icechunk.s3_storage( + region="us-east-1", + endpoint_url="http://localhost:9000", + allow_http=True, + bucket="testbucket", + prefix="multithreaded-test__" + str(time.time()), + access_key_id="minio123", + secret_access_key="minio123", + ) + + # Open the store + repo = icechunk.Repository.create( + storage=storage, + config=config, + virtual_chunk_credentials=credentials, + ) + + session = repo.writable_session("main") + store = session.store + + group = zarr.group(store=store, overwrite=True) + group.create_array("array", shape=(1_000,), chunks=(1,), dtype="i4", compressors=None) + + def do_virtual_writes(start, stop): + n = 0 + start.wait() + while not stop.is_set(): + i = randrange(1_000) + store.set_virtual_ref( + f"array/c/{i}", + f"s3://testbucket/{prefix}/chunk-1", + offset=0, + length=4, + ) + print(colored("■", "green"), end="") + n += 1 + return n + + def do_native_writes(start, stop): + async def do(): + n = 0 + while not stop.is_set(): + i = randrange(1_000) + await store.set( + f"array/c/{i}", zarr.core.buffer.cpu.Buffer.from_bytes(b"0123") + ) + print(colored("■", "white"), end="") + n += 1 + return n + + start.wait() + return asyncio.run(do()) + + def do_reads(start, stop): + buffer_prototype = zarr.core.buffer.default_buffer_prototype() + + async def do(): + n = 0 + while not stop.is_set(): + i = randrange(1_000) + await store.get(f"array/c/{i}", prototype=buffer_prototype) + print(colored("■", "blue"), end="") + n += 1 + return n + + start.wait() + return asyncio.run(do()) + + def do_deletes(start, stop): + async def do(): + n = 0 + while not stop.is_set(): + i = randrange(1_000) + await store.delete(f"array/c/{i}") + print(colored("■", "red"), end="") + n += 1 + return n + + start.wait() + return asyncio.run(do()) + + def do_lists(start, stop): + async def do(): + n = 0 + while not stop.is_set(): + _ = [k async for k in store.list_prefix("")] + print(colored("■", "yellow"), end="") + n += 1 + return n + + start.wait() + return asyncio.run(do()) + + with concurrent.futures.ThreadPoolExecutor(max_workers=THREADS * 5) as pool: + virtual_writes = [] + native_writes = [] + deletes = [] + reads = [] + lists = [] + + start = Event() + stop = Event() + + for _ in range(THREADS): + virtual_writes.append(pool.submit(do_virtual_writes, start, stop)) + native_writes.append(pool.submit(do_native_writes, start, stop)) + deletes.append(pool.submit(do_deletes, start, stop)) + reads.append(pool.submit(do_reads, start, stop)) + lists.append(pool.submit(do_lists, start, stop)) + + start.set() + time.sleep(SECONDS_TO_RUN) + stop.set() + + virtual_writes = sum(future.result() for future in virtual_writes) + native_writes = sum(future.result() for future in native_writes) + deletes = sum(future.result() for future in deletes) + reads = sum(future.result() for future in reads) + lists = sum(future.result() for future in lists) + + print() + print( + f"virtual writes: {virtual_writes}, native writes: {native_writes}, deletes: {deletes}, reads: {reads}, lists: {lists}" + ) + + assert virtual_writes > 2 + assert native_writes > 2 + assert deletes > 2 + assert reads > 2 + assert lists > 2 diff --git a/icechunk-python/tests/test_config.py b/icechunk-python/tests/test_config.py index 53a03693..dc1b0843 100644 --- a/icechunk-python/tests/test_config.py +++ b/icechunk-python/tests/test_config.py @@ -127,7 +127,7 @@ def test_virtual_chunk_containers() -> None: container = icechunk.VirtualChunkContainer("custom", "s3://", store_config) config.set_virtual_chunk_container(container) assert re.match( - r"RepositoryConfig\(inline_chunk_threshold_bytes=None, unsafe_overwrite_refs=None, get_partial_values_concurrency=None, compression=None, caching=None, storage=None, manifest=.*\)", + r"RepositoryConfig\(inline_chunk_threshold_bytes=None, get_partial_values_concurrency=None, compression=None, caching=None, storage=None, manifest=.*\)", repr(config), ) assert config.virtual_chunk_containers @@ -158,11 +158,9 @@ def test_can_change_deep_config_values() -> None: ) config = icechunk.RepositoryConfig( inline_chunk_threshold_bytes=11, - unsafe_overwrite_refs=False, compression=icechunk.CompressionConfig(level=0), ) config.inline_chunk_threshold_bytes = 5 - config.unsafe_overwrite_refs = True config.get_partial_values_concurrency = 42 config.compression = icechunk.CompressionConfig(level=8) config.compression.level = 2 @@ -180,7 +178,7 @@ def test_can_change_deep_config_values() -> None: ) assert re.match( - r"RepositoryConfig\(inline_chunk_threshold_bytes=5, unsafe_overwrite_refs=True, get_partial_values_concurrency=42, compression=CompressionConfig\(algorithm=None, level=2\), caching=CachingConfig\(num_snapshot_nodes=None, num_chunk_refs=8, num_transaction_changes=None, num_bytes_attributes=None, num_bytes_chunks=None\), storage=StorageSettings\(concurrency=StorageConcurrencySettings\(max_concurrent_requests_for_object=5, ideal_concurrent_request_size=1000000\)\), manifest=.*\)", + r"RepositoryConfig\(inline_chunk_threshold_bytes=5, get_partial_values_concurrency=42, compression=CompressionConfig\(algorithm=None, level=2\), caching=CachingConfig\(num_snapshot_nodes=None, num_chunk_refs=8, num_transaction_changes=None, num_bytes_attributes=None, num_bytes_chunks=None\), storage=StorageSettings\(concurrency=StorageConcurrencySettings\(max_concurrent_requests_for_object=5, ideal_concurrent_request_size=1000000\), unsafe_use_conditional_create=None, unsafe_use_conditional_update=None, unsafe_use_metadata=None\), manifest=.*\)", repr(config), ) repo = icechunk.Repository.open( @@ -211,3 +209,7 @@ def test_can_change_deep_config_values() -> None: ] ) ) + + +def test_spec_version(): + assert icechunk.spec_version() >= 1 diff --git a/icechunk-python/tests/test_conflicts.py b/icechunk-python/tests/test_conflicts.py index 1f7c4ded..2615c4ea 100644 --- a/icechunk-python/tests/test_conflicts.py +++ b/icechunk-python/tests/test_conflicts.py @@ -48,18 +48,22 @@ def test_detect_conflicts(repo: icechunk.Repository) -> None: try: session_b.rebase(icechunk.ConflictDetector()) except icechunk.RebaseFailedError as e: - assert len(e.conflicts) == 2 + assert len(e.conflicts) == 3 + assert e.conflicts[0].path == "/foo/bar/some-array" assert e.conflicts[0].path == "/foo/bar/some-array" assert ( e.conflicts[0].conflict_type - == icechunk.ConflictType.UserAttributesDoubleUpdate + == icechunk.ConflictType.ZarrMetadataDoubleUpdate ) - assert e.conflicts[0].conflicted_chunks is None - assert e.conflicts[1].path == "/foo/bar/some-array" - assert e.conflicts[1].conflict_type == icechunk.ConflictType.ChunkDoubleUpdate - assert e.conflicts[1].conflicted_chunks - assert len(e.conflicts[1].conflicted_chunks) == 100 + assert ( + e.conflicts[1].conflict_type + == icechunk.ConflictType.ChunksUpdatedInUpdatedArray + ) + assert e.conflicts[2].path == "/foo/bar/some-array" + assert e.conflicts[2].conflict_type == icechunk.ConflictType.ChunkDoubleUpdate + assert e.conflicts[2].conflicted_chunks + assert len(e.conflicts[2].conflicted_chunks) == 100 raise e @@ -94,7 +98,7 @@ def test_rebase_no_conflicts(repo: icechunk.Repository) -> None: "on_chunk_conflict", [icechunk.VersionSelection.UseOurs, icechunk.VersionSelection.UseTheirs], ) -def test_rebase_user_attrs_with_ours( +def test_rebase_fails_on_user_atts_double_edit( repo: icechunk.Repository, on_chunk_conflict: icechunk.VersionSelection ) -> None: session_a = repo.writable_session("main") @@ -116,24 +120,7 @@ def test_rebase_user_attrs_with_ours( # Make sure it fails if the resolver is not set with pytest.raises(icechunk.RebaseFailedError): - session_b.rebase( - icechunk.BasicConflictSolver( - on_user_attributes_conflict=icechunk.VersionSelection.Fail - ) - ) - - solver = icechunk.BasicConflictSolver( - on_user_attributes_conflict=icechunk.VersionSelection.UseOurs, - ) - - session_b.rebase(solver) - session_b.commit("after conflict") - - assert ( - array_b.attrs["repo"] == 2 - if on_chunk_conflict == icechunk.VersionSelection.UseOurs - else 1 - ) + session_b.rebase(icechunk.BasicConflictSolver()) @pytest.mark.parametrize( diff --git a/icechunk-python/tests/test_credentials.py b/icechunk-python/tests/test_credentials.py index d575afef..e598d7d5 100644 --- a/icechunk-python/tests/test_credentials.py +++ b/icechunk-python/tests/test_credentials.py @@ -113,7 +113,7 @@ def __call__(self) -> S3StaticCredentials: ) -def test_refreshable_credentials_refresh(tmp_path: Path) -> None: +def test_s3_refreshable_credentials_refresh(tmp_path: Path) -> None: path = tmp_path / "calls.txt" creds_obj = ExpirableCredentials(path) diff --git a/icechunk-python/tests/test_debuginfo.py b/icechunk-python/tests/test_debuginfo.py new file mode 100644 index 00000000..3eb13aac --- /dev/null +++ b/icechunk-python/tests/test_debuginfo.py @@ -0,0 +1,6 @@ +from icechunk import print_debug_info + + +def test_debug_info() -> None: + # simple test that this does not cause an error. + print_debug_info() diff --git a/icechunk-python/tests/test_distributed_writers.py b/icechunk-python/tests/test_distributed_writers.py index 133178a5..f3f4057f 100644 --- a/icechunk-python/tests/test_distributed_writers.py +++ b/icechunk-python/tests/test_distributed_writers.py @@ -2,12 +2,15 @@ import warnings from typing import cast +import pytest + import dask.array import icechunk import zarr from dask.array.utils import assert_eq from dask.distributed import Client from icechunk.dask import store_dask +from icechunk.storage import s3_object_store_storage, s3_storage # We create a 2-d array with this many chunks along each direction CHUNKS_PER_DIM = 10 @@ -19,16 +22,27 @@ CHUNKS_PER_TASK = 2 -def mk_repo() -> icechunk.Repository: - storage = icechunk.s3_storage( - endpoint_url="http://localhost:9000", - allow_http=True, - region="us-east-1", - bucket="testbucket", - prefix="python-distributed-writers-test__" + str(time.time()), - access_key_id="minio123", - secret_access_key="minio123", - ) +def mk_repo(use_object_store: bool = False) -> icechunk.Repository: + if use_object_store: + storage = s3_object_store_storage( + endpoint_url="http://localhost:9000", + allow_http=True, + region="us-east-1", + bucket="testbucket", + prefix="python-distributed-writers-test__" + str(time.time()), + access_key_id="minio123", + secret_access_key="minio123", + ) + else: + storage = s3_storage( + endpoint_url="http://localhost:9000", + allow_http=True, + region="us-east-1", + bucket="testbucket", + prefix="python-distributed-writers-test__" + str(time.time()), + access_key_id="minio123", + secret_access_key="minio123", + ) repo_config = icechunk.RepositoryConfig.default() repo_config.inline_chunk_threshold_bytes = 5 repo = icechunk.Repository.open_or_create( @@ -39,7 +53,8 @@ def mk_repo() -> icechunk.Repository: return repo -async def test_distributed_writers() -> None: +@pytest.mark.parametrize("use_object_store", [False, True]) +async def test_distributed_writers(use_object_store: bool) -> None: """Write to an array using uncoordinated writers, distributed via Dask. We create a big array, and then we split into workers, each worker gets @@ -48,7 +63,7 @@ async def test_distributed_writers() -> None: does a distributed commit. When done, we open the store again and verify we can write everything we have written. """ - repo = mk_repo() + repo = mk_repo(use_object_store) session = repo.writable_session(branch="main") store = session.store diff --git a/icechunk-python/tests/test_error.py b/icechunk-python/tests/test_error.py new file mode 100644 index 00000000..4a343b2b --- /dev/null +++ b/icechunk-python/tests/test_error.py @@ -0,0 +1,54 @@ +import glob +import re +from pathlib import Path +from shutil import rmtree + +import pytest + +import icechunk as ic +import zarr + + +def test_error_message_when_snapshot_deleted(tmpdir: Path): + tmpdir = Path(tmpdir) + storage = ic.local_filesystem_storage(str(tmpdir)) + repo = ic.Repository.create(storage=storage) + + rmtree(tmpdir / "snapshots") + + repo = ic.Repository.open(storage=storage) + # we check error includes the spans for ancestry and fetch_snapshot + with pytest.raises( + ValueError, match=re.compile("fetch_snapshot.*ancestry", re.DOTALL) + ): + repo.ancestry(branch="main") + + +def test_error_message_when_manifest_file_altered(tmpdir: Path): + tmpdir = Path(tmpdir) + storage = ic.local_filesystem_storage(str(tmpdir)) + repo = ic.Repository.create(storage=storage) + + session = repo.writable_session("main") + store = session.store + + group = zarr.group(store=store, overwrite=True) + array = group.create_array("array", shape=(100, 100), chunks=(10, 10), dtype="i4") + + array[:, :] = 42 + session.commit("commit 1") + + manifest_path = glob.glob(f"{tmpdir}/manifests/*")[0] + with open(manifest_path, "w") as file: + file.write("invalid msgpack") + + repo = ic.Repository.open(storage=storage) + + session = repo.readonly_session(branch="main") + store = session.store + group = zarr.Group.open(store=store) + array = group["array"] + + ## we check error includes the spans for ancestry and fetch_snapshot + with pytest.raises(ValueError, match=re.compile("fetch_manifest.*get", re.DOTALL)): + array[0] diff --git a/icechunk-python/tests/test_pickle.py b/icechunk-python/tests/test_pickle.py index 0d0ba216..4b3f4b7a 100644 --- a/icechunk-python/tests/test_pickle.py +++ b/icechunk-python/tests/test_pickle.py @@ -1,10 +1,17 @@ import pickle +import time from pathlib import Path import pytest import zarr -from icechunk import Repository, local_filesystem_storage +from icechunk import ( + Repository, + RepositoryConfig, + S3StaticCredentials, + local_filesystem_storage, + s3_storage, +) def create_local_repo(path: str) -> Repository: @@ -18,6 +25,18 @@ def tmp_repo(tmpdir: Path) -> Repository: return repo +def test_pickle_repository(tmpdir: Path, tmp_repo: Repository) -> None: + pickled = pickle.dumps(tmp_repo) + roundtripped = pickle.loads(pickled) + assert tmp_repo.list_branches() == roundtripped.list_branches() + + storage = tmp_repo.storage + assert ( + repr(storage) + == f"ObjectStorage(backend=LocalFileSystemObjectStoreBackend(path={tmpdir}))" + ) + + def test_pickle_read_only(tmp_repo: Repository) -> None: tmp_session = tmp_repo.writable_session(branch="main") tmp_store = tmp_session.store @@ -38,7 +57,29 @@ def test_pickle_read_only(tmp_repo: Repository) -> None: assert tmp_store._read_only is False -def test_pickle(tmp_repo: Repository) -> None: +def get_credentials(): + return S3StaticCredentials("minio123", "minio123") + + +def test_pickle() -> None: + # we test with refreshable credentials because that gave us problems in the past + + def mk_repo() -> tuple[str, Repository]: + prefix = "test-repo__" + str(time.time()) + repo = Repository.create( + storage=s3_storage( + endpoint_url="http://localhost:9000", + allow_http=True, + region="us-east-1", + bucket="testbucket", + prefix=prefix, + get_credentials=get_credentials, + ), + config=RepositoryConfig(inline_chunk_threshold_bytes=0), + ) + return (prefix, repo) + + (_, tmp_repo) = mk_repo() tmp_session = tmp_repo.writable_session(branch="main") tmp_store = tmp_session.store diff --git a/icechunk-python/tests/test_stateful_repo_ops.py b/icechunk-python/tests/test_stateful_repo_ops.py index db2a1be6..3d3a0695 100644 --- a/icechunk-python/tests/test_stateful_repo_ops.py +++ b/icechunk-python/tests/test_stateful_repo_ops.py @@ -3,11 +3,13 @@ import json import textwrap from dataclasses import dataclass +from typing import Any, Literal import numpy as np import pytest from zarr.core.buffer import Buffer, default_buffer_prototype +from zarr.core.metadata.v3 import ArrayV3Metadata pytest.importorskip("hypothesis") pytest.importorskip("xarray") @@ -38,52 +40,58 @@ simple_text, st.integers(min_value=-10_000, max_value=10_000), ) -# set in_side to one -array_shapes = npst.array_shapes(max_dims=4, min_side=1) DEFAULT_BRANCH = "main" ######### # TODO: port to Zarr -@st.composite -def v3_group_metadata(draw): - from zarr.core.group import GroupMetadata - metadata = GroupMetadata(attributes=draw(simple_attrs)) - return metadata.to_buffer_dict(prototype=default_buffer_prototype())["zarr.json"] + +@st.composite +def dimension_names( + draw: st.DrawFn, *, ndim: int | None = None +) -> list[None | str] | None: + simple_text = st.text(zrst.zarr_key_chars, min_size=0) + return draw( + st.none() | st.lists(st.none() | simple_text, min_size=ndim, max_size=ndim) + ) @st.composite -def v3_array_metadata(draw: st.DrawFn) -> bytes: +def array_metadata( + draw: st.DrawFn, + *, + array_shapes: st.SearchStrategy[tuple[int, ...]] = zrst.array_shapes, + zarr_formats: st.SearchStrategy[Literal[2, 3]] = zrst.zarr_formats, + attributes: st.SearchStrategy[dict[str, Any]] = zrst.attrs, +) -> ArrayV3Metadata: from zarr.codecs.bytes import BytesCodec from zarr.core.chunk_grids import RegularChunkGrid from zarr.core.chunk_key_encodings import DefaultChunkKeyEncoding from zarr.core.metadata.v3 import ArrayV3Metadata + zarr_format = draw(zarr_formats) # separator = draw(st.sampled_from(['/', '\\'])) shape = draw(array_shapes) ndim = len(shape) chunk_shape = draw(npst.array_shapes(min_dims=ndim, max_dims=ndim)) dtype = draw(zrst.v3_dtypes()) fill_value = draw(npst.from_dtype(dtype)) - dimension_names = draw( - st.none() | st.lists(st.none() | simple_text, min_size=ndim, max_size=ndim) - ) - + if zarr_format == 2: + raise NotImplementedError metadata = ArrayV3Metadata( shape=shape, data_type=dtype, chunk_grid=RegularChunkGrid(chunk_shape=chunk_shape), fill_value=fill_value, - attributes=draw(simple_attrs), - dimension_names=dimension_names, + attributes=draw(attributes), + dimension_names=draw(dimension_names(ndim=ndim)), chunk_key_encoding=DefaultChunkKeyEncoding(separator="/"), # FIXME codecs=[BytesCodec()], storage_transformers=(), ) - - return metadata.to_buffer_dict(prototype=default_buffer_prototype())["zarr.json"] + return metadata class NewSyncStoreWrapper(SyncStoreWrapper): @@ -97,6 +105,11 @@ def list_prefix(self, prefix: str) -> None: keys = st.lists(zrst.node_names, min_size=1, max_size=4).map("/".join) metadata_paths = keys.map(lambda x: x + "/zarr.json") +v3_array_metadata = array_metadata( + zarr_formats=st.just(3), + array_shapes=npst.array_shapes(max_dims=4, min_side=1), # set min_side to one + attributes=simple_attrs, +).map(lambda x: x.to_buffer_dict(prototype=default_buffer_prototype())["zarr.json"]) @dataclass @@ -221,7 +234,7 @@ def initialize(self, data) -> str: # TODO: always setting array metadata, since we cannot overwrite an existing group's zarr.json # with an array's zarr.json # TODO: consider adding a deeper understanding of the zarr model rather than just setting docs? - self.set_doc(path="zarr.json", value=data.draw(v3_array_metadata())) + self.set_doc(path="zarr.json", value=data.draw(v3_array_metadata)) return DEFAULT_BRANCH @@ -232,7 +245,7 @@ def new_store(self) -> None: def sync_store(self): return NewSyncStoreWrapper(self.session.store) - @rule(path=metadata_paths, value=v3_array_metadata()) + @rule(path=metadata_paths, value=v3_array_metadata) def set_doc(self, path: str, value: Buffer): note(f"setting path {path!r} with {value.to_bytes()!r}") # FIXME: remove when we support complex values with infinity fill_value @@ -258,7 +271,7 @@ def commit(self, message): @rule(ref=commits) def checkout_commit(self, ref): note(f"Checking out commit {ref}") - self.session = self.repo.readonly_session(snapshot=ref) + self.session = self.repo.readonly_session(snapshot_id=ref) assert self.session.read_only self.model.checkout_commit(ref) @@ -374,9 +387,6 @@ def check_list_prefix_from_root(self): actual = json.loads( self.sync_store.get(k, default_buffer_prototype()).to_bytes() ) - # FIXME: zarr omits this if None? - if "dimension_names" not in expected: - actual.pop("dimension_names") actual_fv = actual.pop("fill_value") expected_fv = expected.pop("fill_value") if actual_fv != expected_fv: diff --git a/icechunk-python/tests/test_store.py b/icechunk-python/tests/test_store.py index 9fc5a7ec..37bfc6b4 100644 --- a/icechunk-python/tests/test_store.py +++ b/icechunk-python/tests/test_store.py @@ -1,7 +1,10 @@ +import json + import numpy as np import zarr from tests.conftest import parse_repo +from zarr.core.buffer import default_buffer_prototype rng = np.random.default_rng(seed=12345) @@ -48,3 +51,19 @@ async def test_store_clear_chunk_list() -> None: ) keys = [_ async for _ in store.list_prefix("/")] assert len(keys) == 2 + 3, keys + + +async def test_support_dimension_names_null() -> None: + repo = parse_repo("memory", "test") + session = repo.writable_session("main") + store = session.store + + root = zarr.group(store=store) + # no dimensions names! + root.create_array( + name="0", shape=(1, 3, 5, 1), chunks=(1, 3, 2, 1), fill_value=-1, dtype=np.int64 + ) + meta = json.loads( + (await store.get("0/zarr.json", prototype=default_buffer_prototype())).to_bytes() + ) + assert "dimension_names" not in meta diff --git a/icechunk-python/tests/test_timetravel.py b/icechunk-python/tests/test_timetravel.py index 04a6406b..20074be5 100644 --- a/icechunk-python/tests/test_timetravel.py +++ b/icechunk-python/tests/test_timetravel.py @@ -21,6 +21,7 @@ def test_timetravel() -> None: storage=ic.in_memory_storage(), config=config, ) + session = repo.writable_session("main") store = session.store @@ -32,7 +33,19 @@ def test_timetravel() -> None: air_temp[:, :] = 42 assert air_temp[200, 6] == 42 - snapshot_id = session.commit("commit 1") + status = session.status() + assert status.new_groups == {"/"} + assert status.new_arrays == {"/air_temp"} + assert list(status.updated_chunks.keys()) == ["/air_temp"] + assert sorted(status.updated_chunks["/air_temp"]) == sorted( + [[i, j] for i in range(10) for j in range(10)] + ) + assert status.deleted_groups == set() + assert status.deleted_arrays == set() + assert status.updated_arrays == set() + assert status.updated_groups == set() + + first_snapshot_id = session.commit("commit 1") assert session.read_only session = repo.writable_session("main") @@ -45,14 +58,14 @@ def test_timetravel() -> None: new_snapshot_id = session.commit("commit 2") - session = repo.readonly_session(snapshot=snapshot_id) + session = repo.readonly_session(snapshot_id=first_snapshot_id) store = session.store group = zarr.open_group(store=store, mode="r") air_temp = cast(zarr.core.array.Array, group["air_temp"]) assert store.read_only assert air_temp[200, 6] == 42 - session = repo.readonly_session(snapshot=new_snapshot_id) + session = repo.readonly_session(snapshot_id=new_snapshot_id) store = session.store group = zarr.open_group(store=store, mode="r") air_temp = cast(zarr.core.array.Array, group["air_temp"]) @@ -103,7 +116,7 @@ def test_timetravel() -> None: air_temp = cast(zarr.core.array.Array, group["air_temp"]) assert air_temp[200, 6] == 90 - parents = list(repo.ancestry(snapshot=feature_snapshot_id)) + parents = list(repo.ancestry(snapshot_id=feature_snapshot_id)) assert [snap.message for snap in parents] == [ "commit 3", "commit 2", @@ -115,9 +128,68 @@ def test_timetravel() -> None: assert list(repo.ancestry(tag="v1.0")) == parents assert list(repo.ancestry(branch="feature-not-dead")) == parents + diff = repo.diff(to_tag="v1.0", from_snapshot_id=parents[-1].id) + assert diff.new_groups == {"/"} + assert diff.new_arrays == {"/air_temp"} + assert list(diff.updated_chunks.keys()) == ["/air_temp"] + assert sorted(diff.updated_chunks["/air_temp"]) == sorted( + [[i, j] for i in range(10) for j in range(10)] + ) + assert diff.deleted_groups == set() + assert diff.deleted_arrays == set() + assert diff.updated_arrays == set() + assert diff.updated_groups == set() + assert ( + repr(diff) + == """\ +Groups created: + / + +Arrays created: + /air_temp + +Chunks updated: + /air_temp: + [0, 0] + [0, 1] + [0, 2] + [0, 3] + [0, 4] + [0, 5] + [0, 6] + [0, 7] + [0, 8] + [0, 9] + ... 90 more +""" + ) + + session = repo.writable_session("main") + store = session.store + + group = zarr.open_group(store=store) + air_temp = group.create_array( + "air_temp", shape=(1000, 1000), chunks=(100, 100), dtype="i4", overwrite=True + ) + assert ( + repr(session.status()) + == """\ +Arrays created: + /air_temp + +Arrays deleted: + /air_temp + +""" + ) + + with pytest.raises(ValueError, match="doesn't include"): + # if we call diff in the wrong order it fails with a message + repo.diff(from_tag="v1.0", to_snapshot_id=parents[-1].id) + # check async ancestry works - assert list(repo.ancestry(snapshot=feature_snapshot_id)) == asyncio.run( - async_ancestry(repo, snapshot=feature_snapshot_id) + assert list(repo.ancestry(snapshot_id=feature_snapshot_id)) == asyncio.run( + async_ancestry(repo, snapshot_id=feature_snapshot_id) ) assert list(repo.ancestry(tag="v1.0")) == asyncio.run( async_ancestry(repo, tag="v1.0") @@ -160,7 +232,7 @@ async def test_branch_reset() -> None: repo.reset_branch("main", prev_snapshot_id) - session = repo.readonly_session(branch="main") + session = repo.readonly_session("main") store = session.store keys = {k async for k in store.list()} @@ -186,3 +258,38 @@ async def test_tag_delete() -> None: with pytest.raises(ValueError): repo.create_tag("tag", snap) + + +async def test_session_with_as_of() -> None: + repo = ic.Repository.create( + storage=ic.in_memory_storage(), + ) + + session = repo.writable_session("main") + store = session.store + + times = [] + group = zarr.group(store=store, overwrite=True) + sid = session.commit("root") + times.append(next(repo.ancestry(snapshot_id=sid)).written_at) + + for i in range(5): + session = repo.writable_session("main") + store = session.store + group = zarr.open_group(store=store) + group.create_group(f"child {i}") + sid = session.commit(f"child {i}") + times.append(next(repo.ancestry(snapshot_id=sid)).written_at) + + ancestry = list(p for p in repo.ancestry(branch="main")) + assert len(ancestry) == 7 # initial + root + 5 children + + store = repo.readonly_session("main", as_of=times[-1]).store + group = zarr.open_group(store=store, mode="r") + + for i, time in enumerate(times): + store = repo.readonly_session("main", as_of=time).store + group = zarr.open_group(store=store, mode="r") + expected_children = {f"child {j}" for j in range(i)} + actual_children = {g[0] for g in group.members()} + assert expected_children == actual_children diff --git a/icechunk-python/tests/test_virtual_ref.py b/icechunk-python/tests/test_virtual_ref.py index 61c05a3e..506035e4 100644 --- a/icechunk-python/tests/test_virtual_ref.py +++ b/icechunk-python/tests/test_virtual_ref.py @@ -14,6 +14,7 @@ RepositoryConfig, S3Options, VirtualChunkContainer, + VirtualChunkSpec, containers_credentials, in_memory_storage, local_filesystem_storage, @@ -65,74 +66,97 @@ async def test_write_minio_virtual_refs() -> None: old = datetime.now(UTC) - timedelta(weeks=1) new = datetime.now(UTC) + timedelta(minutes=1) - store.set_virtual_ref( - "c/0/0/0", f"s3://testbucket/{prefix}/chunk-1", offset=0, length=4 - ) - store.set_virtual_ref( - "c/1/0/0", - f"s3://testbucket/{prefix}/chunk-1", - offset=0, - length=4, - checksum=etags[0], - ) - store.set_virtual_ref( - "c/2/0/0", - f"s3://testbucket/{prefix}/chunk-1", - offset=0, - length=4, - checksum="bad etag", - ) - store.set_virtual_ref( - "c/3/0/0", - f"s3://testbucket/{prefix}/chunk-1", - offset=0, - length=4, - checksum=old, - ) - store.set_virtual_ref( - "c/4/0/0", - f"s3://testbucket/{prefix}/chunk-1", - offset=0, - length=4, - checksum=new, - ) - - store.set_virtual_ref( - "c/0/0/1", f"s3://testbucket/{prefix}/chunk-2", offset=1, length=4 - ) - store.set_virtual_ref( - "c/1/0/1", - f"s3://testbucket/{prefix}/chunk-2", - offset=1, - length=4, - checksum=etags[1], - ) - store.set_virtual_ref( - "c/2/0/1", - f"s3://testbucket/{prefix}/chunk-2", - offset=1, - length=4, - checksum="bad etag", - ) - store.set_virtual_ref( - "c/3/0/1", - f"s3://testbucket/{prefix}/chunk-2", - offset=1, - length=4, - checksum=old, - ) - store.set_virtual_ref( - "c/4/0/1", - f"s3://testbucket/{prefix}/chunk-2", - offset=1, - length=4, - checksum=new, + res = store.set_virtual_refs( + array_path="/", + validate_containers=True, + chunks=[ + VirtualChunkSpec( + index=[0, 0, 0], + location=f"s3://testbucket/{prefix}/chunk-1", + offset=0, + length=4, + ), + VirtualChunkSpec( + index=[1, 0, 0], + location=f"s3://testbucket/{prefix}/chunk-1", + offset=0, + length=4, + etag_checksum=etags[0], + ), + VirtualChunkSpec( + index=[2, 0, 0], + location=f"s3://testbucket/{prefix}/chunk-1", + offset=0, + length=4, + etag_checksum="bad etag", + ), + VirtualChunkSpec( + index=[3, 0, 0], + location=f"s3://testbucket/{prefix}/chunk-1", + offset=0, + length=4, + last_updated_at_checksum=old, + ), + VirtualChunkSpec( + index=[4, 0, 0], + location=f"s3://testbucket/{prefix}/chunk-1", + offset=0, + length=4, + last_updated_at_checksum=new, + ), + VirtualChunkSpec( + index=[0, 0, 1], + location=f"s3://testbucket/{prefix}/chunk-2", + offset=1, + length=4, + ), + VirtualChunkSpec( + index=[1, 0, 1], + location=f"s3://testbucket/{prefix}/chunk-2", + offset=1, + length=4, + etag_checksum=etags[1], + ), + VirtualChunkSpec( + index=[2, 0, 1], + location=f"s3://testbucket/{prefix}/chunk-2", + offset=1, + length=4, + etag_checksum="bad etag", + ), + VirtualChunkSpec( + index=[3, 0, 1], + location=f"s3://testbucket/{prefix}/chunk-2", + offset=1, + length=4, + last_updated_at_checksum=old, + ), + VirtualChunkSpec( + index=[4, 0, 1], + location=f"s3://testbucket/{prefix}/chunk-2", + offset=1, + length=4, + last_updated_at_checksum=new, + ), + # we write a ref that simulates a lost chunk + VirtualChunkSpec( + index=[0, 0, 2], + location=f"s3://testbucket/{prefix}/non-existing", + offset=1, + length=4, + ), + # we write one that doesn't pass container validation + VirtualChunkSpec( + index=[0, 0, 2], + location=f"bad://testbucket/{prefix}/non-existing", + offset=1, + length=4, + ), + ], ) - # we write a ref that simulates a lost chunk - store.set_virtual_ref( - "c/0/0/2", f"s3://testbucket/{prefix}/non-existing", offset=1, length=4 - ) + # we got the failed ref index + assert res == [(0, 0, 2)] # can validate virtual chunk containers with pytest.raises(IcechunkError, match="invalid chunk location"): diff --git a/icechunk-python/tests/test_zarr/test_properties.py b/icechunk-python/tests/test_zarr/test_properties.py index 2c0aaf7a..58656e99 100644 --- a/icechunk-python/tests/test_zarr/test_properties.py +++ b/icechunk-python/tests/test_zarr/test_properties.py @@ -1,6 +1,5 @@ from typing import Any -import numpy as np import pytest from numpy.testing import assert_array_equal @@ -40,8 +39,6 @@ def create() -> IcechunkStore: def test_roundtrip(data: st.DataObject, nparray: Any) -> None: # TODO: support size-0 arrays GH392 assume(nparray.size > 0) - # TODO: fix complex fill values GH391 - assume(not np.iscomplexobj(nparray)) zarray = data.draw( arrays( @@ -52,4 +49,6 @@ def test_roundtrip(data: st.DataObject, nparray: Any) -> None: attrs=simple_attrs, ) ) + # TODO: this test sometimes break with the ShardingCodec and zarr 3.0.3 + assume(zarray.shards is None) assert_array_equal(nparray, zarray[:]) diff --git a/icechunk-python/tests/test_zarr/test_stateful.py b/icechunk-python/tests/test_zarr/test_stateful.py index d7dca07f..a3477848 100644 --- a/icechunk-python/tests/test_zarr/test_stateful.py +++ b/icechunk-python/tests/test_zarr/test_stateful.py @@ -1,3 +1,4 @@ +import json from typing import Any import hypothesis.strategies as st @@ -70,13 +71,22 @@ def commit_with_check(self, data) -> None: f"listing changed before ({len(lsbefore)} items) and after ({len(lsafter)} items) committing." f" \n\n Before : {lsbefore!r} \n\n After: {lsafter!r}, \n\n Expected: {lsexpect!r}" ) + + # if it's metadata, we need to compare the data parsed, not raw (because of map ordering) + if path.endswith(".json"): + get_after = json.loads(get_after.to_bytes()) + get_before = json.loads(get_before.to_bytes()) + else: + get_after = get_after.to_bytes() + get_before = get_before.to_bytes() + if get_before != get_after: get_expect = self._sync(self.model.get(path, prototype=PROTOTYPE)) assert get_expect raise ValueError( f"Value changed before and after commit for path {path}" - f" \n\n Before : {get_before.to_bytes()!r} \n\n " - f"After: {get_after.to_bytes()!r}, \n\n " + f" \n\n Before : {get_before!r} \n\n " + f"After: {get_after!r}, \n\n " f"Expected: {get_expect.to_bytes()!r}" ) @@ -96,8 +106,6 @@ def add_array( array, _ = array_and_chunks # TODO: support size-0 arrays GH392 assume(array.size > 0) - # TODO: fix complex fill values GH391 - assume(not np.iscomplexobj(array)) super().add_array(data, name, array_and_chunks) ##### TODO: port everything below to zarr diff --git a/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py b/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py index 10e7d7c2..dc0ef662 100644 --- a/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py +++ b/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py @@ -10,7 +10,7 @@ from icechunk.repository import Repository from zarr.abc.store import OffsetByteRequest, RangeByteRequest, Store, SuffixByteRequest from zarr.core.buffer import Buffer, cpu, default_buffer_prototype -from zarr.core.sync import collect_aiterator +from zarr.core.sync import _collect_aiterator, collect_aiterator from zarr.testing.store import StoreTests from zarr.testing.utils import assert_bytes_equal @@ -293,6 +293,39 @@ async def test_list_prefix(self, store: S) -> None: expected = tuple(sorted(expected)) assert observed == expected + async def test_list_empty_path(self, store: S) -> None: + """ + Verify that list and list_prefix work correctly when path is an empty string, + i.e. no unwanted replacement occurs. + """ + await store.set("foo/bar/zarr.json", self.buffer_cls.from_bytes(ARRAY_METADATA)) + data = self.buffer_cls.from_bytes(b"") + store_dict = { + "foo/bar/c/1/0/0": data, + "foo/bar/c/0/0/0": data, + } + await store._set_many(store_dict.items()) + + all_keys = sorted(list(store_dict.keys()) + ["foo/bar/zarr.json"]) + + # Test list() + observed_list = await _collect_aiterator(store.list()) + observed_list_sorted = sorted(observed_list) + expected_list_sorted = all_keys + assert observed_list_sorted == expected_list_sorted + + # Test list_prefix() with an empty prefix + observed_prefix_empty = await _collect_aiterator(store.list_prefix("")) + observed_prefix_empty_sorted = sorted(observed_prefix_empty) + expected_prefix_empty_sorted = all_keys + assert observed_prefix_empty_sorted == expected_prefix_empty_sorted + + # Test list_prefix() with a non-empty prefix + observed_prefix = await _collect_aiterator(store.list_prefix("foo/bar/")) + observed_prefix_sorted = sorted(observed_prefix) + expected_prefix_sorted = sorted(k for k in all_keys if k.startswith("foo/bar/")) + assert observed_prefix_sorted == expected_prefix_sorted + async def test_list_dir(self, store: IcechunkStore) -> None: out = [k async for k in store.list_dir("")] assert out == [] diff --git a/icechunk/Cargo.toml b/icechunk/Cargo.toml index 5ca1095a..ffb11ca4 100644 --- a/icechunk/Cargo.toml +++ b/icechunk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "icechunk" -version = "0.1.0" +version = "0.2.3" description = "Transactional storage engine for Zarr designed for use on cloud object storage" readme = "../README.md" repository = "https://github.com/earth-mover/icechunk" @@ -43,12 +43,19 @@ aws-smithy-types-convert = { version = "0.60.8", features = [ "convert-chrono", "convert-streams", ] } -serde_yml = "0.0.12" typetag = "0.2.19" zstd = "0.13.2" tokio-util = { version = "0.7.13", features = ["compat", "io-util"] } serde_bytes = "0.11.15" regex = "1.11.1" +tracing-error = "0.2.1" +tracing-subscriber = { version = "0.3.19", features = [ + "env-filter", +], optional = true } +tracing = "0.1.41" +err-into = "1.0.1" +serde_yaml_ng = "0.10.0" +flatbuffers = "25.2.10" [dev-dependencies] pretty_assertions = "1.4.1" @@ -57,3 +64,6 @@ tempfile = "3.15.0" [lints] workspace = true + +[features] +logs = ["dep:tracing-subscriber"] diff --git a/icechunk/examples/low_level_dataset.rs b/icechunk/examples/low_level_dataset.rs index f9c778fa..17b6ccaa 100644 --- a/icechunk/examples/low_level_dataset.rs +++ b/icechunk/examples/low_level_dataset.rs @@ -1,12 +1,9 @@ #![allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)] -use std::{collections::HashMap, iter, num::NonZeroU64, sync::Arc}; +use std::{collections::HashMap, sync::Arc}; +use bytes::Bytes; use icechunk::{ - format::{manifest::ChunkPayload, snapshot::ZarrArrayMetadata, ChunkIndices, Path}, - metadata::{ - ChunkKeyEncoding, ChunkShape, Codec, DataType, FillValue, StorageTransformer, - UserAttributes, - }, + format::{manifest::ChunkPayload, snapshot::ArrayShape, ChunkIndices, Path}, repository::VersionInfo, session::{Session, SessionError}, storage::new_in_memory_storage, @@ -29,7 +26,7 @@ let mut ds = Repository::create(Arc::clone(&storage)); "#, ); - let storage: Arc = new_in_memory_storage()?; + let storage: Arc = new_in_memory_storage().await?; let repo = Repository::create(None, Arc::clone(&storage), HashMap::new()).await?; let mut ds = repo.writable_session("main").await?; @@ -46,9 +43,10 @@ ds.add_group("/group2".into()).await?; "#, ); - ds.add_group(Path::root()).await?; - ds.add_group("/group1".try_into().unwrap()).await?; - ds.add_group("/group2".try_into().unwrap()).await?; + let user_data = Bytes::new(); + ds.add_group(Path::root(), user_data.clone()).await?; + ds.add_group("/group1".try_into().unwrap(), user_data.clone()).await?; + ds.add_group("/group2".try_into().unwrap(), user_data.clone()).await?; println!(); print_nodes(&ds).await?; @@ -96,58 +94,13 @@ ds.add_array(array1_path.clone(), zarr_meta1).await?; "#, ); - let zarr_meta1 = ZarrArrayMetadata { - shape: vec![3], - data_type: DataType::Int32, - chunk_shape: ChunkShape(vec![ - NonZeroU64::new(1).unwrap(), - NonZeroU64::new(1).unwrap(), - NonZeroU64::new(1).unwrap(), - ]), - chunk_key_encoding: ChunkKeyEncoding::Slash, - fill_value: FillValue::Int32(0), - codecs: vec![Codec { - name: "mycodec".to_string(), - configuration: Some(HashMap::from_iter(iter::once(( - "foo".to_string(), - serde_json::Value::from(42), - )))), - }], - storage_transformers: Some(vec![StorageTransformer { - name: "mytransformer".to_string(), - configuration: Some(HashMap::from_iter(iter::once(( - "foo".to_string(), - serde_json::Value::from(42), - )))), - }]), - dimension_names: Some(vec![ - Some("x".to_string()), - Some("y".to_string()), - Some("t".to_string()), - ]), - }; + let shape = ArrayShape::new(vec![(3, 1)]).unwrap(); + let dimension_names = Some(vec!["x".into()]); let array1_path: Path = "/group1/array1".try_into().unwrap(); - ds.add_array(array1_path.clone(), zarr_meta1).await?; + ds.add_array(array1_path.clone(), shape, dimension_names, user_data.clone()).await?; println!(); print_nodes(&ds).await?; - println!(); - println!(); - println!("## Setting array user attributes"); - println!( - r#" -``` -ds.set_user_attributes(array1_path.clone(), Some("{{n:42}}".to_string())).await?; -``` - "#, - ); - ds.set_user_attributes( - array1_path.clone(), - Some(UserAttributes::try_new(br#"{"n":42}"#).unwrap()), - ) - .await?; - print_nodes(&ds).await?; - println!("## Committing"); let v1_id = ds.commit("some message", Default::default()).await?; println!( @@ -286,15 +239,9 @@ async fn print_nodes(ds: &Session) -> Result<(), SessionError> { let rows = ds .list_nodes() .await? + .map(|n| n.unwrap()) .sorted_by_key(|n| n.path.clone()) - .map(|node| { - format!( - "|{:10?}|{:15}|{:10?}\n", - node.node_type(), - node.path.to_string(), - node.user_attributes, - ) - }) + .map(|node| format!("|{:10?}|{:15}\n", node.node_type(), node.path.to_string(),)) .format(""); println!("{}", rows); diff --git a/icechunk/examples/multithreaded_get_chunk_refs.rs b/icechunk/examples/multithreaded_get_chunk_refs.rs new file mode 100644 index 00000000..1c6769d7 --- /dev/null +++ b/icechunk/examples/multithreaded_get_chunk_refs.rs @@ -0,0 +1,174 @@ +//! This example is used to benchmark multithreaded reads and writes of manifest files +//! +//! It launches hundreds of thousands of tasks to writes and then read refs. +//! It generates a manifest with 1M refs and executes 1M random reads. +//! Local filesystem storage is used to try to measure times without depending +//! on bandwidith. +//! +//! Run the example passing --write /path/to/repo +//! and then passing --read /path/to/repo + +#![allow(clippy::unwrap_used)] + +use std::{ + collections::HashMap, + env::{self}, + sync::Arc, + time::Instant, +}; + +use bytes::Bytes; +use futures::{stream::FuturesUnordered, StreamExt}; +use icechunk::{ + config::CompressionConfig, + format::{ + manifest::{ChunkPayload, ChunkRef}, + snapshot::ArrayShape, + ChunkId, ChunkIndices, Path, + }, + new_local_filesystem_storage, + repository::VersionInfo, + session::Session, + Repository, RepositoryConfig, +}; +use itertools::iproduct; +use rand::random_range; +use tokio::sync::RwLock; + +const MAX_I: u64 = 10; +const MAX_J: u64 = 10; +const MAX_L: u64 = 100; +const MAX_K: u64 = 100; +const READS: u64 = 1_000_000; + +async fn mk_repo( + path: &std::path::Path, +) -> Result> { + let storage = new_local_filesystem_storage(path).await?; + let config = RepositoryConfig { + compression: Some(CompressionConfig { + level: Some(3), + ..CompressionConfig::default() + }), + ..RepositoryConfig::default() + }; + let repo = Repository::open_or_create(Some(config), storage, HashMap::new()).await?; + Ok(repo) +} + +async fn do_writes(path: &std::path::Path) -> Result<(), Box> { + let repo = mk_repo(path).await?; + let mut session = repo.writable_session("main").await?; + let shape = + ArrayShape::new(vec![(MAX_I, 1), (MAX_J, 1), (MAX_K, 1), (MAX_L, 1)]).unwrap(); + let user_data = Bytes::new(); + let dimension_names = Some(vec!["i".into(), "j".into(), "k".into(), "l".into()]); + let path: Path = "/array".try_into().unwrap(); + session.add_array(path.clone(), shape, dimension_names, user_data).await?; + session.commit("array created", None).await?; + + let session = Arc::new(RwLock::new(repo.writable_session("main").await?)); + println!("Doing {} writes, wait...", MAX_I * MAX_J * MAX_K * MAX_L); + let before = Instant::now(); + let futures: FuturesUnordered<_> = iproduct!(0..MAX_I, 0..MAX_J, 0..MAX_L, 0..MAX_K) + .map(|(i, j, k, l)| { + let path = path.clone(); + let session = session.clone(); + async move { + let mut session = session.write().await; + let payload = ChunkPayload::Ref(ChunkRef { + id: ChunkId::random(), + offset: i * j * k * l, + length: random_range(1_000_000..2_000_000), + }); + session + .set_chunk_ref( + path.clone(), + ChunkIndices(vec![i as u32, j as u32, k as u32, l as u32]), + Some(payload), + ) + .await + .unwrap(); + } + }) + .collect(); + + futures.collect::<()>().await; + println!("Time to execute writes: {:?}", before.elapsed()); + let before = Instant::now(); + println!("Committing"); + session.write().await.commit("array created", None).await?; + println!("Time to execute commit: {:?}", before.elapsed()); + Ok(()) +} + +async fn do_reads(path: &std::path::Path) -> Result<(), Box> { + let repo = mk_repo(path).await?; + let session = Arc::new(RwLock::new( + repo.readonly_session(&VersionInfo::BranchTipRef("main".to_string())).await?, + )); + + let path: Path = "/array".try_into().unwrap(); + println!("Doing {} reads, wait...", 4 * (READS / 4)); + let before = Instant::now(); + let join1 = tokio::spawn(thread_reads(session.clone(), path.clone(), READS / 4)); + let join2 = tokio::spawn(thread_reads(session.clone(), path.clone(), READS / 4)); + let join3 = tokio::spawn(thread_reads(session.clone(), path.clone(), READS / 4)); + let join4 = tokio::spawn(thread_reads(session.clone(), path.clone(), READS / 4)); + + let total = join1.await? + join2.await? + join3.await? + join4.await?; + assert_eq!(total, 4 * (READS / 4)); + println!("Time to execute reads: {:?}", before.elapsed()); + + Ok(()) +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args: Vec<_> = env::args().collect(); + if args.len() != 3 { + println!("Error: Pass either\n --write path/to/repo\n or\n --read path/to/repo\n as command line argument."); + return Err("Invalid arguments".into()); + } + + let path = std::path::PathBuf::from(args[2].as_str()); + + match &args[1] { + s if s == "--write" => do_writes(path.as_path()).await?, + s if s == "--read" => do_reads(path.as_path()).await?, + _ => { + println!("Error: Pass either --write or --read as command line argument."); + let err: Box = "Invalid arguments".into(); + return Err(err); + } + } + + Ok(()) +} + +async fn thread_reads(session: Arc>, path: Path, reads: u64) -> u64 { + let futures: FuturesUnordered<_> = (0..reads) + .map(|_| { + let i = random_range(0..MAX_I); + let j = random_range(0..MAX_J); + let k = random_range(0..MAX_K); + let l = random_range(0..MAX_L); + let path = path.clone(); + let session = session.clone(); + async move { + let session = session.read().await; + let the_ref = session + .get_chunk_ref( + &path, + &ChunkIndices(vec![i as u32, j as u32, k as u32, l as u32]), + ) + .await + .unwrap(); + assert!(matches!(the_ref, Some(ChunkPayload::Ref(ChunkRef{ offset, .. })) if offset == i*j*k*l)); + 1 + } + }) + .collect(); + + futures.collect::>().await.iter().sum() +} diff --git a/icechunk/examples/multithreaded_store.rs b/icechunk/examples/multithreaded_store.rs index beb8cee7..470627ea 100644 --- a/icechunk/examples/multithreaded_store.rs +++ b/icechunk/examples/multithreaded_store.rs @@ -10,10 +10,9 @@ use tokio::{sync::RwLock, task::JoinSet, time::sleep}; #[tokio::main] async fn main() -> Result<(), Box> { - let storage = new_in_memory_storage()?; + let storage = new_in_memory_storage().await?; let config = RepositoryConfig { inline_chunk_threshold_bytes: Some(128), - unsafe_overwrite_refs: Some(true), ..Default::default() }; let repo = Repository::create(Some(config), storage, HashMap::new()).await?; @@ -55,6 +54,7 @@ async fn main() -> Result<(), Box> { async fn writer(name: &str, range: Range, store: &Store) { println!("Starting writer {name}."); for i in range { + #[allow(clippy::dbg_macro)] if let Err(err) = store .set( format!("array/c/{i}").as_str(), diff --git a/icechunk/flatbuffers/all.fbs b/icechunk/flatbuffers/all.fbs new file mode 100644 index 00000000..0e62a267 --- /dev/null +++ b/icechunk/flatbuffers/all.fbs @@ -0,0 +1,14 @@ +// run this command in the directory icechunk/flatbuffers +// +// flatc --rust -o ../src/format/flatbuffers/ --gen-all all.fbs +// +// This will generate the file all_generated.rs + +include "object_ids.fbs"; +include "manifest.fbs"; +include "snapshot.fbs"; +include "transaction_log.fbs"; + +// This is the way we have found to make it easy to generate code for all files +// flatbuffers rust generation seems to have some issues trying to generate +// separate files diff --git a/icechunk/flatbuffers/manifest.fbs b/icechunk/flatbuffers/manifest.fbs new file mode 100644 index 00000000..8d32b79c --- /dev/null +++ b/icechunk/flatbuffers/manifest.fbs @@ -0,0 +1,56 @@ +include "object_ids.fbs"; + +namespace gen; + +// We don't use unions and datastructures for the different types of refs +// If we do that, the manifest grows in size a lot, because of the extra +// offsets needed. This makes the code more complex because we need to +// interpret the different fields to know what type of ref we have + +table ChunkRef { + // the coordinates of this chunk ref, in the same order as in the array definition + index: [uint32] (required); + + // if this is an inline chunk ref, the data for the chunk will be put here unmodified + inline: [uint8]; + + // if this is a virtual or native chunk ref, offset and length allow to fetch + // the chunk from inside a larger object + offset: uint64 = 0; + length: uint64 = 0; + + // only native chunk refs will have this field, and it points to a file + // in the repository's object store + chunk_id: ObjectId12; + + // only virtual chunk refs will have the following fields + // location is the absolute url to the object where the chunk is stored + location: string; + + // only 0 or 1 of the following fields will be present and only for virtual chunk refs + // the etag assigned by the object store + checksum_etag: string; + // time, in seconds since the unix epoch, when the object containing the chunk + // was last modified + checksum_last_modified: uint32 = 0; +} + +table ArrayManifest { + // the id of the node the chunk refs belong to + node_id: ObjectId8 (required); + + // one element per chunk reference in the array + // this array is sorted in ascending order of the index in the ChunkRef + refs: [ChunkRef] (required); +} + +table Manifest { + // the manifest id + id: ObjectId12 (required); + + // one element for each array that has chunk refs in this manifest + // this array is sorted in ascending order of the node_id of the ArrayManifest + arrays: [ArrayManifest] (required); +} + +root_type Manifest; diff --git a/icechunk/flatbuffers/object_ids.fbs b/icechunk/flatbuffers/object_ids.fbs new file mode 100644 index 00000000..7bd26e80 --- /dev/null +++ b/icechunk/flatbuffers/object_ids.fbs @@ -0,0 +1,11 @@ +namespace gen; + +// used for SnapshotIds, ChunkIds, etc +struct ObjectId12 { + bytes:[uint8:12]; +} + +// used for NodeIds +struct ObjectId8 { + bytes:[uint8:8]; +} diff --git a/icechunk/flatbuffers/snapshot.fbs b/icechunk/flatbuffers/snapshot.fbs new file mode 100644 index 00000000..157762a6 --- /dev/null +++ b/icechunk/flatbuffers/snapshot.fbs @@ -0,0 +1,117 @@ +include "object_ids.fbs"; + +namespace gen; + +// a single key-value of snapshot metadata +table MetadataItem { + // the name of the attribute + name: string (required); + + // the value, serialized as rmp_serde of the json value + // TODO: better serialization format + value: [uint8] (required); +} + +// a pointer to a manifest file +struct ManifestFileInfo { + // id of the object in the repo's object store + id: ObjectId12; + + // size in bytes of the whole manifest + size_bytes: uint64; + + // number of chunk refs in the manifest + num_chunk_refs: uint32; +} + +// A range of chunk indexes +struct ChunkIndexRange { + // inclusive + from: uint32; + + // exclusive + to: uint32; +} + +// a pointer to a manifest +table ManifestRef { + // id of the object in the repo's object store + object_id: ObjectId12 (required); + + // one element per dimension of the array, same order as in metadata + extents: [ChunkIndexRange] (required); +} + +// the shape of the array along a given dimension +struct DimensionShape { + array_length: uint64; + chunk_length: uint64; +} + +table DimensionName { + // optional + name: string; +} + +// a marker for a group node +table GroupNodeData {} + +// data for an array node +table ArrayNodeData { + shape: [DimensionShape] (required); + + dimension_names: [DimensionName]; + + // pointers to all the manifests where this array has chunk references + manifests: [ManifestRef] (required); +} + +// the node contents, that can be either a group or an array +union NodeData { + Array :ArrayNodeData, + Group :GroupNodeData, +} + +// a node +table NodeSnapshot { + // id of the object in the repo's object store + id: ObjectId8 (required); + + // absolute path of the node within the repo + path: string (required); + + // the metadata for the node according to what the user passed + // this will generally be the full zarr metadata for the node + user_data: [uint8] (required); + + // node's data + node_data: NodeData (required); +} + + +table Snapshot { + // the id of this snapshot + id: ObjectId12 (required); + + // the id of the parent snapshot, can be null for a root snapshot + parent_id: ObjectId12; + + nodes: [NodeSnapshot] (required); + + // time at which this snapshot was generated + // non-leap microseconds since Jan 1, 1970 UTC + flushed_at: uint64; + + // commit message + message: string (required); + + // metadata for the snapshot + // sorted in ascending order of MetadataItem.name + metadata: [MetadataItem] (required); + + // the list of all manifest files this snapshot points to + // sorted in ascending order of ManifestFileInfo.id + manifest_files: [ManifestFileInfo] (required); +} + +root_type Snapshot; diff --git a/icechunk/flatbuffers/transaction_log.fbs b/icechunk/flatbuffers/transaction_log.fbs new file mode 100644 index 00000000..3f996119 --- /dev/null +++ b/icechunk/flatbuffers/transaction_log.fbs @@ -0,0 +1,52 @@ +include "object_ids.fbs"; + +namespace gen; + +table ChunkIndices { + coords: [uint32] (required); +} + +table ArrayUpdatedChunks { + // the node id of the array to which the chunks belong to + node_id: ObjectId8 (required); + + // the coordinates of all the chunks modified in this transaction for this array + // sorted in ascending lexicographical order + chunks: [ChunkIndices] (required); +} + +table TransactionLog { + // id of the transaction log file, + // it will be the same as the corresponding snapshot + id: ObjectId12 (required); + + // node ids of the groups created in this transaction + // sorted in ascending order + new_groups: [ObjectId8] (required); + + // node ids of the arrays created in this transaction + // sorted in ascending order + new_arrays: [ObjectId8] (required); + + // node ids of the groups deleted in this transaction + // sorted in ascending order + deleted_groups: [ObjectId8] (required); + + // node ids of the arrays deleted in this transaction + // sorted in ascending order + deleted_arrays: [ObjectId8] (required); + + // node ids of the groups that had user definitions modified in this transaction + // sorted in ascending order + updated_arrays: [ObjectId8] (required); + + // node ids of the arrays that had user definitions modified in this transaction + // sorted in ascending order + updated_groups: [ObjectId8] (required); + + // chunk ref changes made in this transaction + // sorted in ascending order of the node_id of the ArrayUpdatedChunks + updated_chunks: [ArrayUpdatedChunks] (required); +} + +root_type TransactionLog; diff --git a/icechunk/src/asset_manager.rs b/icechunk/src/asset_manager.rs index b0803892..30db9fa3 100644 --- a/icechunk/src/asset_manager.rs +++ b/icechunk/src/asset_manager.rs @@ -9,6 +9,7 @@ use std::{ ops::Range, sync::Arc, }; +use tracing::{debug, instrument, trace, Span}; use crate::{ config::CachingConfig, @@ -21,10 +22,10 @@ use crate::{ }, snapshot::{Snapshot, SnapshotInfo}, transaction_log::TransactionLog, - ChunkId, ChunkOffset, IcechunkFormatError, ManifestId, SnapshotId, + ChunkId, ChunkOffset, IcechunkFormatErrorKind, ManifestId, SnapshotId, }, private, - repository::{RepositoryError, RepositoryResult}, + repository::{RepositoryError, RepositoryErrorKind, RepositoryResult}, storage::{self, Reader}, Storage, }; @@ -137,6 +138,7 @@ impl AssetManager { ) } + #[instrument(skip(self, manifest))] pub async fn write_manifest(&self, manifest: Arc) -> RepositoryResult { let manifest_c = Arc::clone(&manifest); let res = write_new_manifest( @@ -146,10 +148,11 @@ impl AssetManager { &self.storage_settings, ) .await?; - self.manifest_cache.insert(manifest.id.clone(), manifest); + self.manifest_cache.insert(manifest.id().clone(), manifest); Ok(res) } + #[instrument(skip(self))] pub async fn fetch_manifest( &self, manifest_id: &ManifestId, @@ -171,6 +174,7 @@ impl AssetManager { } } + #[instrument(skip(self,))] pub async fn fetch_manifest_unknown_size( &self, manifest_id: &ManifestId, @@ -178,6 +182,7 @@ impl AssetManager { self.fetch_manifest(manifest_id, 0).await } + #[instrument(skip(self, snapshot))] pub async fn write_snapshot(&self, snapshot: Arc) -> RepositoryResult<()> { let snapshot_c = Arc::clone(&snapshot); write_new_snapshot( @@ -188,10 +193,13 @@ impl AssetManager { ) .await?; let snapshot_id = snapshot.id().clone(); + // This line is critical for expiration: + // When we edit snapshots in place, we need the cache to return the new version self.snapshot_cache.insert(snapshot_id, snapshot); Ok(()) } + #[instrument(skip(self))] pub async fn fetch_snapshot( &self, snapshot_id: &SnapshotId, @@ -211,6 +219,7 @@ impl AssetManager { } } + #[instrument(skip(self, log))] pub async fn write_transaction_log( &self, transaction_id: SnapshotId, @@ -229,6 +238,7 @@ impl AssetManager { Ok(()) } + #[instrument(skip(self))] pub async fn fetch_transaction_log( &self, transaction_id: &SnapshotId, @@ -248,15 +258,18 @@ impl AssetManager { } } + #[instrument(skip(self, bytes))] pub async fn write_chunk( &self, chunk_id: ChunkId, bytes: Bytes, ) -> RepositoryResult<()> { + trace!(%chunk_id, size_bytes=bytes.len(), "Writing chunk"); // we don't pre-populate the chunk cache, there are too many of them for this to be useful Ok(self.storage.write_chunk(&self.storage_settings, chunk_id, bytes).await?) } + #[instrument(skip(self))] pub async fn fetch_chunk( &self, chunk_id: &ChunkId, @@ -266,7 +279,7 @@ impl AssetManager { match self.chunk_cache.get_value_or_guard_async(&key).await { Ok(chunk) => Ok(chunk), Err(guard) => { - // TODO: split and parallelize downloads + trace!(%chunk_id, ?range, "Downloading chunk"); let chunk = self .storage .fetch_chunk(&self.storage_settings, chunk_id, range) @@ -279,16 +292,18 @@ impl AssetManager { /// Returns the sequence of parents of the current session, in order of latest first. /// Output stream includes snapshot_id argument + #[instrument(skip(self))] pub async fn snapshot_ancestry( self: Arc, snapshot_id: &SnapshotId, ) -> RepositoryResult>> { let mut this = self.fetch_snapshot(snapshot_id).await?; let stream = try_stream! { - yield this.as_ref().into(); + let info: SnapshotInfo = this.as_ref().try_into()?; + yield info; while let Some(parent) = this.parent_id() { - let snap = self.fetch_snapshot(parent).await?; - let info: SnapshotInfo = snap.as_ref().into(); + let snap = self.fetch_snapshot(&parent).await?; + let info: SnapshotInfo = snap.as_ref().try_into()?; yield info; this = snap; } @@ -296,11 +311,16 @@ impl AssetManager { Ok(stream) } + #[instrument(skip(self))] pub async fn get_snapshot_last_modified( &self, - snap: &SnapshotId, + snapshot_id: &SnapshotId, ) -> RepositoryResult> { - Ok(self.storage.get_snapshot_last_modified(&self.storage_settings, snap).await?) + debug!(%snapshot_id, "Getting snapshot timestamp"); + Ok(self + .storage + .get_snapshot_last_modified(&self.storage_settings, snapshot_id) + .await?) } } @@ -333,9 +353,10 @@ fn check_header( read.read_exact(&mut buf)?; // Magic numbers if format_constants::ICECHUNK_FORMAT_MAGIC_BYTES != buf { - return Err(RepositoryError::FormatError( - IcechunkFormatError::InvalidMagicNumbers, - )); + return Err(RepositoryErrorKind::FormatError( + IcechunkFormatErrorKind::InvalidMagicNumbers, + ) + .into()); } let mut buf = [0; 24]; @@ -346,7 +367,7 @@ fn check_header( read.read_exact(std::slice::from_mut(&mut spec_version))?; let spec_version = spec_version.try_into().map_err(|_| { - RepositoryError::FormatError(IcechunkFormatError::InvalidSpecVersion) + RepositoryErrorKind::FormatError(IcechunkFormatErrorKind::InvalidSpecVersion) })?; let mut actual_file_type_int = 0; @@ -354,24 +375,29 @@ fn check_header( let actual_file_type: FileTypeBin = actual_file_type_int.try_into().map_err(|_| { - RepositoryError::FormatError(IcechunkFormatError::InvalidFileType { + RepositoryErrorKind::FormatError(IcechunkFormatErrorKind::InvalidFileType { expected: file_type, got: actual_file_type_int, }) })?; if actual_file_type != file_type { - return Err(RepositoryError::FormatError(IcechunkFormatError::InvalidFileType { - expected: file_type, - got: actual_file_type_int, - })); + return Err(RepositoryErrorKind::FormatError( + IcechunkFormatErrorKind::InvalidFileType { + expected: file_type, + got: actual_file_type_int, + }, + ) + .into()); } let mut compression = 0; read.read_exact(std::slice::from_mut(&mut compression))?; let compression = compression.try_into().map_err(|_| { - RepositoryError::FormatError(IcechunkFormatError::InvalidCompressionAlgorithm) + RepositoryErrorKind::FormatError( + IcechunkFormatErrorKind::InvalidCompressionAlgorithm, + ) })?; Ok((spec_version, compression)) @@ -400,10 +426,13 @@ async fn write_new_manifest( ), ]; - let id = new_manifest.id.clone(); + let id = new_manifest.id().clone(); + + let span = Span::current(); // TODO: we should compress only when the manifest reaches a certain size // but then, we would need to include metadata to know if it's compressed or not let buffer = tokio::task::spawn_blocking(move || { + let _entered = span.entered(); let buffer = binary_file_header( SpecVersionBin::current(), FileTypeBin::Manifest, @@ -418,11 +447,12 @@ async fn write_new_manifest( &mut compressor, )?; - compressor.finish().map_err(RepositoryError::IOError) + compressor.finish().map_err(RepositoryErrorKind::IOError) }) .await??; let len = buffer.len() as u64; + debug!(%id, size_bytes=len, "Writing manifest"); storage.write_manifest(storage_settings, id.clone(), metadata, buffer.into()).await?; Ok(len) } @@ -433,6 +463,8 @@ async fn fetch_manifest( storage: &(dyn Storage + Send + Sync), storage_settings: &storage::Settings, ) -> RepositoryResult> { + debug!(%manifest_id, "Downloading manifest"); + let reader = if manifest_size > 0 { storage .fetch_manifest_known_size(storage_settings, manifest_id, manifest_size) @@ -443,11 +475,12 @@ async fn fetch_manifest( ) }; + let span = Span::current(); tokio::task::spawn_blocking(move || { + let _entered = span.entered(); let (spec_version, decompressor) = check_and_get_decompressor(reader, FileTypeBin::Manifest)?; - deserialize_manifest(spec_version, decompressor) - .map_err(RepositoryError::DeserializationError) + deserialize_manifest(spec_version, decompressor).map_err(RepositoryError::from) }) .await? .map(Arc::new) @@ -490,7 +523,9 @@ async fn write_new_snapshot( ]; let id = new_snapshot.id().clone(); + let span = Span::current(); let buffer = tokio::task::spawn_blocking(move || { + let _entered = span.entered(); let buffer = binary_file_header( SpecVersionBin::current(), FileTypeBin::Snapshot, @@ -505,10 +540,11 @@ async fn write_new_snapshot( &mut compressor, )?; - compressor.finish().map_err(RepositoryError::IOError) + compressor.finish().map_err(RepositoryErrorKind::IOError) }) .await??; + debug!(%id, size_bytes=buffer.len(), "Writing snapshot"); storage.write_snapshot(storage_settings, id.clone(), metadata, buffer.into()).await?; Ok(id) @@ -519,15 +555,17 @@ async fn fetch_snapshot( storage: &(dyn Storage + Send + Sync), storage_settings: &storage::Settings, ) -> RepositoryResult> { + debug!(%snapshot_id, "Downloading snapshot"); let read = storage.fetch_snapshot(storage_settings, snapshot_id).await?; + let span = Span::current(); tokio::task::spawn_blocking(move || { + let _entered = span.entered(); let (spec_version, decompressor) = check_and_get_decompressor( Reader::Asynchronous(read), FileTypeBin::Snapshot, )?; - deserialize_snapshot(spec_version, decompressor) - .map_err(RepositoryError::DeserializationError) + deserialize_snapshot(spec_version, decompressor).map_err(RepositoryError::from) }) .await? .map(Arc::new) @@ -557,7 +595,9 @@ async fn write_new_tx_log( ), ]; + let span = Span::current(); let buffer = tokio::task::spawn_blocking(move || { + let _entered = span.entered(); let buffer = binary_file_header( SpecVersionBin::current(), FileTypeBin::TransactionLog, @@ -570,10 +610,11 @@ async fn write_new_tx_log( SpecVersionBin::current(), &mut compressor, )?; - compressor.finish().map_err(RepositoryError::IOError) + compressor.finish().map_err(RepositoryErrorKind::IOError) }) .await??; + debug!(%transaction_id, size_bytes=buffer.len(), "Writing transaction log"); storage .write_transaction_log(storage_settings, transaction_id, metadata, buffer.into()) .await?; @@ -586,15 +627,18 @@ async fn fetch_transaction_log( storage: &(dyn Storage + Send + Sync), storage_settings: &storage::Settings, ) -> RepositoryResult> { + debug!(%transaction_id, "Downloading transaction log"); let read = storage.fetch_transaction_log(storage_settings, transaction_id).await?; + let span = Span::current(); tokio::task::spawn_blocking(move || { + let _entered = span.entered(); let (spec_version, decompressor) = check_and_get_decompressor( Reader::Asynchronous(read), FileTypeBin::TransactionLog, )?; deserialize_transaction_log(spec_version, decompressor) - .map_err(RepositoryError::DeserializationError) + .map_err(RepositoryError::from) }) .await? .map(Arc::new) @@ -631,7 +675,7 @@ impl Weighter> for FileWeighter { #[allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)] mod test { - use itertools::Itertools; + use itertools::{assert_equal, Itertools}; use super::*; use crate::{ @@ -644,24 +688,26 @@ mod test { #[tokio::test(flavor = "multi_thread")] async fn test_caching_caches() -> Result<(), Box> { - let backend: Arc = new_in_memory_storage()?; + let backend: Arc = new_in_memory_storage().await?; let settings = storage::Settings::default(); let manager = AssetManager::new_no_cache(backend.clone(), settings.clone(), 1); + let node1 = NodeId::random(); + let node2 = NodeId::random(); let ci1 = ChunkInfo { - node: NodeId::random(), - coord: ChunkIndices(vec![]), + node: node1.clone(), + coord: ChunkIndices(vec![0]), payload: ChunkPayload::Inline(Bytes::copy_from_slice(b"a")), }; let ci2 = ChunkInfo { - node: NodeId::random(), - coord: ChunkIndices(vec![]), + node: node2.clone(), + coord: ChunkIndices(vec![1]), payload: ChunkPayload::Inline(Bytes::copy_from_slice(b"b")), }; let pre_existing_manifest = Manifest::from_iter(vec![ci1].into_iter()).await?.unwrap(); let pre_existing_manifest = Arc::new(pre_existing_manifest); - let pre_existing_id = &pre_existing_manifest.id; + let pre_existing_id = pre_existing_manifest.id(); let pre_size = manager.write_manifest(Arc::clone(&pre_existing_manifest)).await?; let logging = Arc::new(LoggingStorage::new(Arc::clone(&backend))); @@ -674,37 +720,38 @@ mod test { ); let manifest = - Arc::new(Manifest::from_iter(vec![ci2].into_iter()).await?.unwrap()); - let id = &manifest.id; + Arc::new(Manifest::from_iter(vec![ci2.clone()].into_iter()).await?.unwrap()); + let id = manifest.id(); let size = caching.write_manifest(Arc::clone(&manifest)).await?; - assert_eq!(caching.fetch_manifest(id, size).await?, manifest); - assert_eq!(caching.fetch_manifest(id, size).await?, manifest); + let fetched = caching.fetch_manifest(&id, size).await?; + assert_eq!(fetched.len(), 1); + assert_equal( + fetched.iter(node2.clone()).map(|x| x.unwrap()), + [(ci2.coord.clone(), ci2.payload.clone())], + ); + + // fetch again + caching.fetch_manifest(&id, size).await?; // when we insert we cache, so no fetches assert_eq!(logging.fetch_operations(), vec![]); // first time it sees an ID it calls the backend - assert_eq!( - caching.fetch_manifest(pre_existing_id, pre_size).await?, - pre_existing_manifest - ); + caching.fetch_manifest(&pre_existing_id, pre_size).await?; assert_eq!( logging.fetch_operations(), vec![("fetch_manifest_splitting".to_string(), pre_existing_id.to_string())] ); // only calls backend once - assert_eq!( - caching.fetch_manifest(pre_existing_id, pre_size).await?, - pre_existing_manifest - ); + caching.fetch_manifest(&pre_existing_id, pre_size).await?; assert_eq!( logging.fetch_operations(), vec![("fetch_manifest_splitting".to_string(), pre_existing_id.to_string())] ); // other walues still cached - assert_eq!(caching.fetch_manifest(id, size).await?, manifest); + caching.fetch_manifest(&id, size).await?; assert_eq!( logging.fetch_operations(), vec![("fetch_manifest_splitting".to_string(), pre_existing_id.to_string())] @@ -714,7 +761,7 @@ mod test { #[tokio::test(flavor = "multi_thread")] async fn test_caching_storage_has_limit() -> Result<(), Box> { - let backend: Arc = new_in_memory_storage()?; + let backend: Arc = new_in_memory_storage().await?; let settings = storage::Settings::default(); let manager = AssetManager::new_no_cache(backend.clone(), settings.clone(), 1); @@ -734,15 +781,15 @@ mod test { let manifest1 = Arc::new(Manifest::from_iter(vec![ci1, ci2, ci3]).await?.unwrap()); - let id1 = &manifest1.id; + let id1 = manifest1.id(); let size1 = manager.write_manifest(Arc::clone(&manifest1)).await?; let manifest2 = Arc::new(Manifest::from_iter(vec![ci4, ci5, ci6]).await?.unwrap()); - let id2 = &manifest2.id; + let id2 = manifest2.id(); let size2 = manager.write_manifest(Arc::clone(&manifest2)).await?; let manifest3 = Arc::new(Manifest::from_iter(vec![ci7, ci8, ci9]).await?.unwrap()); - let id3 = &manifest3.id; + let id3 = manifest3.id(); let size3 = manager.write_manifest(Arc::clone(&manifest3)).await?; let logging = Arc::new(LoggingStorage::new(Arc::clone(&backend))); @@ -763,9 +810,9 @@ mod test { // we keep asking for all 3 items, but the cache can only fit 2 for _ in 0..20 { - assert_eq!(caching.fetch_manifest(id1, size1).await?, manifest1); - assert_eq!(caching.fetch_manifest(id2, size2).await?, manifest2); - assert_eq!(caching.fetch_manifest(id3, size3).await?, manifest3); + caching.fetch_manifest(&id1, size1).await?; + caching.fetch_manifest(&id2, size2).await?; + caching.fetch_manifest(&id3, size3).await?; } // after the initial warming requests, we only request the file that doesn't fit in the cache assert_eq!(logging.fetch_operations()[10..].iter().unique().count(), 1); @@ -777,7 +824,7 @@ mod test { async fn test_dont_fetch_asset_twice() -> Result<(), Box> { // Test that two concurrent requests for the same manifest doesn't generate two // object_store requests, one of them must wait - let storage: Arc = new_in_memory_storage()?; + let storage: Arc = new_in_memory_storage().await?; let settings = storage::Settings::default(); let manager = Arc::new(AssetManager::new_no_cache(storage.clone(), settings.clone(), 1)); @@ -791,7 +838,7 @@ mod test { .await .unwrap() .unwrap(); - let manifest_id = manifest.id.clone(); + let manifest_id = manifest.id().clone(); let size = manager.write_manifest(Arc::new(manifest)).await?; let logging = Arc::new(LoggingStorage::new(Arc::clone(&storage))); diff --git a/icechunk/src/change_set.rs b/icechunk/src/change_set.rs index ec5f4d33..130f465d 100644 --- a/icechunk/src/change_set.rs +++ b/icechunk/src/change_set.rs @@ -1,41 +1,42 @@ use std::{ - collections::{HashMap, HashSet}, + collections::{BTreeMap, HashMap, HashSet}, iter, mem::take, }; -use itertools::Either; +use bytes::Bytes; +use itertools::{Either, Itertools as _}; use serde::{Deserialize, Serialize}; use crate::{ format::{ manifest::{ChunkInfo, ChunkPayload}, - snapshot::{NodeData, NodeSnapshot, UserAttributesSnapshot, ZarrArrayMetadata}, + snapshot::{ArrayShape, DimensionName, NodeData, NodeSnapshot}, ChunkIndices, NodeId, Path, }, - metadata::UserAttributes, session::SessionResult, }; -#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ArrayData { + pub shape: ArrayShape, + pub dimension_names: Option>, + pub user_data: Bytes, +} + +#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] pub struct ChangeSet { - new_groups: HashMap, - new_arrays: HashMap, - updated_arrays: HashMap, - // These paths may point to Arrays or Groups, - // since both Groups and Arrays support UserAttributes - updated_attributes: HashMap>, - // FIXME: issue with too many inline chunks kept in mem - set_chunks: HashMap>>, + new_groups: HashMap, + new_arrays: HashMap, + updated_arrays: HashMap, + updated_groups: HashMap, + // It's important we keep these sorted, we use this fact in TransactionLog creation + set_chunks: BTreeMap>>, deleted_groups: HashSet<(Path, NodeId)>, deleted_arrays: HashSet<(Path, NodeId)>, } impl ChangeSet { - pub fn zarr_updated_arrays(&self) -> impl Iterator { - self.updated_arrays.keys() - } - pub fn deleted_arrays(&self) -> impl Iterator { self.deleted_arrays.iter() } @@ -44,8 +45,12 @@ impl ChangeSet { self.deleted_groups.iter() } - pub fn user_attributes_updated_nodes(&self) -> impl Iterator { - self.updated_attributes.keys() + pub fn updated_arrays(&self) -> impl Iterator { + self.updated_arrays.keys() + } + + pub fn updated_groups(&self) -> impl Iterator { + self.updated_groups.keys() } pub fn array_is_deleted(&self, path_and_id: &(Path, NodeId)) -> bool { @@ -54,7 +59,7 @@ impl ChangeSet { pub fn chunk_changes( &self, - ) -> impl Iterator>)> + ) -> impl Iterator>)> { self.set_chunks.iter() } @@ -71,39 +76,55 @@ impl ChangeSet { self == &ChangeSet::default() } - pub fn add_group(&mut self, path: Path, node_id: NodeId) { - self.new_groups.insert(path, node_id); + pub fn add_group(&mut self, path: Path, node_id: NodeId, definition: Bytes) { + debug_assert!(!self.updated_groups.contains_key(&node_id)); + self.new_groups.insert(path, (node_id, definition)); } - pub fn get_group(&self, path: &Path) -> Option<&NodeId> { + pub fn get_group(&self, path: &Path) -> Option<&(NodeId, Bytes)> { self.new_groups.get(path) } - pub fn get_array(&self, path: &Path) -> Option<&(NodeId, ZarrArrayMetadata)> { + pub fn get_array(&self, path: &Path) -> Option<&(NodeId, ArrayData)> { self.new_arrays.get(path) } /// IMPORTANT: This method does not delete children. The caller /// is responsible for doing that pub fn delete_group(&mut self, path: Path, node_id: &NodeId) { - self.updated_attributes.remove(node_id); + self.updated_groups.remove(node_id); if self.new_groups.remove(&path).is_none() { // it's an old group, we need to flag it as deleted self.deleted_groups.insert((path, node_id.clone())); } } - pub fn add_array( - &mut self, - path: Path, - node_id: NodeId, - metadata: ZarrArrayMetadata, - ) { - self.new_arrays.insert(path, (node_id, metadata)); + pub fn add_array(&mut self, path: Path, node_id: NodeId, array_data: ArrayData) { + self.new_arrays.insert(path, (node_id, array_data)); } - pub fn update_array(&mut self, node_id: NodeId, metadata: ZarrArrayMetadata) { - self.updated_arrays.insert(node_id, metadata); + pub fn update_array(&mut self, node_id: &NodeId, path: &Path, array_data: ArrayData) { + match self.new_arrays.get(path) { + Some((id, _)) => { + debug_assert!(!self.updated_arrays.contains_key(id)); + self.new_arrays.insert(path.clone(), (node_id.clone(), array_data)); + } + None => { + self.updated_arrays.insert(node_id.clone(), array_data); + } + } + } + + pub fn update_group(&mut self, node_id: &NodeId, path: &Path, definition: Bytes) { + match self.new_groups.get(path) { + Some((id, _)) => { + debug_assert!(!self.updated_groups.contains_key(id)); + self.new_groups.insert(path.clone(), (node_id.clone(), definition)); + } + None => { + self.updated_groups.insert(node_id.clone(), definition); + } + } } pub fn delete_array(&mut self, path: Path, node_id: &NodeId) { @@ -116,7 +137,6 @@ impl ChangeSet { ); self.updated_arrays.remove(node_id); - self.updated_attributes.remove(node_id); self.set_chunks.remove(node_id); if !is_new_array { self.deleted_arrays.insert((path, node_id.clone())); @@ -128,30 +148,16 @@ impl ChangeSet { self.deleted_groups.contains(&key) || self.deleted_arrays.contains(&key) } - pub fn has_updated_attributes(&self, node_id: &NodeId) -> bool { - self.updated_attributes.contains_key(node_id) - } + //pub fn has_updated_definition(&self, node_id: &NodeId) -> bool { + // self.updated_definitions.contains_key(node_id) + //} - pub fn get_updated_zarr_metadata( - &self, - node_id: &NodeId, - ) -> Option<&ZarrArrayMetadata> { + pub fn get_updated_array(&self, node_id: &NodeId) -> Option<&ArrayData> { self.updated_arrays.get(node_id) } - pub fn update_user_attributes( - &mut self, - node_id: NodeId, - atts: Option, - ) { - self.updated_attributes.insert(node_id, atts); - } - - pub fn get_user_attributes( - &self, - node_id: &NodeId, - ) -> Option<&Option> { - self.updated_attributes.get(node_id) + pub fn get_updated_group(&self, node_id: &NodeId) -> Option<&Bytes> { + self.updated_groups.get(node_id) } pub fn set_chunk_ref( @@ -167,7 +173,7 @@ impl ChangeSet { .and_modify(|h| { h.insert(coord.clone(), data.clone()); }) - .or_insert(HashMap::from([(coord, data)])); + .or_insert(BTreeMap::from([(coord, data)])); } pub fn get_chunk_ref( @@ -233,7 +239,7 @@ impl ChangeSet { } pub fn new_groups(&self) -> impl Iterator { - self.new_groups.iter() + self.new_groups.iter().map(|(path, (node_id, _))| (path, node_id)) } pub fn new_arrays(&self) -> impl Iterator { @@ -242,13 +248,13 @@ impl ChangeSet { pub fn take_chunks( &mut self, - ) -> HashMap>> { + ) -> BTreeMap>> { take(&mut self.set_chunks) } pub fn set_chunks( &mut self, - chunks: HashMap>>, + chunks: BTreeMap>>, ) { self.set_chunks = chunks } @@ -263,8 +269,8 @@ impl ChangeSet { // TODO: optimize self.new_groups.extend(other.new_groups); self.new_arrays.extend(other.new_arrays); + self.updated_groups.extend(other.updated_groups); self.updated_arrays.extend(other.updated_arrays); - self.updated_attributes.extend(other.updated_attributes); self.deleted_groups.extend(other.deleted_groups); self.deleted_arrays.extend(other.deleted_arrays); @@ -302,12 +308,12 @@ impl ChangeSet { Ok(rmp_serde::from_slice(bytes)?) } - pub fn update_existing_chunks<'a>( + pub fn update_existing_chunks<'a, E>( &'a self, node: NodeId, - chunks: impl Iterator + 'a, - ) -> impl Iterator + 'a { - chunks.filter_map(move |chunk| match self.get_chunk_ref(&node, &chunk.coord) { + chunks: impl Iterator> + 'a, + ) -> impl Iterator> + 'a { + chunks.filter_map_ok(move |chunk| match self.get_chunk_ref(&node, &chunk.coord) { None => Some(chunk), Some(new_payload) => { new_payload.clone().map(|pl| ChunkInfo { payload: pl, ..chunk }) @@ -320,27 +326,30 @@ impl ChangeSet { } pub fn get_new_array(&self, path: &Path) -> Option { - self.get_array(path).map(|(id, meta)| { - let meta = self.get_updated_zarr_metadata(id).unwrap_or(meta).clone(); - let atts = self.get_user_attributes(id).cloned(); + self.get_array(path).map(|(id, array_data)| { + debug_assert!(!self.updated_arrays.contains_key(id)); NodeSnapshot { id: id.clone(), path: path.clone(), - user_attributes: atts.flatten().map(UserAttributesSnapshot::Inline), + user_data: array_data.user_data.clone(), // We put no manifests in new arrays, see get_chunk_ref to understand how chunks get // fetched for those arrays - node_data: NodeData::Array(meta.clone(), vec![]), + node_data: NodeData::Array { + shape: array_data.shape.clone(), + dimension_names: array_data.dimension_names.clone(), + manifests: vec![], + }, } }) } pub fn get_new_group(&self, path: &Path) -> Option { - self.get_group(path).map(|id| { - let atts = self.get_user_attributes(id).cloned(); + self.get_group(path).map(|(id, definition)| { + debug_assert!(!self.updated_groups.contains_key(id)); NodeSnapshot { id: id.clone(), path: path.clone(), - user_attributes: atts.flatten().map(UserAttributesSnapshot::Inline), + user_data: definition.clone(), node_data: NodeData::Group, } }) @@ -365,51 +374,51 @@ impl ChangeSet { return None; } - let session_atts = self - .get_user_attributes(&node.id) - .cloned() - .map(|a| a.map(UserAttributesSnapshot::Inline)); - let new_atts = session_atts.unwrap_or(node.user_attributes); match node.node_data { - NodeData::Group => Some(NodeSnapshot { user_attributes: new_atts, ..node }), - NodeData::Array(old_zarr_meta, manifests) => { - let new_zarr_meta = self - .get_updated_zarr_metadata(&node.id) - .cloned() - .unwrap_or(old_zarr_meta); - + NodeData::Group => { + let new_definition = + self.updated_groups.get(&node.id).cloned().unwrap_or(node.user_data); + Some(NodeSnapshot { user_data: new_definition, ..node }) + } + NodeData::Array { shape, dimension_names, manifests } => { + let new_data = + self.updated_arrays.get(&node.id).cloned().unwrap_or_else(|| { + ArrayData { shape, dimension_names, user_data: node.user_data } + }); Some(NodeSnapshot { - node_data: NodeData::Array(new_zarr_meta, manifests), - user_attributes: new_atts, + user_data: new_data.user_data, + node_data: NodeData::Array { + shape: new_data.shape, + dimension_names: new_data.dimension_names, + manifests, + }, ..node }) } } } - pub fn undo_user_attributes_update(&mut self, node_id: &NodeId) { - self.updated_attributes.remove(node_id); + pub fn undo_update(&mut self, node_id: &NodeId) { + self.updated_arrays.remove(node_id); + self.updated_groups.remove(node_id); } } #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { - use std::num::NonZeroU64; - + use bytes::Bytes; use itertools::Itertools; use super::ChangeSet; use crate::{ + change_set::ArrayData, format::{ manifest::{ChunkInfo, ChunkPayload}, - snapshot::ZarrArrayMetadata, + snapshot::ArrayShape, ChunkIndices, NodeId, }, - metadata::{ - ChunkKeyEncoding, ChunkShape, Codec, DataType, FillValue, StorageTransformer, - }, }; #[test] @@ -417,36 +426,26 @@ mod tests { let mut change_set = ChangeSet::default(); assert_eq!(None, change_set.new_arrays_chunk_iterator().next()); - let zarr_meta = ZarrArrayMetadata { - shape: vec![2, 2, 2], - data_type: DataType::Int32, - chunk_shape: ChunkShape(vec![ - NonZeroU64::new(1).unwrap(), - NonZeroU64::new(1).unwrap(), - NonZeroU64::new(1).unwrap(), - ]), - chunk_key_encoding: ChunkKeyEncoding::Slash, - fill_value: FillValue::Int32(0), - codecs: vec![Codec { name: "mycodec".to_string(), configuration: None }], - storage_transformers: Some(vec![StorageTransformer { - name: "mytransformer".to_string(), - configuration: None, - }]), - dimension_names: Some(vec![ - Some("x".to_string()), - Some("y".to_string()), - Some("t".to_string()), - ]), - }; + let shape = ArrayShape::new(vec![(2, 1), (2, 1), (2, 1)]).unwrap(); + let dimension_names = Some(vec!["x".into(), "y".into(), "t".into()]); let node_id1 = NodeId::random(); let node_id2 = NodeId::random(); + let array_data = ArrayData { + shape: shape.clone(), + dimension_names: dimension_names.clone(), + user_data: Bytes::from_static(b"foobar"), + }; change_set.add_array( "/foo/bar".try_into().unwrap(), node_id1.clone(), - zarr_meta.clone(), + array_data.clone(), + ); + change_set.add_array( + "/foo/baz".try_into().unwrap(), + node_id2.clone(), + array_data.clone(), ); - change_set.add_array("/foo/baz".try_into().unwrap(), node_id2.clone(), zarr_meta); assert_eq!(None, change_set.new_arrays_chunk_iterator().next()); change_set.set_chunk_ref(node_id1.clone(), ChunkIndices(vec![0, 1]), None); diff --git a/icechunk/src/config.rs b/icechunk/src/config.rs index df337bc6..da10f171 100644 --- a/icechunk/src/config.rs +++ b/icechunk/src/config.rs @@ -8,6 +8,7 @@ use std::{ use async_trait::async_trait; use chrono::{DateTime, Utc}; +pub use object_store::gcp::GcpCredential; use serde::{Deserialize, Serialize}; use crate::{ @@ -23,6 +24,19 @@ pub struct S3Options { pub allow_http: bool, } +impl fmt::Display for S3Options { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "S3Options(region={}, endpoint_url={}, anonymous={}, allow_http={})", + self.region.as_deref().unwrap_or("None"), + self.endpoint_url.as_deref().unwrap_or("None"), + self.anonymous, + self.allow_http + ) + } +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ObjectStoreConfig { @@ -54,7 +68,7 @@ impl CompressionConfig { } pub fn level(&self) -> u8 { - self.level.unwrap_or(1) + self.level.unwrap_or(3) } pub fn merge(&self, other: Self) -> Self { @@ -144,28 +158,28 @@ impl ManifestPreloadConfig { // regexes taken from https://github.com/xarray-contrib/cf-xarray/blob/1591ff5ea7664a6bdef24055ef75e242cd5bfc8b/cf_xarray/criteria.py#L149-L160 ManifestPreloadCondition::NameMatches { // time - regex: r#"\bt\b|(time|min|hour|day|week|month|year)[0-9]*"#.to_string(), // codespell:ignore + regex: r#"^\bt\b$|^(time|min|hour|day|week|month|year)[0-9]*$"#.to_string(), // codespell:ignore }, ManifestPreloadCondition::NameMatches { // Z - regex: r#"(z|nav_lev|gdep|lv_|[o]*lev|bottom_top|sigma|h(ei)?ght|altitude|depth|isobaric|pres|isotherm)[a-z_]*[0-9]*"#.to_string(), // codespell:ignore + regex: r#"^(z|nav_lev|gdep|lv_|[o]*lev|bottom_top|sigma|h(ei)?ght|altitude|depth|isobaric|pres|isotherm)[a-z_]*[0-9]*$"#.to_string(), // codespell:ignore }, ManifestPreloadCondition::NameMatches { // Y - regex: r#"y|j|nlat|rlat|nj"#.to_string(), // codespell:ignore + regex: r#"^(y|j|nlat|rlat|nj)$"#.to_string(), // codespell:ignore }, ManifestPreloadCondition::NameMatches { // latitude - regex: r#"y?(nav_lat|lat|gphi)[a-z0-9]*"#.to_string(), // codespell:ignore + regex: r#"^y?(nav_lat|lat|gphi)[a-z0-9]*$"#.to_string(), // codespell:ignore }, ManifestPreloadCondition::NameMatches { // longitude - regex: r#"x?(nav_lon|lon|glam)[a-z0-9]*"#.to_string(), // codespell:ignore + regex: r#"^x?(nav_lon|lon|glam)[a-z0-9]*$"#.to_string(), // codespell:ignore }, ManifestPreloadCondition::NameMatches { // X - regex: r#"x|i|nlon|rlon|ni"#.to_string(), // codespell:ignore + regex: r#"^(x|i|nlon|rlon|ni)$"#.to_string(), // codespell:ignore }, ]), ManifestPreloadCondition::NumRefs { @@ -204,11 +218,6 @@ impl ManifestConfig { pub struct RepositoryConfig { /// Chunks smaller than this will be stored inline in the manifst pub inline_chunk_threshold_bytes: Option, - /// Unsafely overwrite refs on write. This is not recommended, users should only use it at their - /// own risk in object stores for which we don't support write-object-if-not-exists. There is - /// the possibility of race conditions if this variable is set to true and there are concurrent - /// commit attempts. - pub unsafe_overwrite_refs: Option, /// Concurrency used by the get_partial_values operation to fetch different keys in parallel pub get_partial_values_concurrency: Option, @@ -235,9 +244,6 @@ impl RepositoryConfig { pub fn inline_chunk_threshold_bytes(&self) -> u16 { self.inline_chunk_threshold_bytes.unwrap_or(512) } - pub fn unsafe_overwrite_refs(&self) -> bool { - self.unsafe_overwrite_refs.unwrap_or(false) - } pub fn get_partial_values_concurrency(&self) -> u16 { self.get_partial_values_concurrency.unwrap_or(10) } @@ -267,9 +273,6 @@ impl RepositoryConfig { inline_chunk_threshold_bytes: other .inline_chunk_threshold_bytes .or(self.inline_chunk_threshold_bytes), - unsafe_overwrite_refs: other - .unsafe_overwrite_refs - .or(self.unsafe_overwrite_refs), get_partial_values_concurrency: other .get_partial_values_concurrency .or(self.get_partial_values_concurrency), @@ -365,38 +368,64 @@ pub struct S3StaticCredentials { } #[async_trait] -#[typetag::serde(tag = "type")] -pub trait CredentialsFetcher: fmt::Debug + Sync + Send { +#[typetag::serde(tag = "s3_credentials_fetcher_type")] +pub trait S3CredentialsFetcher: fmt::Debug + Sync + Send { async fn get(&self) -> Result; } #[derive(Clone, Debug, Deserialize, Serialize, Default)] -#[serde(tag = "type")] +#[serde(tag = "s3_credential_type")] #[serde(rename_all = "snake_case")] pub enum S3Credentials { #[default] FromEnv, Anonymous, Static(S3StaticCredentials), - Refreshable(Arc), + Refreshable(Arc), } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(tag = "gcs_static_credential_type")] #[serde(rename_all = "snake_case")] pub enum GcsStaticCredentials { ServiceAccount(PathBuf), ServiceAccountKey(String), ApplicationCredentials(PathBuf), + BearerToken(GcsBearerCredential), } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(tag = "gcs_bearer_credential_type")] +#[serde(rename_all = "snake_case")] +pub struct GcsBearerCredential { + pub bearer: String, + pub expires_after: Option>, +} + +impl From<&GcsBearerCredential> for GcpCredential { + fn from(value: &GcsBearerCredential) -> Self { + GcpCredential { bearer: value.bearer.clone() } + } +} + +#[async_trait] +#[typetag::serde(tag = "gcs_credentials_fetcher_type")] +pub trait GcsCredentialsFetcher: fmt::Debug + Sync + Send { + async fn get(&self) -> Result; +} + +#[derive(Clone, Debug, Deserialize, Serialize, Default)] +#[serde(tag = "gcs_credential_type")] #[serde(rename_all = "snake_case")] pub enum GcsCredentials { + #[default] FromEnv, Static(GcsStaticCredentials), + Refreshable(Arc), } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(tag = "az_static_credential_type")] #[serde(rename_all = "snake_case")] pub enum AzureStaticCredentials { AccessKey(String), @@ -405,6 +434,7 @@ pub enum AzureStaticCredentials { } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(tag = "az_credential_type")] #[serde(rename_all = "snake_case")] pub enum AzureCredentials { FromEnv, @@ -412,7 +442,7 @@ pub enum AzureCredentials { } #[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(tag = "type")] +#[serde(tag = "credential_type")] #[serde(rename_all = "snake_case")] pub enum Credentials { S3(S3Credentials), diff --git a/icechunk/src/conflicts/basic_solver.rs b/icechunk/src/conflicts/basic_solver.rs index 642c6b7f..c154da6e 100644 --- a/icechunk/src/conflicts/basic_solver.rs +++ b/icechunk/src/conflicts/basic_solver.rs @@ -17,7 +17,6 @@ pub enum VersionSelection { #[derive(Debug, Clone)] pub struct BasicConflictSolver { - pub on_user_attributes_conflict: VersionSelection, pub on_chunk_conflict: VersionSelection, pub fail_on_delete_of_updated_array: bool, pub fail_on_delete_of_updated_group: bool, @@ -26,7 +25,6 @@ pub struct BasicConflictSolver { impl Default for BasicConflictSolver { fn default() -> Self { Self { - on_user_attributes_conflict: VersionSelection::UseOurs, on_chunk_conflict: VersionSelection::UseOurs, fail_on_delete_of_updated_array: false, fail_on_delete_of_updated_group: false, @@ -72,32 +70,24 @@ impl BasicConflictSolver { conflicts: Vec, ) -> SessionResult { use Conflict::*; - let unsolvable = conflicts.iter().any( - |conflict| { - matches!( - conflict, - NewNodeConflictsWithExistingNode(_) | - NewNodeInInvalidGroup(_) | - ZarrMetadataDoubleUpdate(_) | - ZarrMetadataUpdateOfDeletedArray(_) | - UserAttributesUpdateOfDeletedNode(_) | - ChunksUpdatedInDeletedArray{..} | - ChunksUpdatedInUpdatedArray{..} - ) || - matches!(conflict, - UserAttributesDoubleUpdate{..} if self.on_user_attributes_conflict == VersionSelection::Fail - ) || - matches!(conflict, - ChunkDoubleUpdate{..} if self.on_chunk_conflict == VersionSelection::Fail - ) || - matches!(conflict, - DeleteOfUpdatedArray{..} if self.fail_on_delete_of_updated_array - ) || - matches!(conflict, - DeleteOfUpdatedGroup{..} if self.fail_on_delete_of_updated_group - ) - }, - ); + let unsolvable = conflicts.iter().any(|conflict| { + matches!( + conflict, + NewNodeConflictsWithExistingNode(_) + | NewNodeInInvalidGroup(_) + | ZarrMetadataDoubleUpdate(_) + | ZarrMetadataUpdateOfDeletedArray(_) + | ZarrMetadataUpdateOfDeletedGroup(_) + | ChunksUpdatedInDeletedArray { .. } + | ChunksUpdatedInUpdatedArray { .. } + ) || matches!(conflict, + ChunkDoubleUpdate{..} if self.on_chunk_conflict == VersionSelection::Fail + ) || matches!(conflict, + DeleteOfUpdatedArray{..} if self.fail_on_delete_of_updated_array + ) || matches!(conflict, + DeleteOfUpdatedGroup{..} if self.fail_on_delete_of_updated_group + ) + }); if unsolvable { return Ok(ConflictResolution::Unsolvable { @@ -123,20 +113,6 @@ impl BasicConflictSolver { VersionSelection::Fail => panic!("Bug in conflict resolution: ChunkDoubleUpdate flagged as unrecoverable") } } - UserAttributesDoubleUpdate { node_id, .. } => { - match self.on_user_attributes_conflict { - VersionSelection::UseOurs => { - // this is a no-op, our change will override the conflicting change - } - VersionSelection::UseTheirs => { - current_changes.undo_user_attributes_update(&node_id); - } - // we can panic here because we have returned from the function if there - // were any unsolvable conflicts - #[allow(clippy::panic)] - VersionSelection::Fail => panic!("Bug in conflict resolution: UserAttributesDoubleUpdate flagged as unrecoverable") - } - } DeleteOfUpdatedArray { .. } => { assert!(!self.fail_on_delete_of_updated_array); // this is a no-op, the solution is to still delete the array diff --git a/icechunk/src/conflicts/detector.rs b/icechunk/src/conflicts/detector.rs index 9cb79ab7..5bdd8dbe 100644 --- a/icechunk/src/conflicts/detector.rs +++ b/icechunk/src/conflicts/detector.rs @@ -10,7 +10,7 @@ use futures::{stream, StreamExt, TryStreamExt}; use crate::{ change_set::ChangeSet, format::{snapshot::NodeSnapshot, transaction_log::TransactionLog, NodeId, Path}, - session::{Session, SessionError, SessionResult}, + session::{Session, SessionError, SessionErrorKind, SessionResult}, }; use super::{Conflict, ConflictResolution, ConflictSolver}; @@ -35,7 +35,9 @@ impl ConflictSolver for ConflictDetector { Ok(_) => { Ok(Some(Conflict::NewNodeConflictsWithExistingNode(path.clone()))) } - Err(SessionError::NodeNotFound { .. }) => Ok(None), + Err(SessionError { + kind: SessionErrorKind::NodeNotFound { .. }, .. + }) => Ok(None), Err(err) => Err(err), } }); @@ -47,8 +49,14 @@ impl ConflictSolver for ConflictDetector { for parent in path.ancestors().skip(1) { match previous_repo.get_array(&parent).await { Ok(_) => return Ok(Some(Conflict::NewNodeInInvalidGroup(parent))), - Err(SessionError::NodeNotFound { .. }) - | Err(SessionError::NotAnArray { .. }) => {} + Err(SessionError { + kind: SessionErrorKind::NodeNotFound { .. }, + .. + }) + | Err(SessionError { + kind: SessionErrorKind::NotAnArray { .. }, + .. + }) => {} Err(err) => return Err(err), } } @@ -58,8 +66,8 @@ impl ConflictSolver for ConflictDetector { let path_finder = PathFinder::new(current_repo.list_nodes().await?); let updated_arrays_already_updated = current_changes - .zarr_updated_arrays() - .filter(|node_id| previous_change.updated_zarr_metadata.contains(node_id)) + .updated_arrays() + .filter(|node_id| previous_change.array_updated(node_id)) .map(Ok); let updated_arrays_already_updated = stream::iter(updated_arrays_already_updated) @@ -68,48 +76,42 @@ impl ConflictSolver for ConflictDetector { Ok(Conflict::ZarrMetadataDoubleUpdate(path)) }); - let updated_arrays_were_deleted = current_changes - .zarr_updated_arrays() - .filter(|node_id| previous_change.deleted_arrays.contains(node_id)) + let updated_groups_already_updated = current_changes + .updated_groups() + .filter(|node_id| previous_change.group_updated(node_id)) .map(Ok); - let updated_arrays_were_deleted = stream::iter(updated_arrays_were_deleted) + let updated_groups_already_updated = stream::iter(updated_groups_already_updated) .and_then(|node_id| async { let path = path_finder.find(node_id)?; - Ok(Conflict::ZarrMetadataUpdateOfDeletedArray(path)) + Ok(Conflict::ZarrMetadataDoubleUpdate(path)) }); - let updated_attributes_already_updated = current_changes - .user_attributes_updated_nodes() - .filter(|node_id| previous_change.updated_user_attributes.contains(node_id)) + let updated_arrays_were_deleted = current_changes + .updated_arrays() + .filter(|node_id| previous_change.array_deleted(node_id)) .map(Ok); - let updated_attributes_already_updated = - stream::iter(updated_attributes_already_updated).and_then(|node_id| async { + let updated_arrays_were_deleted = stream::iter(updated_arrays_were_deleted) + .and_then(|node_id| async { let path = path_finder.find(node_id)?; - Ok(Conflict::UserAttributesDoubleUpdate { - path, - node_id: node_id.clone(), - }) + Ok(Conflict::ZarrMetadataUpdateOfDeletedArray(path)) }); - let updated_attributes_on_deleted_node = current_changes - .user_attributes_updated_nodes() - .filter(|node_id| { - previous_change.deleted_arrays.contains(node_id) - || previous_change.deleted_groups.contains(node_id) - }) + let updated_groups_were_deleted = current_changes + .updated_groups() + .filter(|node_id| previous_change.group_deleted(node_id)) .map(Ok); - let updated_attributes_on_deleted_node = - stream::iter(updated_attributes_on_deleted_node).and_then(|node_id| async { + let updated_groups_were_deleted = stream::iter(updated_groups_were_deleted) + .and_then(|node_id| async { let path = path_finder.find(node_id)?; - Ok(Conflict::UserAttributesUpdateOfDeletedNode(path)) + Ok(Conflict::ZarrMetadataUpdateOfDeletedGroup(path)) }); let chunks_updated_in_deleted_array = current_changes .arrays_with_chunk_changes() - .filter(|node_id| previous_change.deleted_arrays.contains(node_id)) + .filter(|node_id| previous_change.array_deleted(node_id)) .map(Ok); let chunks_updated_in_deleted_array = @@ -123,7 +125,7 @@ impl ConflictSolver for ConflictDetector { let chunks_updated_in_updated_array = current_changes .arrays_with_chunk_changes() - .filter(|node_id| previous_change.updated_zarr_metadata.contains(node_id)) + .filter(|node_id| previous_change.array_updated(node_id)) .map(Ok); let chunks_updated_in_updated_array = @@ -137,9 +139,11 @@ impl ConflictSolver for ConflictDetector { let chunks_double_updated = current_changes.chunk_changes().filter_map(|(node_id, changes)| { - if let Some(previous_changes) = - previous_change.updated_chunks.get(node_id) - { + let previous_changes: HashSet<_> = + previous_change.updated_chunks_for(node_id).collect(); + if previous_changes.is_empty() { + None + } else { let conflicting: HashSet<_> = changes .keys() .filter(|coord| previous_changes.contains(coord)) @@ -150,8 +154,6 @@ impl ConflictSolver for ConflictDetector { } else { Some(Ok((node_id, conflicting))) } - } else { - None } }); @@ -172,14 +174,15 @@ impl ConflictSolver for ConflictDetector { .try_filter_map(|(path, _node_id)| async { let id = match previous_repo.get_node(path).await { Ok(node) => Some(node.id), - Err(SessionError::NodeNotFound { .. }) => None, + Err(SessionError { + kind: SessionErrorKind::NodeNotFound { .. }, .. + }) => None, Err(err) => Err(err)?, }; if let Some(node_id) = id { - if previous_change.updated_zarr_metadata.contains(&node_id) - || previous_change.updated_user_attributes.contains(&node_id) - || previous_change.updated_chunks.contains_key(&node_id) + if previous_change.array_updated(&node_id) + || previous_change.chunks_updated(&node_id) { Ok(Some(Conflict::DeleteOfUpdatedArray { path: path.clone(), @@ -199,12 +202,14 @@ impl ConflictSolver for ConflictDetector { .try_filter_map(|(path, _node_id)| async { let id = match previous_repo.get_node(path).await { Ok(node) => Some(node.id), - Err(SessionError::NodeNotFound { .. }) => None, + Err(SessionError { + kind: SessionErrorKind::NodeNotFound { .. }, .. + }) => None, Err(err) => Err(err)?, }; if let Some(node_id) = id { - if previous_change.updated_user_attributes.contains(&node_id) { + if previous_change.group_updated(&node_id) { Ok(Some(Conflict::DeleteOfUpdatedGroup { path: path.clone(), node_id: node_id.clone(), @@ -220,9 +225,9 @@ impl ConflictSolver for ConflictDetector { let all_conflicts: Vec<_> = new_nodes_explicit_conflicts .chain(new_nodes_implicit_conflicts) .chain(updated_arrays_already_updated) + .chain(updated_groups_already_updated) .chain(updated_arrays_were_deleted) - .chain(updated_attributes_already_updated) - .chain(updated_attributes_on_deleted_node) + .chain(updated_groups_were_deleted) .chain(chunks_updated_in_deleted_array) .chain(chunks_updated_in_updated_array) .chain(chunks_double_updated) @@ -244,7 +249,7 @@ impl ConflictSolver for ConflictDetector { struct PathFinder(Mutex<(HashMap, Option)>); -impl> PathFinder { +impl>> PathFinder { fn new(iter: It) -> Self { Self(Mutex::new((HashMap::new(), Some(iter)))) } @@ -260,6 +265,7 @@ impl> PathFinder { Ok(cached.clone()) } else if let Some(iterator) = iter { for node in iterator { + let node = node?; if &node.id == node_id { cache.insert(node.id, node.path.clone()); return Ok(node.path); @@ -268,9 +274,9 @@ impl> PathFinder { } } *iter = None; - Err(SessionError::ConflictingPathNotFound(node_id.clone())) + Err(SessionErrorKind::ConflictingPathNotFound(node_id.clone()).into()) } else { - Err(SessionError::ConflictingPathNotFound(node_id.clone())) + Err(SessionErrorKind::ConflictingPathNotFound(node_id.clone()).into()) } } } diff --git a/icechunk/src/conflicts/mod.rs b/icechunk/src/conflicts/mod.rs index 6bb84e5a..bc6b69d7 100644 --- a/icechunk/src/conflicts/mod.rs +++ b/icechunk/src/conflicts/mod.rs @@ -17,11 +17,7 @@ pub enum Conflict { NewNodeInInvalidGroup(Path), ZarrMetadataDoubleUpdate(Path), ZarrMetadataUpdateOfDeletedArray(Path), - UserAttributesDoubleUpdate { - path: Path, - node_id: NodeId, - }, - UserAttributesUpdateOfDeletedNode(Path), + ZarrMetadataUpdateOfDeletedGroup(Path), ChunkDoubleUpdate { path: Path, node_id: NodeId, diff --git a/icechunk/src/error.rs b/icechunk/src/error.rs new file mode 100644 index 00000000..7a375579 --- /dev/null +++ b/icechunk/src/error.rs @@ -0,0 +1,45 @@ +use std::fmt::Display; + +use tracing_error::SpanTrace; + +#[derive(Debug)] +pub struct ICError { + pub kind: E, + pub context: SpanTrace, +} + +impl ICError { + pub fn new(kind: E) -> Self { + Self::with_context(kind, SpanTrace::capture()) + } + + pub fn with_context(kind: E, context: SpanTrace) -> Self { + Self { kind, context } + } + + pub fn kind(&self) -> &E { + &self.kind + } + + pub fn span(&self) -> &SpanTrace { + &self.context + } +} + +impl std::fmt::Display for ICError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.kind.fmt(f)?; + write!(f, "\n\ncontext:\n{}\n", self.context)?; + Ok(()) + } +} + +impl std::error::Error for ICError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.kind) + } + + fn cause(&self) -> Option<&dyn std::error::Error> { + self.source() + } +} diff --git a/icechunk/src/format/flatbuffers/all_generated.rs b/icechunk/src/format/flatbuffers/all_generated.rs new file mode 100644 index 00000000..a0fe584e --- /dev/null +++ b/icechunk/src/format/flatbuffers/all_generated.rs @@ -0,0 +1,3101 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +// @generated + +use core::cmp::Ordering; +use core::mem; + +extern crate flatbuffers; +use self::flatbuffers::{EndianScalar, Follow}; + +#[allow(unused_imports, dead_code)] +pub mod gen { + + use core::cmp::Ordering; + use core::mem; + + extern crate flatbuffers; + use self::flatbuffers::{EndianScalar, Follow}; + + #[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." + )] + pub const ENUM_MIN_NODE_DATA: u8 = 0; + #[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." + )] + pub const ENUM_MAX_NODE_DATA: u8 = 2; + #[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." + )] + #[allow(non_camel_case_types)] + pub const ENUM_VALUES_NODE_DATA: [NodeData; 3] = + [NodeData::NONE, NodeData::Array, NodeData::Group]; + + #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] + #[repr(transparent)] + pub struct NodeData(pub u8); + #[allow(non_upper_case_globals)] + impl NodeData { + pub const NONE: Self = Self(0); + pub const Array: Self = Self(1); + pub const Group: Self = Self(2); + + pub const ENUM_MIN: u8 = 0; + pub const ENUM_MAX: u8 = 2; + pub const ENUM_VALUES: &'static [Self] = &[Self::NONE, Self::Array, Self::Group]; + /// Returns the variant's name or "" if unknown. + pub fn variant_name(self) -> Option<&'static str> { + match self { + Self::NONE => Some("NONE"), + Self::Array => Some("Array"), + Self::Group => Some("Group"), + _ => None, + } + } + } + impl core::fmt::Debug for NodeData { + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + if let Some(name) = self.variant_name() { + f.write_str(name) + } else { + f.write_fmt(format_args!("", self.0)) + } + } + } + impl<'a> flatbuffers::Follow<'a> for NodeData { + type Inner = Self; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + let b = flatbuffers::read_scalar_at::(buf, loc); + Self(b) + } + } + + impl flatbuffers::Push for NodeData { + type Output = NodeData; + #[inline] + unsafe fn push(&self, dst: &mut [u8], _written_len: usize) { + flatbuffers::emplace_scalar::(dst, self.0); + } + } + + impl flatbuffers::EndianScalar for NodeData { + type Scalar = u8; + #[inline] + fn to_little_endian(self) -> u8 { + self.0.to_le() + } + #[inline] + #[allow(clippy::wrong_self_convention)] + fn from_little_endian(v: u8) -> Self { + let b = u8::from_le(v); + Self(b) + } + } + + impl<'a> flatbuffers::Verifiable for NodeData { + #[inline] + fn run_verifier( + v: &mut flatbuffers::Verifier, + pos: usize, + ) -> Result<(), flatbuffers::InvalidFlatbuffer> { + use self::flatbuffers::Verifiable; + u8::run_verifier(v, pos) + } + } + + impl flatbuffers::SimpleToVerifyInSlice for NodeData {} + pub struct NodeDataUnionTableOffset {} + + // struct ObjectId12, aligned to 1 + #[repr(transparent)] + #[derive(Clone, Copy, PartialEq)] + pub struct ObjectId12(pub [u8; 12]); + impl Default for ObjectId12 { + fn default() -> Self { + Self([0; 12]) + } + } + impl core::fmt::Debug for ObjectId12 { + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + f.debug_struct("ObjectId12").field("bytes", &self.bytes()).finish() + } + } + + impl flatbuffers::SimpleToVerifyInSlice for ObjectId12 {} + impl<'a> flatbuffers::Follow<'a> for ObjectId12 { + type Inner = &'a ObjectId12; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + <&'a ObjectId12>::follow(buf, loc) + } + } + impl<'a> flatbuffers::Follow<'a> for &'a ObjectId12 { + type Inner = &'a ObjectId12; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + flatbuffers::follow_cast_ref::(buf, loc) + } + } + impl<'b> flatbuffers::Push for ObjectId12 { + type Output = ObjectId12; + #[inline] + unsafe fn push(&self, dst: &mut [u8], _written_len: usize) { + let src = ::core::slice::from_raw_parts( + self as *const ObjectId12 as *const u8, + ::size(), + ); + dst.copy_from_slice(src); + } + #[inline] + fn alignment() -> flatbuffers::PushAlignment { + flatbuffers::PushAlignment::new(1) + } + } + + impl<'a> flatbuffers::Verifiable for ObjectId12 { + #[inline] + fn run_verifier( + v: &mut flatbuffers::Verifier, + pos: usize, + ) -> Result<(), flatbuffers::InvalidFlatbuffer> { + use self::flatbuffers::Verifiable; + v.in_buffer::(pos) + } + } + + impl<'a> ObjectId12 { + #[allow(clippy::too_many_arguments)] + pub fn new(bytes: &[u8; 12]) -> Self { + let mut s = Self([0; 12]); + s.set_bytes(bytes); + s + } + + pub fn bytes(&'a self) -> flatbuffers::Array<'a, u8, 12> { + // Safety: + // Created from a valid Table for this object + // Which contains a valid array in this slot + unsafe { flatbuffers::Array::follow(&self.0, 0) } + } + + pub fn set_bytes(&mut self, items: &[u8; 12]) { + // Safety: + // Created from a valid Table for this object + // Which contains a valid array in this slot + unsafe { flatbuffers::emplace_scalar_array(&mut self.0, 0, items) }; + } + } + + // struct ObjectId8, aligned to 1 + #[repr(transparent)] + #[derive(Clone, Copy, PartialEq)] + pub struct ObjectId8(pub [u8; 8]); + impl Default for ObjectId8 { + fn default() -> Self { + Self([0; 8]) + } + } + impl core::fmt::Debug for ObjectId8 { + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + f.debug_struct("ObjectId8").field("bytes", &self.bytes()).finish() + } + } + + impl flatbuffers::SimpleToVerifyInSlice for ObjectId8 {} + impl<'a> flatbuffers::Follow<'a> for ObjectId8 { + type Inner = &'a ObjectId8; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + <&'a ObjectId8>::follow(buf, loc) + } + } + impl<'a> flatbuffers::Follow<'a> for &'a ObjectId8 { + type Inner = &'a ObjectId8; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + flatbuffers::follow_cast_ref::(buf, loc) + } + } + impl<'b> flatbuffers::Push for ObjectId8 { + type Output = ObjectId8; + #[inline] + unsafe fn push(&self, dst: &mut [u8], _written_len: usize) { + let src = ::core::slice::from_raw_parts( + self as *const ObjectId8 as *const u8, + ::size(), + ); + dst.copy_from_slice(src); + } + #[inline] + fn alignment() -> flatbuffers::PushAlignment { + flatbuffers::PushAlignment::new(1) + } + } + + impl<'a> flatbuffers::Verifiable for ObjectId8 { + #[inline] + fn run_verifier( + v: &mut flatbuffers::Verifier, + pos: usize, + ) -> Result<(), flatbuffers::InvalidFlatbuffer> { + use self::flatbuffers::Verifiable; + v.in_buffer::(pos) + } + } + + impl<'a> ObjectId8 { + #[allow(clippy::too_many_arguments)] + pub fn new(bytes: &[u8; 8]) -> Self { + let mut s = Self([0; 8]); + s.set_bytes(bytes); + s + } + + pub fn bytes(&'a self) -> flatbuffers::Array<'a, u8, 8> { + // Safety: + // Created from a valid Table for this object + // Which contains a valid array in this slot + unsafe { flatbuffers::Array::follow(&self.0, 0) } + } + + pub fn set_bytes(&mut self, items: &[u8; 8]) { + // Safety: + // Created from a valid Table for this object + // Which contains a valid array in this slot + unsafe { flatbuffers::emplace_scalar_array(&mut self.0, 0, items) }; + } + } + + // struct ManifestFileInfo, aligned to 8 + #[repr(transparent)] + #[derive(Clone, Copy, PartialEq)] + pub struct ManifestFileInfo(pub [u8; 32]); + impl Default for ManifestFileInfo { + fn default() -> Self { + Self([0; 32]) + } + } + impl core::fmt::Debug for ManifestFileInfo { + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + f.debug_struct("ManifestFileInfo") + .field("id", &self.id()) + .field("size_bytes", &self.size_bytes()) + .field("num_chunk_refs", &self.num_chunk_refs()) + .finish() + } + } + + impl flatbuffers::SimpleToVerifyInSlice for ManifestFileInfo {} + impl<'a> flatbuffers::Follow<'a> for ManifestFileInfo { + type Inner = &'a ManifestFileInfo; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + <&'a ManifestFileInfo>::follow(buf, loc) + } + } + impl<'a> flatbuffers::Follow<'a> for &'a ManifestFileInfo { + type Inner = &'a ManifestFileInfo; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + flatbuffers::follow_cast_ref::(buf, loc) + } + } + impl<'b> flatbuffers::Push for ManifestFileInfo { + type Output = ManifestFileInfo; + #[inline] + unsafe fn push(&self, dst: &mut [u8], _written_len: usize) { + let src = ::core::slice::from_raw_parts( + self as *const ManifestFileInfo as *const u8, + ::size(), + ); + dst.copy_from_slice(src); + } + #[inline] + fn alignment() -> flatbuffers::PushAlignment { + flatbuffers::PushAlignment::new(8) + } + } + + impl<'a> flatbuffers::Verifiable for ManifestFileInfo { + #[inline] + fn run_verifier( + v: &mut flatbuffers::Verifier, + pos: usize, + ) -> Result<(), flatbuffers::InvalidFlatbuffer> { + use self::flatbuffers::Verifiable; + v.in_buffer::(pos) + } + } + + impl<'a> ManifestFileInfo { + #[allow(clippy::too_many_arguments)] + pub fn new(id: &ObjectId12, size_bytes: u64, num_chunk_refs: u32) -> Self { + let mut s = Self([0; 32]); + s.set_id(id); + s.set_size_bytes(size_bytes); + s.set_num_chunk_refs(num_chunk_refs); + s + } + + pub fn id(&self) -> &ObjectId12 { + // Safety: + // Created from a valid Table for this object + // Which contains a valid struct in this slot + unsafe { &*(self.0[0..].as_ptr() as *const ObjectId12) } + } + + #[allow(clippy::identity_op)] + pub fn set_id(&mut self, x: &ObjectId12) { + self.0[0..0 + 12].copy_from_slice(&x.0) + } + + pub fn size_bytes(&self) -> u64 { + let mut mem = + core::mem::MaybeUninit::<::Scalar>::uninit(); + // Safety: + // Created from a valid Table for this object + // Which contains a valid value in this slot + EndianScalar::from_little_endian(unsafe { + core::ptr::copy_nonoverlapping( + self.0[16..].as_ptr(), + mem.as_mut_ptr() as *mut u8, + core::mem::size_of::<::Scalar>(), + ); + mem.assume_init() + }) + } + + pub fn set_size_bytes(&mut self, x: u64) { + let x_le = x.to_little_endian(); + // Safety: + // Created from a valid Table for this object + // Which contains a valid value in this slot + unsafe { + core::ptr::copy_nonoverlapping( + &x_le as *const _ as *const u8, + self.0[16..].as_mut_ptr(), + core::mem::size_of::<::Scalar>(), + ); + } + } + + pub fn num_chunk_refs(&self) -> u32 { + let mut mem = + core::mem::MaybeUninit::<::Scalar>::uninit(); + // Safety: + // Created from a valid Table for this object + // Which contains a valid value in this slot + EndianScalar::from_little_endian(unsafe { + core::ptr::copy_nonoverlapping( + self.0[24..].as_ptr(), + mem.as_mut_ptr() as *mut u8, + core::mem::size_of::<::Scalar>(), + ); + mem.assume_init() + }) + } + + pub fn set_num_chunk_refs(&mut self, x: u32) { + let x_le = x.to_little_endian(); + // Safety: + // Created from a valid Table for this object + // Which contains a valid value in this slot + unsafe { + core::ptr::copy_nonoverlapping( + &x_le as *const _ as *const u8, + self.0[24..].as_mut_ptr(), + core::mem::size_of::<::Scalar>(), + ); + } + } + } + + // struct ChunkIndexRange, aligned to 4 + #[repr(transparent)] + #[derive(Clone, Copy, PartialEq)] + pub struct ChunkIndexRange(pub [u8; 8]); + impl Default for ChunkIndexRange { + fn default() -> Self { + Self([0; 8]) + } + } + impl core::fmt::Debug for ChunkIndexRange { + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + f.debug_struct("ChunkIndexRange") + .field("from", &self.from()) + .field("to", &self.to()) + .finish() + } + } + + impl flatbuffers::SimpleToVerifyInSlice for ChunkIndexRange {} + impl<'a> flatbuffers::Follow<'a> for ChunkIndexRange { + type Inner = &'a ChunkIndexRange; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + <&'a ChunkIndexRange>::follow(buf, loc) + } + } + impl<'a> flatbuffers::Follow<'a> for &'a ChunkIndexRange { + type Inner = &'a ChunkIndexRange; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + flatbuffers::follow_cast_ref::(buf, loc) + } + } + impl<'b> flatbuffers::Push for ChunkIndexRange { + type Output = ChunkIndexRange; + #[inline] + unsafe fn push(&self, dst: &mut [u8], _written_len: usize) { + let src = ::core::slice::from_raw_parts( + self as *const ChunkIndexRange as *const u8, + ::size(), + ); + dst.copy_from_slice(src); + } + #[inline] + fn alignment() -> flatbuffers::PushAlignment { + flatbuffers::PushAlignment::new(4) + } + } + + impl<'a> flatbuffers::Verifiable for ChunkIndexRange { + #[inline] + fn run_verifier( + v: &mut flatbuffers::Verifier, + pos: usize, + ) -> Result<(), flatbuffers::InvalidFlatbuffer> { + use self::flatbuffers::Verifiable; + v.in_buffer::(pos) + } + } + + impl<'a> ChunkIndexRange { + #[allow(clippy::too_many_arguments)] + pub fn new(from: u32, to: u32) -> Self { + let mut s = Self([0; 8]); + s.set_from(from); + s.set_to(to); + s + } + + pub fn from(&self) -> u32 { + let mut mem = + core::mem::MaybeUninit::<::Scalar>::uninit(); + // Safety: + // Created from a valid Table for this object + // Which contains a valid value in this slot + EndianScalar::from_little_endian(unsafe { + core::ptr::copy_nonoverlapping( + self.0[0..].as_ptr(), + mem.as_mut_ptr() as *mut u8, + core::mem::size_of::<::Scalar>(), + ); + mem.assume_init() + }) + } + + pub fn set_from(&mut self, x: u32) { + let x_le = x.to_little_endian(); + // Safety: + // Created from a valid Table for this object + // Which contains a valid value in this slot + unsafe { + core::ptr::copy_nonoverlapping( + &x_le as *const _ as *const u8, + self.0[0..].as_mut_ptr(), + core::mem::size_of::<::Scalar>(), + ); + } + } + + pub fn to(&self) -> u32 { + let mut mem = + core::mem::MaybeUninit::<::Scalar>::uninit(); + // Safety: + // Created from a valid Table for this object + // Which contains a valid value in this slot + EndianScalar::from_little_endian(unsafe { + core::ptr::copy_nonoverlapping( + self.0[4..].as_ptr(), + mem.as_mut_ptr() as *mut u8, + core::mem::size_of::<::Scalar>(), + ); + mem.assume_init() + }) + } + + pub fn set_to(&mut self, x: u32) { + let x_le = x.to_little_endian(); + // Safety: + // Created from a valid Table for this object + // Which contains a valid value in this slot + unsafe { + core::ptr::copy_nonoverlapping( + &x_le as *const _ as *const u8, + self.0[4..].as_mut_ptr(), + core::mem::size_of::<::Scalar>(), + ); + } + } + } + + // struct DimensionShape, aligned to 8 + #[repr(transparent)] + #[derive(Clone, Copy, PartialEq)] + pub struct DimensionShape(pub [u8; 16]); + impl Default for DimensionShape { + fn default() -> Self { + Self([0; 16]) + } + } + impl core::fmt::Debug for DimensionShape { + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + f.debug_struct("DimensionShape") + .field("array_length", &self.array_length()) + .field("chunk_length", &self.chunk_length()) + .finish() + } + } + + impl flatbuffers::SimpleToVerifyInSlice for DimensionShape {} + impl<'a> flatbuffers::Follow<'a> for DimensionShape { + type Inner = &'a DimensionShape; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + <&'a DimensionShape>::follow(buf, loc) + } + } + impl<'a> flatbuffers::Follow<'a> for &'a DimensionShape { + type Inner = &'a DimensionShape; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + flatbuffers::follow_cast_ref::(buf, loc) + } + } + impl<'b> flatbuffers::Push for DimensionShape { + type Output = DimensionShape; + #[inline] + unsafe fn push(&self, dst: &mut [u8], _written_len: usize) { + let src = ::core::slice::from_raw_parts( + self as *const DimensionShape as *const u8, + ::size(), + ); + dst.copy_from_slice(src); + } + #[inline] + fn alignment() -> flatbuffers::PushAlignment { + flatbuffers::PushAlignment::new(8) + } + } + + impl<'a> flatbuffers::Verifiable for DimensionShape { + #[inline] + fn run_verifier( + v: &mut flatbuffers::Verifier, + pos: usize, + ) -> Result<(), flatbuffers::InvalidFlatbuffer> { + use self::flatbuffers::Verifiable; + v.in_buffer::(pos) + } + } + + impl<'a> DimensionShape { + #[allow(clippy::too_many_arguments)] + pub fn new(array_length: u64, chunk_length: u64) -> Self { + let mut s = Self([0; 16]); + s.set_array_length(array_length); + s.set_chunk_length(chunk_length); + s + } + + pub fn array_length(&self) -> u64 { + let mut mem = + core::mem::MaybeUninit::<::Scalar>::uninit(); + // Safety: + // Created from a valid Table for this object + // Which contains a valid value in this slot + EndianScalar::from_little_endian(unsafe { + core::ptr::copy_nonoverlapping( + self.0[0..].as_ptr(), + mem.as_mut_ptr() as *mut u8, + core::mem::size_of::<::Scalar>(), + ); + mem.assume_init() + }) + } + + pub fn set_array_length(&mut self, x: u64) { + let x_le = x.to_little_endian(); + // Safety: + // Created from a valid Table for this object + // Which contains a valid value in this slot + unsafe { + core::ptr::copy_nonoverlapping( + &x_le as *const _ as *const u8, + self.0[0..].as_mut_ptr(), + core::mem::size_of::<::Scalar>(), + ); + } + } + + pub fn chunk_length(&self) -> u64 { + let mut mem = + core::mem::MaybeUninit::<::Scalar>::uninit(); + // Safety: + // Created from a valid Table for this object + // Which contains a valid value in this slot + EndianScalar::from_little_endian(unsafe { + core::ptr::copy_nonoverlapping( + self.0[8..].as_ptr(), + mem.as_mut_ptr() as *mut u8, + core::mem::size_of::<::Scalar>(), + ); + mem.assume_init() + }) + } + + pub fn set_chunk_length(&mut self, x: u64) { + let x_le = x.to_little_endian(); + // Safety: + // Created from a valid Table for this object + // Which contains a valid value in this slot + unsafe { + core::ptr::copy_nonoverlapping( + &x_le as *const _ as *const u8, + self.0[8..].as_mut_ptr(), + core::mem::size_of::<::Scalar>(), + ); + } + } + } + + pub enum ChunkRefOffset {} + #[derive(Copy, Clone, PartialEq)] + + pub struct ChunkRef<'a> { + pub _tab: flatbuffers::Table<'a>, + } + + impl<'a> flatbuffers::Follow<'a> for ChunkRef<'a> { + type Inner = ChunkRef<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { _tab: flatbuffers::Table::new(buf, loc) } + } + } + + impl<'a> ChunkRef<'a> { + pub const VT_INDEX: flatbuffers::VOffsetT = 4; + pub const VT_INLINE: flatbuffers::VOffsetT = 6; + pub const VT_OFFSET: flatbuffers::VOffsetT = 8; + pub const VT_LENGTH: flatbuffers::VOffsetT = 10; + pub const VT_CHUNK_ID: flatbuffers::VOffsetT = 12; + pub const VT_LOCATION: flatbuffers::VOffsetT = 14; + pub const VT_CHECKSUM_ETAG: flatbuffers::VOffsetT = 16; + pub const VT_CHECKSUM_LAST_MODIFIED: flatbuffers::VOffsetT = 18; + + #[inline] + pub unsafe fn init_from_table(table: flatbuffers::Table<'a>) -> Self { + ChunkRef { _tab: table } + } + #[allow(unused_mut)] + pub fn create< + 'bldr: 'args, + 'args: 'mut_bldr, + 'mut_bldr, + A: flatbuffers::Allocator + 'bldr, + >( + _fbb: &'mut_bldr mut flatbuffers::FlatBufferBuilder<'bldr, A>, + args: &'args ChunkRefArgs<'args>, + ) -> flatbuffers::WIPOffset> { + let mut builder = ChunkRefBuilder::new(_fbb); + builder.add_length(args.length); + builder.add_offset(args.offset); + builder.add_checksum_last_modified(args.checksum_last_modified); + if let Some(x) = args.checksum_etag { + builder.add_checksum_etag(x); + } + if let Some(x) = args.location { + builder.add_location(x); + } + if let Some(x) = args.chunk_id { + builder.add_chunk_id(x); + } + if let Some(x) = args.inline { + builder.add_inline(x); + } + if let Some(x) = args.index { + builder.add_index(x); + } + builder.finish() + } + + #[inline] + pub fn index(&self) -> flatbuffers::Vector<'a, u32> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::>>( + ChunkRef::VT_INDEX, + None, + ) + .unwrap() + } + } + #[inline] + pub fn inline(&self) -> Option> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::>>( + ChunkRef::VT_INLINE, + None, + ) + } + } + #[inline] + pub fn offset(&self) -> u64 { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::(ChunkRef::VT_OFFSET, Some(0)).unwrap() } + } + #[inline] + pub fn length(&self) -> u64 { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::(ChunkRef::VT_LENGTH, Some(0)).unwrap() } + } + #[inline] + pub fn chunk_id(&self) -> Option<&'a ObjectId12> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::(ChunkRef::VT_CHUNK_ID, None) } + } + #[inline] + pub fn location(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab.get::>( + ChunkRef::VT_LOCATION, + None, + ) + } + } + #[inline] + pub fn checksum_etag(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab.get::>( + ChunkRef::VT_CHECKSUM_ETAG, + None, + ) + } + } + #[inline] + pub fn checksum_last_modified(&self) -> u32 { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::(ChunkRef::VT_CHECKSUM_LAST_MODIFIED, Some(0)) + .unwrap() + } + } + } + + impl flatbuffers::Verifiable for ChunkRef<'_> { + #[inline] + fn run_verifier( + v: &mut flatbuffers::Verifier, + pos: usize, + ) -> Result<(), flatbuffers::InvalidFlatbuffer> { + use self::flatbuffers::Verifiable; + v.visit_table(pos)? + .visit_field::>>("index", Self::VT_INDEX, true)? + .visit_field::>>("inline", Self::VT_INLINE, false)? + .visit_field::("offset", Self::VT_OFFSET, false)? + .visit_field::("length", Self::VT_LENGTH, false)? + .visit_field::("chunk_id", Self::VT_CHUNK_ID, false)? + .visit_field::>("location", Self::VT_LOCATION, false)? + .visit_field::>("checksum_etag", Self::VT_CHECKSUM_ETAG, false)? + .visit_field::("checksum_last_modified", Self::VT_CHECKSUM_LAST_MODIFIED, false)? + .finish(); + Ok(()) + } + } + pub struct ChunkRefArgs<'a> { + pub index: Option>>, + pub inline: Option>>, + pub offset: u64, + pub length: u64, + pub chunk_id: Option<&'a ObjectId12>, + pub location: Option>, + pub checksum_etag: Option>, + pub checksum_last_modified: u32, + } + impl<'a> Default for ChunkRefArgs<'a> { + #[inline] + fn default() -> Self { + ChunkRefArgs { + index: None, // required field + inline: None, + offset: 0, + length: 0, + chunk_id: None, + location: None, + checksum_etag: None, + checksum_last_modified: 0, + } + } + } + + pub struct ChunkRefBuilder<'a: 'b, 'b, A: flatbuffers::Allocator + 'a> { + fbb_: &'b mut flatbuffers::FlatBufferBuilder<'a, A>, + start_: flatbuffers::WIPOffset, + } + impl<'a: 'b, 'b, A: flatbuffers::Allocator + 'a> ChunkRefBuilder<'a, 'b, A> { + #[inline] + pub fn add_index( + &mut self, + index: flatbuffers::WIPOffset>, + ) { + self.fbb_ + .push_slot_always::>(ChunkRef::VT_INDEX, index); + } + #[inline] + pub fn add_inline( + &mut self, + inline: flatbuffers::WIPOffset>, + ) { + self.fbb_.push_slot_always::>( + ChunkRef::VT_INLINE, + inline, + ); + } + #[inline] + pub fn add_offset(&mut self, offset: u64) { + self.fbb_.push_slot::(ChunkRef::VT_OFFSET, offset, 0); + } + #[inline] + pub fn add_length(&mut self, length: u64) { + self.fbb_.push_slot::(ChunkRef::VT_LENGTH, length, 0); + } + #[inline] + pub fn add_chunk_id(&mut self, chunk_id: &ObjectId12) { + self.fbb_.push_slot_always::<&ObjectId12>(ChunkRef::VT_CHUNK_ID, chunk_id); + } + #[inline] + pub fn add_location(&mut self, location: flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::>( + ChunkRef::VT_LOCATION, + location, + ); + } + #[inline] + pub fn add_checksum_etag( + &mut self, + checksum_etag: flatbuffers::WIPOffset<&'b str>, + ) { + self.fbb_.push_slot_always::>( + ChunkRef::VT_CHECKSUM_ETAG, + checksum_etag, + ); + } + #[inline] + pub fn add_checksum_last_modified(&mut self, checksum_last_modified: u32) { + self.fbb_.push_slot::( + ChunkRef::VT_CHECKSUM_LAST_MODIFIED, + checksum_last_modified, + 0, + ); + } + #[inline] + pub fn new( + _fbb: &'b mut flatbuffers::FlatBufferBuilder<'a, A>, + ) -> ChunkRefBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + ChunkRefBuilder { fbb_: _fbb, start_: start } + } + #[inline] + pub fn finish(self) -> flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + self.fbb_.required(o, ChunkRef::VT_INDEX, "index"); + flatbuffers::WIPOffset::new(o.value()) + } + } + + impl core::fmt::Debug for ChunkRef<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let mut ds = f.debug_struct("ChunkRef"); + ds.field("index", &self.index()); + ds.field("inline", &self.inline()); + ds.field("offset", &self.offset()); + ds.field("length", &self.length()); + ds.field("chunk_id", &self.chunk_id()); + ds.field("location", &self.location()); + ds.field("checksum_etag", &self.checksum_etag()); + ds.field("checksum_last_modified", &self.checksum_last_modified()); + ds.finish() + } + } + pub enum ArrayManifestOffset {} + #[derive(Copy, Clone, PartialEq)] + + pub struct ArrayManifest<'a> { + pub _tab: flatbuffers::Table<'a>, + } + + impl<'a> flatbuffers::Follow<'a> for ArrayManifest<'a> { + type Inner = ArrayManifest<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { _tab: flatbuffers::Table::new(buf, loc) } + } + } + + impl<'a> ArrayManifest<'a> { + pub const VT_NODE_ID: flatbuffers::VOffsetT = 4; + pub const VT_REFS: flatbuffers::VOffsetT = 6; + + #[inline] + pub unsafe fn init_from_table(table: flatbuffers::Table<'a>) -> Self { + ArrayManifest { _tab: table } + } + #[allow(unused_mut)] + pub fn create< + 'bldr: 'args, + 'args: 'mut_bldr, + 'mut_bldr, + A: flatbuffers::Allocator + 'bldr, + >( + _fbb: &'mut_bldr mut flatbuffers::FlatBufferBuilder<'bldr, A>, + args: &'args ArrayManifestArgs<'args>, + ) -> flatbuffers::WIPOffset> { + let mut builder = ArrayManifestBuilder::new(_fbb); + if let Some(x) = args.refs { + builder.add_refs(x); + } + if let Some(x) = args.node_id { + builder.add_node_id(x); + } + builder.finish() + } + + #[inline] + pub fn node_id(&self) -> &'a ObjectId8 { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab.get::(ArrayManifest::VT_NODE_ID, None).unwrap() + } + } + #[inline] + pub fn refs( + &self, + ) -> flatbuffers::Vector<'a, flatbuffers::ForwardsUOffset>> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::>, + >>(ArrayManifest::VT_REFS, None) + .unwrap() + } + } + } + + impl flatbuffers::Verifiable for ArrayManifest<'_> { + #[inline] + fn run_verifier( + v: &mut flatbuffers::Verifier, + pos: usize, + ) -> Result<(), flatbuffers::InvalidFlatbuffer> { + use self::flatbuffers::Verifiable; + v.visit_table(pos)? + .visit_field::("node_id", Self::VT_NODE_ID, true)? + .visit_field::>, + >>("refs", Self::VT_REFS, true)? + .finish(); + Ok(()) + } + } + pub struct ArrayManifestArgs<'a> { + pub node_id: Option<&'a ObjectId8>, + pub refs: Option< + flatbuffers::WIPOffset< + flatbuffers::Vector<'a, flatbuffers::ForwardsUOffset>>, + >, + >, + } + impl<'a> Default for ArrayManifestArgs<'a> { + #[inline] + fn default() -> Self { + ArrayManifestArgs { + node_id: None, // required field + refs: None, // required field + } + } + } + + pub struct ArrayManifestBuilder<'a: 'b, 'b, A: flatbuffers::Allocator + 'a> { + fbb_: &'b mut flatbuffers::FlatBufferBuilder<'a, A>, + start_: flatbuffers::WIPOffset, + } + impl<'a: 'b, 'b, A: flatbuffers::Allocator + 'a> ArrayManifestBuilder<'a, 'b, A> { + #[inline] + pub fn add_node_id(&mut self, node_id: &ObjectId8) { + self.fbb_.push_slot_always::<&ObjectId8>(ArrayManifest::VT_NODE_ID, node_id); + } + #[inline] + pub fn add_refs( + &mut self, + refs: flatbuffers::WIPOffset< + flatbuffers::Vector<'b, flatbuffers::ForwardsUOffset>>, + >, + ) { + self.fbb_.push_slot_always::>( + ArrayManifest::VT_REFS, + refs, + ); + } + #[inline] + pub fn new( + _fbb: &'b mut flatbuffers::FlatBufferBuilder<'a, A>, + ) -> ArrayManifestBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + ArrayManifestBuilder { fbb_: _fbb, start_: start } + } + #[inline] + pub fn finish(self) -> flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + self.fbb_.required(o, ArrayManifest::VT_NODE_ID, "node_id"); + self.fbb_.required(o, ArrayManifest::VT_REFS, "refs"); + flatbuffers::WIPOffset::new(o.value()) + } + } + + impl core::fmt::Debug for ArrayManifest<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let mut ds = f.debug_struct("ArrayManifest"); + ds.field("node_id", &self.node_id()); + ds.field("refs", &self.refs()); + ds.finish() + } + } + pub enum ManifestOffset {} + #[derive(Copy, Clone, PartialEq)] + + pub struct Manifest<'a> { + pub _tab: flatbuffers::Table<'a>, + } + + impl<'a> flatbuffers::Follow<'a> for Manifest<'a> { + type Inner = Manifest<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { _tab: flatbuffers::Table::new(buf, loc) } + } + } + + impl<'a> Manifest<'a> { + pub const VT_ID: flatbuffers::VOffsetT = 4; + pub const VT_ARRAYS: flatbuffers::VOffsetT = 6; + + #[inline] + pub unsafe fn init_from_table(table: flatbuffers::Table<'a>) -> Self { + Manifest { _tab: table } + } + #[allow(unused_mut)] + pub fn create< + 'bldr: 'args, + 'args: 'mut_bldr, + 'mut_bldr, + A: flatbuffers::Allocator + 'bldr, + >( + _fbb: &'mut_bldr mut flatbuffers::FlatBufferBuilder<'bldr, A>, + args: &'args ManifestArgs<'args>, + ) -> flatbuffers::WIPOffset> { + let mut builder = ManifestBuilder::new(_fbb); + if let Some(x) = args.arrays { + builder.add_arrays(x); + } + if let Some(x) = args.id { + builder.add_id(x); + } + builder.finish() + } + + #[inline] + pub fn id(&self) -> &'a ObjectId12 { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::(Manifest::VT_ID, None).unwrap() } + } + #[inline] + pub fn arrays( + &self, + ) -> flatbuffers::Vector<'a, flatbuffers::ForwardsUOffset>> + { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::, + >, + >>(Manifest::VT_ARRAYS, None) + .unwrap() + } + } + } + + impl flatbuffers::Verifiable for Manifest<'_> { + #[inline] + fn run_verifier( + v: &mut flatbuffers::Verifier, + pos: usize, + ) -> Result<(), flatbuffers::InvalidFlatbuffer> { + use self::flatbuffers::Verifiable; + v.visit_table(pos)? + .visit_field::("id", Self::VT_ID, true)? + .visit_field::>, + >>("arrays", Self::VT_ARRAYS, true)? + .finish(); + Ok(()) + } + } + pub struct ManifestArgs<'a> { + pub id: Option<&'a ObjectId12>, + pub arrays: Option< + flatbuffers::WIPOffset< + flatbuffers::Vector<'a, flatbuffers::ForwardsUOffset>>, + >, + >, + } + impl<'a> Default for ManifestArgs<'a> { + #[inline] + fn default() -> Self { + ManifestArgs { + id: None, // required field + arrays: None, // required field + } + } + } + + pub struct ManifestBuilder<'a: 'b, 'b, A: flatbuffers::Allocator + 'a> { + fbb_: &'b mut flatbuffers::FlatBufferBuilder<'a, A>, + start_: flatbuffers::WIPOffset, + } + impl<'a: 'b, 'b, A: flatbuffers::Allocator + 'a> ManifestBuilder<'a, 'b, A> { + #[inline] + pub fn add_id(&mut self, id: &ObjectId12) { + self.fbb_.push_slot_always::<&ObjectId12>(Manifest::VT_ID, id); + } + #[inline] + pub fn add_arrays( + &mut self, + arrays: flatbuffers::WIPOffset< + flatbuffers::Vector<'b, flatbuffers::ForwardsUOffset>>, + >, + ) { + self.fbb_.push_slot_always::>( + Manifest::VT_ARRAYS, + arrays, + ); + } + #[inline] + pub fn new( + _fbb: &'b mut flatbuffers::FlatBufferBuilder<'a, A>, + ) -> ManifestBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + ManifestBuilder { fbb_: _fbb, start_: start } + } + #[inline] + pub fn finish(self) -> flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + self.fbb_.required(o, Manifest::VT_ID, "id"); + self.fbb_.required(o, Manifest::VT_ARRAYS, "arrays"); + flatbuffers::WIPOffset::new(o.value()) + } + } + + impl core::fmt::Debug for Manifest<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let mut ds = f.debug_struct("Manifest"); + ds.field("id", &self.id()); + ds.field("arrays", &self.arrays()); + ds.finish() + } + } + pub enum MetadataItemOffset {} + #[derive(Copy, Clone, PartialEq)] + + pub struct MetadataItem<'a> { + pub _tab: flatbuffers::Table<'a>, + } + + impl<'a> flatbuffers::Follow<'a> for MetadataItem<'a> { + type Inner = MetadataItem<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { _tab: flatbuffers::Table::new(buf, loc) } + } + } + + impl<'a> MetadataItem<'a> { + pub const VT_NAME: flatbuffers::VOffsetT = 4; + pub const VT_VALUE: flatbuffers::VOffsetT = 6; + + #[inline] + pub unsafe fn init_from_table(table: flatbuffers::Table<'a>) -> Self { + MetadataItem { _tab: table } + } + #[allow(unused_mut)] + pub fn create< + 'bldr: 'args, + 'args: 'mut_bldr, + 'mut_bldr, + A: flatbuffers::Allocator + 'bldr, + >( + _fbb: &'mut_bldr mut flatbuffers::FlatBufferBuilder<'bldr, A>, + args: &'args MetadataItemArgs<'args>, + ) -> flatbuffers::WIPOffset> { + let mut builder = MetadataItemBuilder::new(_fbb); + if let Some(x) = args.value { + builder.add_value(x); + } + if let Some(x) = args.name { + builder.add_name(x); + } + builder.finish() + } + + #[inline] + pub fn name(&self) -> &'a str { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::>( + MetadataItem::VT_NAME, + None, + ) + .unwrap() + } + } + #[inline] + pub fn value(&self) -> flatbuffers::Vector<'a, u8> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::>>( + MetadataItem::VT_VALUE, + None, + ) + .unwrap() + } + } + } + + impl flatbuffers::Verifiable for MetadataItem<'_> { + #[inline] + fn run_verifier( + v: &mut flatbuffers::Verifier, + pos: usize, + ) -> Result<(), flatbuffers::InvalidFlatbuffer> { + use self::flatbuffers::Verifiable; + v.visit_table(pos)? + .visit_field::>( + "name", + Self::VT_NAME, + true, + )? + .visit_field::>>( + "value", + Self::VT_VALUE, + true, + )? + .finish(); + Ok(()) + } + } + pub struct MetadataItemArgs<'a> { + pub name: Option>, + pub value: Option>>, + } + impl<'a> Default for MetadataItemArgs<'a> { + #[inline] + fn default() -> Self { + MetadataItemArgs { + name: None, // required field + value: None, // required field + } + } + } + + pub struct MetadataItemBuilder<'a: 'b, 'b, A: flatbuffers::Allocator + 'a> { + fbb_: &'b mut flatbuffers::FlatBufferBuilder<'a, A>, + start_: flatbuffers::WIPOffset, + } + impl<'a: 'b, 'b, A: flatbuffers::Allocator + 'a> MetadataItemBuilder<'a, 'b, A> { + #[inline] + pub fn add_name(&mut self, name: flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::>( + MetadataItem::VT_NAME, + name, + ); + } + #[inline] + pub fn add_value( + &mut self, + value: flatbuffers::WIPOffset>, + ) { + self.fbb_.push_slot_always::>( + MetadataItem::VT_VALUE, + value, + ); + } + #[inline] + pub fn new( + _fbb: &'b mut flatbuffers::FlatBufferBuilder<'a, A>, + ) -> MetadataItemBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + MetadataItemBuilder { fbb_: _fbb, start_: start } + } + #[inline] + pub fn finish(self) -> flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + self.fbb_.required(o, MetadataItem::VT_NAME, "name"); + self.fbb_.required(o, MetadataItem::VT_VALUE, "value"); + flatbuffers::WIPOffset::new(o.value()) + } + } + + impl core::fmt::Debug for MetadataItem<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let mut ds = f.debug_struct("MetadataItem"); + ds.field("name", &self.name()); + ds.field("value", &self.value()); + ds.finish() + } + } + pub enum ManifestRefOffset {} + #[derive(Copy, Clone, PartialEq)] + + pub struct ManifestRef<'a> { + pub _tab: flatbuffers::Table<'a>, + } + + impl<'a> flatbuffers::Follow<'a> for ManifestRef<'a> { + type Inner = ManifestRef<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { _tab: flatbuffers::Table::new(buf, loc) } + } + } + + impl<'a> ManifestRef<'a> { + pub const VT_OBJECT_ID: flatbuffers::VOffsetT = 4; + pub const VT_EXTENTS: flatbuffers::VOffsetT = 6; + + #[inline] + pub unsafe fn init_from_table(table: flatbuffers::Table<'a>) -> Self { + ManifestRef { _tab: table } + } + #[allow(unused_mut)] + pub fn create< + 'bldr: 'args, + 'args: 'mut_bldr, + 'mut_bldr, + A: flatbuffers::Allocator + 'bldr, + >( + _fbb: &'mut_bldr mut flatbuffers::FlatBufferBuilder<'bldr, A>, + args: &'args ManifestRefArgs<'args>, + ) -> flatbuffers::WIPOffset> { + let mut builder = ManifestRefBuilder::new(_fbb); + if let Some(x) = args.extents { + builder.add_extents(x); + } + if let Some(x) = args.object_id { + builder.add_object_id(x); + } + builder.finish() + } + + #[inline] + pub fn object_id(&self) -> &'a ObjectId12 { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab.get::(ManifestRef::VT_OBJECT_ID, None).unwrap() + } + } + #[inline] + pub fn extents(&self) -> flatbuffers::Vector<'a, ChunkIndexRange> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab.get::>>(ManifestRef::VT_EXTENTS, None).unwrap() + } + } + } + + impl flatbuffers::Verifiable for ManifestRef<'_> { + #[inline] + fn run_verifier( + v: &mut flatbuffers::Verifier, + pos: usize, + ) -> Result<(), flatbuffers::InvalidFlatbuffer> { + use self::flatbuffers::Verifiable; + v.visit_table(pos)? + .visit_field::("object_id", Self::VT_OBJECT_ID, true)? + .visit_field::>>("extents", Self::VT_EXTENTS, true)? + .finish(); + Ok(()) + } + } + pub struct ManifestRefArgs<'a> { + pub object_id: Option<&'a ObjectId12>, + pub extents: + Option>>, + } + impl<'a> Default for ManifestRefArgs<'a> { + #[inline] + fn default() -> Self { + ManifestRefArgs { + object_id: None, // required field + extents: None, // required field + } + } + } + + pub struct ManifestRefBuilder<'a: 'b, 'b, A: flatbuffers::Allocator + 'a> { + fbb_: &'b mut flatbuffers::FlatBufferBuilder<'a, A>, + start_: flatbuffers::WIPOffset, + } + impl<'a: 'b, 'b, A: flatbuffers::Allocator + 'a> ManifestRefBuilder<'a, 'b, A> { + #[inline] + pub fn add_object_id(&mut self, object_id: &ObjectId12) { + self.fbb_ + .push_slot_always::<&ObjectId12>(ManifestRef::VT_OBJECT_ID, object_id); + } + #[inline] + pub fn add_extents( + &mut self, + extents: flatbuffers::WIPOffset>, + ) { + self.fbb_.push_slot_always::>( + ManifestRef::VT_EXTENTS, + extents, + ); + } + #[inline] + pub fn new( + _fbb: &'b mut flatbuffers::FlatBufferBuilder<'a, A>, + ) -> ManifestRefBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + ManifestRefBuilder { fbb_: _fbb, start_: start } + } + #[inline] + pub fn finish(self) -> flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + self.fbb_.required(o, ManifestRef::VT_OBJECT_ID, "object_id"); + self.fbb_.required(o, ManifestRef::VT_EXTENTS, "extents"); + flatbuffers::WIPOffset::new(o.value()) + } + } + + impl core::fmt::Debug for ManifestRef<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let mut ds = f.debug_struct("ManifestRef"); + ds.field("object_id", &self.object_id()); + ds.field("extents", &self.extents()); + ds.finish() + } + } + pub enum DimensionNameOffset {} + #[derive(Copy, Clone, PartialEq)] + + pub struct DimensionName<'a> { + pub _tab: flatbuffers::Table<'a>, + } + + impl<'a> flatbuffers::Follow<'a> for DimensionName<'a> { + type Inner = DimensionName<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { _tab: flatbuffers::Table::new(buf, loc) } + } + } + + impl<'a> DimensionName<'a> { + pub const VT_NAME: flatbuffers::VOffsetT = 4; + + #[inline] + pub unsafe fn init_from_table(table: flatbuffers::Table<'a>) -> Self { + DimensionName { _tab: table } + } + #[allow(unused_mut)] + pub fn create< + 'bldr: 'args, + 'args: 'mut_bldr, + 'mut_bldr, + A: flatbuffers::Allocator + 'bldr, + >( + _fbb: &'mut_bldr mut flatbuffers::FlatBufferBuilder<'bldr, A>, + args: &'args DimensionNameArgs<'args>, + ) -> flatbuffers::WIPOffset> { + let mut builder = DimensionNameBuilder::new(_fbb); + if let Some(x) = args.name { + builder.add_name(x); + } + builder.finish() + } + + #[inline] + pub fn name(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab.get::>( + DimensionName::VT_NAME, + None, + ) + } + } + } + + impl flatbuffers::Verifiable for DimensionName<'_> { + #[inline] + fn run_verifier( + v: &mut flatbuffers::Verifier, + pos: usize, + ) -> Result<(), flatbuffers::InvalidFlatbuffer> { + use self::flatbuffers::Verifiable; + v.visit_table(pos)? + .visit_field::>( + "name", + Self::VT_NAME, + false, + )? + .finish(); + Ok(()) + } + } + pub struct DimensionNameArgs<'a> { + pub name: Option>, + } + impl<'a> Default for DimensionNameArgs<'a> { + #[inline] + fn default() -> Self { + DimensionNameArgs { name: None } + } + } + + pub struct DimensionNameBuilder<'a: 'b, 'b, A: flatbuffers::Allocator + 'a> { + fbb_: &'b mut flatbuffers::FlatBufferBuilder<'a, A>, + start_: flatbuffers::WIPOffset, + } + impl<'a: 'b, 'b, A: flatbuffers::Allocator + 'a> DimensionNameBuilder<'a, 'b, A> { + #[inline] + pub fn add_name(&mut self, name: flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::>( + DimensionName::VT_NAME, + name, + ); + } + #[inline] + pub fn new( + _fbb: &'b mut flatbuffers::FlatBufferBuilder<'a, A>, + ) -> DimensionNameBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + DimensionNameBuilder { fbb_: _fbb, start_: start } + } + #[inline] + pub fn finish(self) -> flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + flatbuffers::WIPOffset::new(o.value()) + } + } + + impl core::fmt::Debug for DimensionName<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let mut ds = f.debug_struct("DimensionName"); + ds.field("name", &self.name()); + ds.finish() + } + } + pub enum GroupNodeDataOffset {} + #[derive(Copy, Clone, PartialEq)] + + pub struct GroupNodeData<'a> { + pub _tab: flatbuffers::Table<'a>, + } + + impl<'a> flatbuffers::Follow<'a> for GroupNodeData<'a> { + type Inner = GroupNodeData<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { _tab: flatbuffers::Table::new(buf, loc) } + } + } + + impl<'a> GroupNodeData<'a> { + #[inline] + pub unsafe fn init_from_table(table: flatbuffers::Table<'a>) -> Self { + GroupNodeData { _tab: table } + } + #[allow(unused_mut)] + pub fn create< + 'bldr: 'args, + 'args: 'mut_bldr, + 'mut_bldr, + A: flatbuffers::Allocator + 'bldr, + >( + _fbb: &'mut_bldr mut flatbuffers::FlatBufferBuilder<'bldr, A>, + _args: &'args GroupNodeDataArgs, + ) -> flatbuffers::WIPOffset> { + let mut builder = GroupNodeDataBuilder::new(_fbb); + builder.finish() + } + } + + impl flatbuffers::Verifiable for GroupNodeData<'_> { + #[inline] + fn run_verifier( + v: &mut flatbuffers::Verifier, + pos: usize, + ) -> Result<(), flatbuffers::InvalidFlatbuffer> { + use self::flatbuffers::Verifiable; + v.visit_table(pos)?.finish(); + Ok(()) + } + } + pub struct GroupNodeDataArgs {} + impl<'a> Default for GroupNodeDataArgs { + #[inline] + fn default() -> Self { + GroupNodeDataArgs {} + } + } + + pub struct GroupNodeDataBuilder<'a: 'b, 'b, A: flatbuffers::Allocator + 'a> { + fbb_: &'b mut flatbuffers::FlatBufferBuilder<'a, A>, + start_: flatbuffers::WIPOffset, + } + impl<'a: 'b, 'b, A: flatbuffers::Allocator + 'a> GroupNodeDataBuilder<'a, 'b, A> { + #[inline] + pub fn new( + _fbb: &'b mut flatbuffers::FlatBufferBuilder<'a, A>, + ) -> GroupNodeDataBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + GroupNodeDataBuilder { fbb_: _fbb, start_: start } + } + #[inline] + pub fn finish(self) -> flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + flatbuffers::WIPOffset::new(o.value()) + } + } + + impl core::fmt::Debug for GroupNodeData<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let mut ds = f.debug_struct("GroupNodeData"); + ds.finish() + } + } + pub enum ArrayNodeDataOffset {} + #[derive(Copy, Clone, PartialEq)] + + pub struct ArrayNodeData<'a> { + pub _tab: flatbuffers::Table<'a>, + } + + impl<'a> flatbuffers::Follow<'a> for ArrayNodeData<'a> { + type Inner = ArrayNodeData<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { _tab: flatbuffers::Table::new(buf, loc) } + } + } + + impl<'a> ArrayNodeData<'a> { + pub const VT_SHAPE: flatbuffers::VOffsetT = 4; + pub const VT_DIMENSION_NAMES: flatbuffers::VOffsetT = 6; + pub const VT_MANIFESTS: flatbuffers::VOffsetT = 8; + + #[inline] + pub unsafe fn init_from_table(table: flatbuffers::Table<'a>) -> Self { + ArrayNodeData { _tab: table } + } + #[allow(unused_mut)] + pub fn create< + 'bldr: 'args, + 'args: 'mut_bldr, + 'mut_bldr, + A: flatbuffers::Allocator + 'bldr, + >( + _fbb: &'mut_bldr mut flatbuffers::FlatBufferBuilder<'bldr, A>, + args: &'args ArrayNodeDataArgs<'args>, + ) -> flatbuffers::WIPOffset> { + let mut builder = ArrayNodeDataBuilder::new(_fbb); + if let Some(x) = args.manifests { + builder.add_manifests(x); + } + if let Some(x) = args.dimension_names { + builder.add_dimension_names(x); + } + if let Some(x) = args.shape { + builder.add_shape(x); + } + builder.finish() + } + + #[inline] + pub fn shape(&self) -> flatbuffers::Vector<'a, DimensionShape> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab.get::>>(ArrayNodeData::VT_SHAPE, None).unwrap() + } + } + #[inline] + pub fn dimension_names( + &self, + ) -> Option< + flatbuffers::Vector<'a, flatbuffers::ForwardsUOffset>>, + > { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab.get::>, + >>(ArrayNodeData::VT_DIMENSION_NAMES, None) + } + } + #[inline] + pub fn manifests( + &self, + ) -> flatbuffers::Vector<'a, flatbuffers::ForwardsUOffset>> + { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::, + >, + >>(ArrayNodeData::VT_MANIFESTS, None) + .unwrap() + } + } + } + + impl flatbuffers::Verifiable for ArrayNodeData<'_> { + #[inline] + fn run_verifier( + v: &mut flatbuffers::Verifier, + pos: usize, + ) -> Result<(), flatbuffers::InvalidFlatbuffer> { + use self::flatbuffers::Verifiable; + v.visit_table(pos)? + .visit_field::>>("shape", Self::VT_SHAPE, true)? + .visit_field::>>>("dimension_names", Self::VT_DIMENSION_NAMES, false)? + .visit_field::>>>("manifests", Self::VT_MANIFESTS, true)? + .finish(); + Ok(()) + } + } + pub struct ArrayNodeDataArgs<'a> { + pub shape: + Option>>, + pub dimension_names: Option< + flatbuffers::WIPOffset< + flatbuffers::Vector<'a, flatbuffers::ForwardsUOffset>>, + >, + >, + pub manifests: Option< + flatbuffers::WIPOffset< + flatbuffers::Vector<'a, flatbuffers::ForwardsUOffset>>, + >, + >, + } + impl<'a> Default for ArrayNodeDataArgs<'a> { + #[inline] + fn default() -> Self { + ArrayNodeDataArgs { + shape: None, // required field + dimension_names: None, + manifests: None, // required field + } + } + } + + pub struct ArrayNodeDataBuilder<'a: 'b, 'b, A: flatbuffers::Allocator + 'a> { + fbb_: &'b mut flatbuffers::FlatBufferBuilder<'a, A>, + start_: flatbuffers::WIPOffset, + } + impl<'a: 'b, 'b, A: flatbuffers::Allocator + 'a> ArrayNodeDataBuilder<'a, 'b, A> { + #[inline] + pub fn add_shape( + &mut self, + shape: flatbuffers::WIPOffset>, + ) { + self.fbb_.push_slot_always::>( + ArrayNodeData::VT_SHAPE, + shape, + ); + } + #[inline] + pub fn add_dimension_names( + &mut self, + dimension_names: flatbuffers::WIPOffset< + flatbuffers::Vector<'b, flatbuffers::ForwardsUOffset>>, + >, + ) { + self.fbb_.push_slot_always::>( + ArrayNodeData::VT_DIMENSION_NAMES, + dimension_names, + ); + } + #[inline] + pub fn add_manifests( + &mut self, + manifests: flatbuffers::WIPOffset< + flatbuffers::Vector<'b, flatbuffers::ForwardsUOffset>>, + >, + ) { + self.fbb_.push_slot_always::>( + ArrayNodeData::VT_MANIFESTS, + manifests, + ); + } + #[inline] + pub fn new( + _fbb: &'b mut flatbuffers::FlatBufferBuilder<'a, A>, + ) -> ArrayNodeDataBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + ArrayNodeDataBuilder { fbb_: _fbb, start_: start } + } + #[inline] + pub fn finish(self) -> flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + self.fbb_.required(o, ArrayNodeData::VT_SHAPE, "shape"); + self.fbb_.required(o, ArrayNodeData::VT_MANIFESTS, "manifests"); + flatbuffers::WIPOffset::new(o.value()) + } + } + + impl core::fmt::Debug for ArrayNodeData<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let mut ds = f.debug_struct("ArrayNodeData"); + ds.field("shape", &self.shape()); + ds.field("dimension_names", &self.dimension_names()); + ds.field("manifests", &self.manifests()); + ds.finish() + } + } + pub enum NodeSnapshotOffset {} + #[derive(Copy, Clone, PartialEq)] + + pub struct NodeSnapshot<'a> { + pub _tab: flatbuffers::Table<'a>, + } + + impl<'a> flatbuffers::Follow<'a> for NodeSnapshot<'a> { + type Inner = NodeSnapshot<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { _tab: flatbuffers::Table::new(buf, loc) } + } + } + + impl<'a> NodeSnapshot<'a> { + pub const VT_ID: flatbuffers::VOffsetT = 4; + pub const VT_PATH: flatbuffers::VOffsetT = 6; + pub const VT_USER_DATA: flatbuffers::VOffsetT = 8; + pub const VT_NODE_DATA_TYPE: flatbuffers::VOffsetT = 10; + pub const VT_NODE_DATA: flatbuffers::VOffsetT = 12; + + #[inline] + pub unsafe fn init_from_table(table: flatbuffers::Table<'a>) -> Self { + NodeSnapshot { _tab: table } + } + #[allow(unused_mut)] + pub fn create< + 'bldr: 'args, + 'args: 'mut_bldr, + 'mut_bldr, + A: flatbuffers::Allocator + 'bldr, + >( + _fbb: &'mut_bldr mut flatbuffers::FlatBufferBuilder<'bldr, A>, + args: &'args NodeSnapshotArgs<'args>, + ) -> flatbuffers::WIPOffset> { + let mut builder = NodeSnapshotBuilder::new(_fbb); + if let Some(x) = args.node_data { + builder.add_node_data(x); + } + if let Some(x) = args.user_data { + builder.add_user_data(x); + } + if let Some(x) = args.path { + builder.add_path(x); + } + if let Some(x) = args.id { + builder.add_id(x); + } + builder.add_node_data_type(args.node_data_type); + builder.finish() + } + + #[inline] + pub fn id(&self) -> &'a ObjectId8 { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::(NodeSnapshot::VT_ID, None).unwrap() } + } + #[inline] + pub fn path(&self) -> &'a str { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::>( + NodeSnapshot::VT_PATH, + None, + ) + .unwrap() + } + } + #[inline] + pub fn user_data(&self) -> flatbuffers::Vector<'a, u8> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::>>( + NodeSnapshot::VT_USER_DATA, + None, + ) + .unwrap() + } + } + #[inline] + pub fn node_data_type(&self) -> NodeData { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::( + NodeSnapshot::VT_NODE_DATA_TYPE, + Some(NodeData::NONE), + ) + .unwrap() + } + } + #[inline] + pub fn node_data(&self) -> flatbuffers::Table<'a> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::>>( + NodeSnapshot::VT_NODE_DATA, + None, + ) + .unwrap() + } + } + #[inline] + #[allow(non_snake_case)] + pub fn node_data_as_array(&self) -> Option> { + if self.node_data_type() == NodeData::Array { + let u = self.node_data(); + // Safety: + // Created from a valid Table for this object + // Which contains a valid union in this slot + Some(unsafe { ArrayNodeData::init_from_table(u) }) + } else { + None + } + } + + #[inline] + #[allow(non_snake_case)] + pub fn node_data_as_group(&self) -> Option> { + if self.node_data_type() == NodeData::Group { + let u = self.node_data(); + // Safety: + // Created from a valid Table for this object + // Which contains a valid union in this slot + Some(unsafe { GroupNodeData::init_from_table(u) }) + } else { + None + } + } + } + + impl flatbuffers::Verifiable for NodeSnapshot<'_> { + #[inline] + fn run_verifier( + v: &mut flatbuffers::Verifier, + pos: usize, + ) -> Result<(), flatbuffers::InvalidFlatbuffer> { + use self::flatbuffers::Verifiable; + v.visit_table(pos)? + .visit_field::("id", Self::VT_ID, true)? + .visit_field::>("path", Self::VT_PATH, true)? + .visit_field::>>("user_data", Self::VT_USER_DATA, true)? + .visit_union::("node_data_type", Self::VT_NODE_DATA_TYPE, "node_data", Self::VT_NODE_DATA, true, |key, v, pos| { + match key { + NodeData::Array => v.verify_union_variant::>("NodeData::Array", pos), + NodeData::Group => v.verify_union_variant::>("NodeData::Group", pos), + _ => Ok(()), + } + })? + .finish(); + Ok(()) + } + } + pub struct NodeSnapshotArgs<'a> { + pub id: Option<&'a ObjectId8>, + pub path: Option>, + pub user_data: Option>>, + pub node_data_type: NodeData, + pub node_data: Option>, + } + impl<'a> Default for NodeSnapshotArgs<'a> { + #[inline] + fn default() -> Self { + NodeSnapshotArgs { + id: None, // required field + path: None, // required field + user_data: None, // required field + node_data_type: NodeData::NONE, + node_data: None, // required field + } + } + } + + pub struct NodeSnapshotBuilder<'a: 'b, 'b, A: flatbuffers::Allocator + 'a> { + fbb_: &'b mut flatbuffers::FlatBufferBuilder<'a, A>, + start_: flatbuffers::WIPOffset, + } + impl<'a: 'b, 'b, A: flatbuffers::Allocator + 'a> NodeSnapshotBuilder<'a, 'b, A> { + #[inline] + pub fn add_id(&mut self, id: &ObjectId8) { + self.fbb_.push_slot_always::<&ObjectId8>(NodeSnapshot::VT_ID, id); + } + #[inline] + pub fn add_path(&mut self, path: flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::>( + NodeSnapshot::VT_PATH, + path, + ); + } + #[inline] + pub fn add_user_data( + &mut self, + user_data: flatbuffers::WIPOffset>, + ) { + self.fbb_.push_slot_always::>( + NodeSnapshot::VT_USER_DATA, + user_data, + ); + } + #[inline] + pub fn add_node_data_type(&mut self, node_data_type: NodeData) { + self.fbb_.push_slot::( + NodeSnapshot::VT_NODE_DATA_TYPE, + node_data_type, + NodeData::NONE, + ); + } + #[inline] + pub fn add_node_data( + &mut self, + node_data: flatbuffers::WIPOffset, + ) { + self.fbb_.push_slot_always::>( + NodeSnapshot::VT_NODE_DATA, + node_data, + ); + } + #[inline] + pub fn new( + _fbb: &'b mut flatbuffers::FlatBufferBuilder<'a, A>, + ) -> NodeSnapshotBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + NodeSnapshotBuilder { fbb_: _fbb, start_: start } + } + #[inline] + pub fn finish(self) -> flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + self.fbb_.required(o, NodeSnapshot::VT_ID, "id"); + self.fbb_.required(o, NodeSnapshot::VT_PATH, "path"); + self.fbb_.required(o, NodeSnapshot::VT_USER_DATA, "user_data"); + self.fbb_.required(o, NodeSnapshot::VT_NODE_DATA, "node_data"); + flatbuffers::WIPOffset::new(o.value()) + } + } + + impl core::fmt::Debug for NodeSnapshot<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let mut ds = f.debug_struct("NodeSnapshot"); + ds.field("id", &self.id()); + ds.field("path", &self.path()); + ds.field("user_data", &self.user_data()); + ds.field("node_data_type", &self.node_data_type()); + match self.node_data_type() { + NodeData::Array => { + if let Some(x) = self.node_data_as_array() { + ds.field("node_data", &x) + } else { + ds.field("node_data", &"InvalidFlatbuffer: Union discriminant does not match value.") + } + } + NodeData::Group => { + if let Some(x) = self.node_data_as_group() { + ds.field("node_data", &x) + } else { + ds.field("node_data", &"InvalidFlatbuffer: Union discriminant does not match value.") + } + } + _ => { + let x: Option<()> = None; + ds.field("node_data", &x) + } + }; + ds.finish() + } + } + pub enum SnapshotOffset {} + #[derive(Copy, Clone, PartialEq)] + + pub struct Snapshot<'a> { + pub _tab: flatbuffers::Table<'a>, + } + + impl<'a> flatbuffers::Follow<'a> for Snapshot<'a> { + type Inner = Snapshot<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { _tab: flatbuffers::Table::new(buf, loc) } + } + } + + impl<'a> Snapshot<'a> { + pub const VT_ID: flatbuffers::VOffsetT = 4; + pub const VT_PARENT_ID: flatbuffers::VOffsetT = 6; + pub const VT_NODES: flatbuffers::VOffsetT = 8; + pub const VT_FLUSHED_AT: flatbuffers::VOffsetT = 10; + pub const VT_MESSAGE: flatbuffers::VOffsetT = 12; + pub const VT_METADATA: flatbuffers::VOffsetT = 14; + pub const VT_MANIFEST_FILES: flatbuffers::VOffsetT = 16; + + #[inline] + pub unsafe fn init_from_table(table: flatbuffers::Table<'a>) -> Self { + Snapshot { _tab: table } + } + #[allow(unused_mut)] + pub fn create< + 'bldr: 'args, + 'args: 'mut_bldr, + 'mut_bldr, + A: flatbuffers::Allocator + 'bldr, + >( + _fbb: &'mut_bldr mut flatbuffers::FlatBufferBuilder<'bldr, A>, + args: &'args SnapshotArgs<'args>, + ) -> flatbuffers::WIPOffset> { + let mut builder = SnapshotBuilder::new(_fbb); + builder.add_flushed_at(args.flushed_at); + if let Some(x) = args.manifest_files { + builder.add_manifest_files(x); + } + if let Some(x) = args.metadata { + builder.add_metadata(x); + } + if let Some(x) = args.message { + builder.add_message(x); + } + if let Some(x) = args.nodes { + builder.add_nodes(x); + } + if let Some(x) = args.parent_id { + builder.add_parent_id(x); + } + if let Some(x) = args.id { + builder.add_id(x); + } + builder.finish() + } + + #[inline] + pub fn id(&self) -> &'a ObjectId12 { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::(Snapshot::VT_ID, None).unwrap() } + } + #[inline] + pub fn parent_id(&self) -> Option<&'a ObjectId12> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::(Snapshot::VT_PARENT_ID, None) } + } + #[inline] + pub fn nodes( + &self, + ) -> flatbuffers::Vector<'a, flatbuffers::ForwardsUOffset>> + { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::, + >, + >>(Snapshot::VT_NODES, None) + .unwrap() + } + } + #[inline] + pub fn flushed_at(&self) -> u64 { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::(Snapshot::VT_FLUSHED_AT, Some(0)).unwrap() } + } + #[inline] + pub fn message(&self) -> &'a str { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::>(Snapshot::VT_MESSAGE, None) + .unwrap() + } + } + #[inline] + pub fn metadata( + &self, + ) -> flatbuffers::Vector<'a, flatbuffers::ForwardsUOffset>> + { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::, + >, + >>(Snapshot::VT_METADATA, None) + .unwrap() + } + } + #[inline] + pub fn manifest_files(&self) -> flatbuffers::Vector<'a, ManifestFileInfo> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::, + >>(Snapshot::VT_MANIFEST_FILES, None) + .unwrap() + } + } + } + + impl flatbuffers::Verifiable for Snapshot<'_> { + #[inline] + fn run_verifier( + v: &mut flatbuffers::Verifier, + pos: usize, + ) -> Result<(), flatbuffers::InvalidFlatbuffer> { + use self::flatbuffers::Verifiable; + v.visit_table(pos)? + .visit_field::("id", Self::VT_ID, true)? + .visit_field::("parent_id", Self::VT_PARENT_ID, false)? + .visit_field::>>>("nodes", Self::VT_NODES, true)? + .visit_field::("flushed_at", Self::VT_FLUSHED_AT, false)? + .visit_field::>("message", Self::VT_MESSAGE, true)? + .visit_field::>>>("metadata", Self::VT_METADATA, true)? + .visit_field::>>("manifest_files", Self::VT_MANIFEST_FILES, true)? + .finish(); + Ok(()) + } + } + pub struct SnapshotArgs<'a> { + pub id: Option<&'a ObjectId12>, + pub parent_id: Option<&'a ObjectId12>, + pub nodes: Option< + flatbuffers::WIPOffset< + flatbuffers::Vector<'a, flatbuffers::ForwardsUOffset>>, + >, + >, + pub flushed_at: u64, + pub message: Option>, + pub metadata: Option< + flatbuffers::WIPOffset< + flatbuffers::Vector<'a, flatbuffers::ForwardsUOffset>>, + >, + >, + pub manifest_files: + Option>>, + } + impl<'a> Default for SnapshotArgs<'a> { + #[inline] + fn default() -> Self { + SnapshotArgs { + id: None, // required field + parent_id: None, + nodes: None, // required field + flushed_at: 0, + message: None, // required field + metadata: None, // required field + manifest_files: None, // required field + } + } + } + + pub struct SnapshotBuilder<'a: 'b, 'b, A: flatbuffers::Allocator + 'a> { + fbb_: &'b mut flatbuffers::FlatBufferBuilder<'a, A>, + start_: flatbuffers::WIPOffset, + } + impl<'a: 'b, 'b, A: flatbuffers::Allocator + 'a> SnapshotBuilder<'a, 'b, A> { + #[inline] + pub fn add_id(&mut self, id: &ObjectId12) { + self.fbb_.push_slot_always::<&ObjectId12>(Snapshot::VT_ID, id); + } + #[inline] + pub fn add_parent_id(&mut self, parent_id: &ObjectId12) { + self.fbb_.push_slot_always::<&ObjectId12>(Snapshot::VT_PARENT_ID, parent_id); + } + #[inline] + pub fn add_nodes( + &mut self, + nodes: flatbuffers::WIPOffset< + flatbuffers::Vector<'b, flatbuffers::ForwardsUOffset>>, + >, + ) { + self.fbb_ + .push_slot_always::>(Snapshot::VT_NODES, nodes); + } + #[inline] + pub fn add_flushed_at(&mut self, flushed_at: u64) { + self.fbb_.push_slot::(Snapshot::VT_FLUSHED_AT, flushed_at, 0); + } + #[inline] + pub fn add_message(&mut self, message: flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::>( + Snapshot::VT_MESSAGE, + message, + ); + } + #[inline] + pub fn add_metadata( + &mut self, + metadata: flatbuffers::WIPOffset< + flatbuffers::Vector<'b, flatbuffers::ForwardsUOffset>>, + >, + ) { + self.fbb_.push_slot_always::>( + Snapshot::VT_METADATA, + metadata, + ); + } + #[inline] + pub fn add_manifest_files( + &mut self, + manifest_files: flatbuffers::WIPOffset< + flatbuffers::Vector<'b, ManifestFileInfo>, + >, + ) { + self.fbb_.push_slot_always::>( + Snapshot::VT_MANIFEST_FILES, + manifest_files, + ); + } + #[inline] + pub fn new( + _fbb: &'b mut flatbuffers::FlatBufferBuilder<'a, A>, + ) -> SnapshotBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + SnapshotBuilder { fbb_: _fbb, start_: start } + } + #[inline] + pub fn finish(self) -> flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + self.fbb_.required(o, Snapshot::VT_ID, "id"); + self.fbb_.required(o, Snapshot::VT_NODES, "nodes"); + self.fbb_.required(o, Snapshot::VT_MESSAGE, "message"); + self.fbb_.required(o, Snapshot::VT_METADATA, "metadata"); + self.fbb_.required(o, Snapshot::VT_MANIFEST_FILES, "manifest_files"); + flatbuffers::WIPOffset::new(o.value()) + } + } + + impl core::fmt::Debug for Snapshot<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let mut ds = f.debug_struct("Snapshot"); + ds.field("id", &self.id()); + ds.field("parent_id", &self.parent_id()); + ds.field("nodes", &self.nodes()); + ds.field("flushed_at", &self.flushed_at()); + ds.field("message", &self.message()); + ds.field("metadata", &self.metadata()); + ds.field("manifest_files", &self.manifest_files()); + ds.finish() + } + } + pub enum ChunkIndicesOffset {} + #[derive(Copy, Clone, PartialEq)] + + pub struct ChunkIndices<'a> { + pub _tab: flatbuffers::Table<'a>, + } + + impl<'a> flatbuffers::Follow<'a> for ChunkIndices<'a> { + type Inner = ChunkIndices<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { _tab: flatbuffers::Table::new(buf, loc) } + } + } + + impl<'a> ChunkIndices<'a> { + pub const VT_COORDS: flatbuffers::VOffsetT = 4; + + #[inline] + pub unsafe fn init_from_table(table: flatbuffers::Table<'a>) -> Self { + ChunkIndices { _tab: table } + } + #[allow(unused_mut)] + pub fn create< + 'bldr: 'args, + 'args: 'mut_bldr, + 'mut_bldr, + A: flatbuffers::Allocator + 'bldr, + >( + _fbb: &'mut_bldr mut flatbuffers::FlatBufferBuilder<'bldr, A>, + args: &'args ChunkIndicesArgs<'args>, + ) -> flatbuffers::WIPOffset> { + let mut builder = ChunkIndicesBuilder::new(_fbb); + if let Some(x) = args.coords { + builder.add_coords(x); + } + builder.finish() + } + + #[inline] + pub fn coords(&self) -> flatbuffers::Vector<'a, u32> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::>>( + ChunkIndices::VT_COORDS, + None, + ) + .unwrap() + } + } + } + + impl flatbuffers::Verifiable for ChunkIndices<'_> { + #[inline] + fn run_verifier( + v: &mut flatbuffers::Verifier, + pos: usize, + ) -> Result<(), flatbuffers::InvalidFlatbuffer> { + use self::flatbuffers::Verifiable; + v.visit_table(pos)? + .visit_field::>>("coords", Self::VT_COORDS, true)? + .finish(); + Ok(()) + } + } + pub struct ChunkIndicesArgs<'a> { + pub coords: Option>>, + } + impl<'a> Default for ChunkIndicesArgs<'a> { + #[inline] + fn default() -> Self { + ChunkIndicesArgs { + coords: None, // required field + } + } + } + + pub struct ChunkIndicesBuilder<'a: 'b, 'b, A: flatbuffers::Allocator + 'a> { + fbb_: &'b mut flatbuffers::FlatBufferBuilder<'a, A>, + start_: flatbuffers::WIPOffset, + } + impl<'a: 'b, 'b, A: flatbuffers::Allocator + 'a> ChunkIndicesBuilder<'a, 'b, A> { + #[inline] + pub fn add_coords( + &mut self, + coords: flatbuffers::WIPOffset>, + ) { + self.fbb_.push_slot_always::>( + ChunkIndices::VT_COORDS, + coords, + ); + } + #[inline] + pub fn new( + _fbb: &'b mut flatbuffers::FlatBufferBuilder<'a, A>, + ) -> ChunkIndicesBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + ChunkIndicesBuilder { fbb_: _fbb, start_: start } + } + #[inline] + pub fn finish(self) -> flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + self.fbb_.required(o, ChunkIndices::VT_COORDS, "coords"); + flatbuffers::WIPOffset::new(o.value()) + } + } + + impl core::fmt::Debug for ChunkIndices<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let mut ds = f.debug_struct("ChunkIndices"); + ds.field("coords", &self.coords()); + ds.finish() + } + } + pub enum ArrayUpdatedChunksOffset {} + #[derive(Copy, Clone, PartialEq)] + + pub struct ArrayUpdatedChunks<'a> { + pub _tab: flatbuffers::Table<'a>, + } + + impl<'a> flatbuffers::Follow<'a> for ArrayUpdatedChunks<'a> { + type Inner = ArrayUpdatedChunks<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { _tab: flatbuffers::Table::new(buf, loc) } + } + } + + impl<'a> ArrayUpdatedChunks<'a> { + pub const VT_NODE_ID: flatbuffers::VOffsetT = 4; + pub const VT_CHUNKS: flatbuffers::VOffsetT = 6; + + #[inline] + pub unsafe fn init_from_table(table: flatbuffers::Table<'a>) -> Self { + ArrayUpdatedChunks { _tab: table } + } + #[allow(unused_mut)] + pub fn create< + 'bldr: 'args, + 'args: 'mut_bldr, + 'mut_bldr, + A: flatbuffers::Allocator + 'bldr, + >( + _fbb: &'mut_bldr mut flatbuffers::FlatBufferBuilder<'bldr, A>, + args: &'args ArrayUpdatedChunksArgs<'args>, + ) -> flatbuffers::WIPOffset> { + let mut builder = ArrayUpdatedChunksBuilder::new(_fbb); + if let Some(x) = args.chunks { + builder.add_chunks(x); + } + if let Some(x) = args.node_id { + builder.add_node_id(x); + } + builder.finish() + } + + #[inline] + pub fn node_id(&self) -> &'a ObjectId8 { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab.get::(ArrayUpdatedChunks::VT_NODE_ID, None).unwrap() + } + } + #[inline] + pub fn chunks( + &self, + ) -> flatbuffers::Vector<'a, flatbuffers::ForwardsUOffset>> + { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::, + >, + >>(ArrayUpdatedChunks::VT_CHUNKS, None) + .unwrap() + } + } + } + + impl flatbuffers::Verifiable for ArrayUpdatedChunks<'_> { + #[inline] + fn run_verifier( + v: &mut flatbuffers::Verifier, + pos: usize, + ) -> Result<(), flatbuffers::InvalidFlatbuffer> { + use self::flatbuffers::Verifiable; + v.visit_table(pos)? + .visit_field::("node_id", Self::VT_NODE_ID, true)? + .visit_field::>, + >>("chunks", Self::VT_CHUNKS, true)? + .finish(); + Ok(()) + } + } + pub struct ArrayUpdatedChunksArgs<'a> { + pub node_id: Option<&'a ObjectId8>, + pub chunks: Option< + flatbuffers::WIPOffset< + flatbuffers::Vector<'a, flatbuffers::ForwardsUOffset>>, + >, + >, + } + impl<'a> Default for ArrayUpdatedChunksArgs<'a> { + #[inline] + fn default() -> Self { + ArrayUpdatedChunksArgs { + node_id: None, // required field + chunks: None, // required field + } + } + } + + pub struct ArrayUpdatedChunksBuilder<'a: 'b, 'b, A: flatbuffers::Allocator + 'a> { + fbb_: &'b mut flatbuffers::FlatBufferBuilder<'a, A>, + start_: flatbuffers::WIPOffset, + } + impl<'a: 'b, 'b, A: flatbuffers::Allocator + 'a> ArrayUpdatedChunksBuilder<'a, 'b, A> { + #[inline] + pub fn add_node_id(&mut self, node_id: &ObjectId8) { + self.fbb_ + .push_slot_always::<&ObjectId8>(ArrayUpdatedChunks::VT_NODE_ID, node_id); + } + #[inline] + pub fn add_chunks( + &mut self, + chunks: flatbuffers::WIPOffset< + flatbuffers::Vector<'b, flatbuffers::ForwardsUOffset>>, + >, + ) { + self.fbb_.push_slot_always::>( + ArrayUpdatedChunks::VT_CHUNKS, + chunks, + ); + } + #[inline] + pub fn new( + _fbb: &'b mut flatbuffers::FlatBufferBuilder<'a, A>, + ) -> ArrayUpdatedChunksBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + ArrayUpdatedChunksBuilder { fbb_: _fbb, start_: start } + } + #[inline] + pub fn finish(self) -> flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + self.fbb_.required(o, ArrayUpdatedChunks::VT_NODE_ID, "node_id"); + self.fbb_.required(o, ArrayUpdatedChunks::VT_CHUNKS, "chunks"); + flatbuffers::WIPOffset::new(o.value()) + } + } + + impl core::fmt::Debug for ArrayUpdatedChunks<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let mut ds = f.debug_struct("ArrayUpdatedChunks"); + ds.field("node_id", &self.node_id()); + ds.field("chunks", &self.chunks()); + ds.finish() + } + } + pub enum TransactionLogOffset {} + #[derive(Copy, Clone, PartialEq)] + + pub struct TransactionLog<'a> { + pub _tab: flatbuffers::Table<'a>, + } + + impl<'a> flatbuffers::Follow<'a> for TransactionLog<'a> { + type Inner = TransactionLog<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { _tab: flatbuffers::Table::new(buf, loc) } + } + } + + impl<'a> TransactionLog<'a> { + pub const VT_ID: flatbuffers::VOffsetT = 4; + pub const VT_NEW_GROUPS: flatbuffers::VOffsetT = 6; + pub const VT_NEW_ARRAYS: flatbuffers::VOffsetT = 8; + pub const VT_DELETED_GROUPS: flatbuffers::VOffsetT = 10; + pub const VT_DELETED_ARRAYS: flatbuffers::VOffsetT = 12; + pub const VT_UPDATED_ARRAYS: flatbuffers::VOffsetT = 14; + pub const VT_UPDATED_GROUPS: flatbuffers::VOffsetT = 16; + pub const VT_UPDATED_CHUNKS: flatbuffers::VOffsetT = 18; + + #[inline] + pub unsafe fn init_from_table(table: flatbuffers::Table<'a>) -> Self { + TransactionLog { _tab: table } + } + #[allow(unused_mut)] + pub fn create< + 'bldr: 'args, + 'args: 'mut_bldr, + 'mut_bldr, + A: flatbuffers::Allocator + 'bldr, + >( + _fbb: &'mut_bldr mut flatbuffers::FlatBufferBuilder<'bldr, A>, + args: &'args TransactionLogArgs<'args>, + ) -> flatbuffers::WIPOffset> { + let mut builder = TransactionLogBuilder::new(_fbb); + if let Some(x) = args.updated_chunks { + builder.add_updated_chunks(x); + } + if let Some(x) = args.updated_groups { + builder.add_updated_groups(x); + } + if let Some(x) = args.updated_arrays { + builder.add_updated_arrays(x); + } + if let Some(x) = args.deleted_arrays { + builder.add_deleted_arrays(x); + } + if let Some(x) = args.deleted_groups { + builder.add_deleted_groups(x); + } + if let Some(x) = args.new_arrays { + builder.add_new_arrays(x); + } + if let Some(x) = args.new_groups { + builder.add_new_groups(x); + } + if let Some(x) = args.id { + builder.add_id(x); + } + builder.finish() + } + + #[inline] + pub fn id(&self) -> &'a ObjectId12 { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::(TransactionLog::VT_ID, None).unwrap() } + } + #[inline] + pub fn new_groups(&self) -> flatbuffers::Vector<'a, ObjectId8> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab.get::>>(TransactionLog::VT_NEW_GROUPS, None).unwrap() + } + } + #[inline] + pub fn new_arrays(&self) -> flatbuffers::Vector<'a, ObjectId8> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab.get::>>(TransactionLog::VT_NEW_ARRAYS, None).unwrap() + } + } + #[inline] + pub fn deleted_groups(&self) -> flatbuffers::Vector<'a, ObjectId8> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab.get::>>(TransactionLog::VT_DELETED_GROUPS, None).unwrap() + } + } + #[inline] + pub fn deleted_arrays(&self) -> flatbuffers::Vector<'a, ObjectId8> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab.get::>>(TransactionLog::VT_DELETED_ARRAYS, None).unwrap() + } + } + #[inline] + pub fn updated_arrays(&self) -> flatbuffers::Vector<'a, ObjectId8> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab.get::>>(TransactionLog::VT_UPDATED_ARRAYS, None).unwrap() + } + } + #[inline] + pub fn updated_groups(&self) -> flatbuffers::Vector<'a, ObjectId8> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab.get::>>(TransactionLog::VT_UPDATED_GROUPS, None).unwrap() + } + } + #[inline] + pub fn updated_chunks( + &self, + ) -> flatbuffers::Vector<'a, flatbuffers::ForwardsUOffset>> + { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::, + >, + >>(TransactionLog::VT_UPDATED_CHUNKS, None) + .unwrap() + } + } + } + + impl flatbuffers::Verifiable for TransactionLog<'_> { + #[inline] + fn run_verifier( + v: &mut flatbuffers::Verifier, + pos: usize, + ) -> Result<(), flatbuffers::InvalidFlatbuffer> { + use self::flatbuffers::Verifiable; + v.visit_table(pos)? + .visit_field::("id", Self::VT_ID, true)? + .visit_field::>>("new_groups", Self::VT_NEW_GROUPS, true)? + .visit_field::>>("new_arrays", Self::VT_NEW_ARRAYS, true)? + .visit_field::>>("deleted_groups", Self::VT_DELETED_GROUPS, true)? + .visit_field::>>("deleted_arrays", Self::VT_DELETED_ARRAYS, true)? + .visit_field::>>("updated_arrays", Self::VT_UPDATED_ARRAYS, true)? + .visit_field::>>("updated_groups", Self::VT_UPDATED_GROUPS, true)? + .visit_field::>>>("updated_chunks", Self::VT_UPDATED_CHUNKS, true)? + .finish(); + Ok(()) + } + } + pub struct TransactionLogArgs<'a> { + pub id: Option<&'a ObjectId12>, + pub new_groups: + Option>>, + pub new_arrays: + Option>>, + pub deleted_groups: + Option>>, + pub deleted_arrays: + Option>>, + pub updated_arrays: + Option>>, + pub updated_groups: + Option>>, + pub updated_chunks: Option< + flatbuffers::WIPOffset< + flatbuffers::Vector< + 'a, + flatbuffers::ForwardsUOffset>, + >, + >, + >, + } + impl<'a> Default for TransactionLogArgs<'a> { + #[inline] + fn default() -> Self { + TransactionLogArgs { + id: None, // required field + new_groups: None, // required field + new_arrays: None, // required field + deleted_groups: None, // required field + deleted_arrays: None, // required field + updated_arrays: None, // required field + updated_groups: None, // required field + updated_chunks: None, // required field + } + } + } + + pub struct TransactionLogBuilder<'a: 'b, 'b, A: flatbuffers::Allocator + 'a> { + fbb_: &'b mut flatbuffers::FlatBufferBuilder<'a, A>, + start_: flatbuffers::WIPOffset, + } + impl<'a: 'b, 'b, A: flatbuffers::Allocator + 'a> TransactionLogBuilder<'a, 'b, A> { + #[inline] + pub fn add_id(&mut self, id: &ObjectId12) { + self.fbb_.push_slot_always::<&ObjectId12>(TransactionLog::VT_ID, id); + } + #[inline] + pub fn add_new_groups( + &mut self, + new_groups: flatbuffers::WIPOffset>, + ) { + self.fbb_.push_slot_always::>( + TransactionLog::VT_NEW_GROUPS, + new_groups, + ); + } + #[inline] + pub fn add_new_arrays( + &mut self, + new_arrays: flatbuffers::WIPOffset>, + ) { + self.fbb_.push_slot_always::>( + TransactionLog::VT_NEW_ARRAYS, + new_arrays, + ); + } + #[inline] + pub fn add_deleted_groups( + &mut self, + deleted_groups: flatbuffers::WIPOffset>, + ) { + self.fbb_.push_slot_always::>( + TransactionLog::VT_DELETED_GROUPS, + deleted_groups, + ); + } + #[inline] + pub fn add_deleted_arrays( + &mut self, + deleted_arrays: flatbuffers::WIPOffset>, + ) { + self.fbb_.push_slot_always::>( + TransactionLog::VT_DELETED_ARRAYS, + deleted_arrays, + ); + } + #[inline] + pub fn add_updated_arrays( + &mut self, + updated_arrays: flatbuffers::WIPOffset>, + ) { + self.fbb_.push_slot_always::>( + TransactionLog::VT_UPDATED_ARRAYS, + updated_arrays, + ); + } + #[inline] + pub fn add_updated_groups( + &mut self, + updated_groups: flatbuffers::WIPOffset>, + ) { + self.fbb_.push_slot_always::>( + TransactionLog::VT_UPDATED_GROUPS, + updated_groups, + ); + } + #[inline] + pub fn add_updated_chunks( + &mut self, + updated_chunks: flatbuffers::WIPOffset< + flatbuffers::Vector< + 'b, + flatbuffers::ForwardsUOffset>, + >, + >, + ) { + self.fbb_.push_slot_always::>( + TransactionLog::VT_UPDATED_CHUNKS, + updated_chunks, + ); + } + #[inline] + pub fn new( + _fbb: &'b mut flatbuffers::FlatBufferBuilder<'a, A>, + ) -> TransactionLogBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + TransactionLogBuilder { fbb_: _fbb, start_: start } + } + #[inline] + pub fn finish(self) -> flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + self.fbb_.required(o, TransactionLog::VT_ID, "id"); + self.fbb_.required(o, TransactionLog::VT_NEW_GROUPS, "new_groups"); + self.fbb_.required(o, TransactionLog::VT_NEW_ARRAYS, "new_arrays"); + self.fbb_.required(o, TransactionLog::VT_DELETED_GROUPS, "deleted_groups"); + self.fbb_.required(o, TransactionLog::VT_DELETED_ARRAYS, "deleted_arrays"); + self.fbb_.required(o, TransactionLog::VT_UPDATED_ARRAYS, "updated_arrays"); + self.fbb_.required(o, TransactionLog::VT_UPDATED_GROUPS, "updated_groups"); + self.fbb_.required(o, TransactionLog::VT_UPDATED_CHUNKS, "updated_chunks"); + flatbuffers::WIPOffset::new(o.value()) + } + } + + impl core::fmt::Debug for TransactionLog<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let mut ds = f.debug_struct("TransactionLog"); + ds.field("id", &self.id()); + ds.field("new_groups", &self.new_groups()); + ds.field("new_arrays", &self.new_arrays()); + ds.field("deleted_groups", &self.deleted_groups()); + ds.field("deleted_arrays", &self.deleted_arrays()); + ds.field("updated_arrays", &self.updated_arrays()); + ds.field("updated_groups", &self.updated_groups()); + ds.field("updated_chunks", &self.updated_chunks()); + ds.finish() + } + } +} // pub mod gen diff --git a/icechunk/src/format/manifest.rs b/icechunk/src/format/manifest.rs index fca97f55..bcf665f1 100644 --- a/icechunk/src/format/manifest.rs +++ b/icechunk/src/format/manifest.rs @@ -1,24 +1,25 @@ -use ::futures::{pin_mut, Stream, TryStreamExt}; -use itertools::Itertools; -use std::{ - collections::BTreeMap, - convert::Infallible, - ops::{Bound, Range}, - sync::Arc, -}; -use thiserror::Error; +use std::{borrow::Cow, convert::Infallible, ops::Range, sync::Arc}; +use crate::format::flatbuffers::gen; use bytes::Bytes; +use flatbuffers::VerifierOptions; +use futures::{Stream, TryStreamExt}; +use itertools::Itertools; use serde::{Deserialize, Serialize}; +use thiserror::Error; -use crate::storage::ETag; +use crate::{ + error::ICError, + format::{IcechunkFormatError, IcechunkFormatErrorKind}, + storage::ETag, +}; use super::{ - ChunkId, ChunkIndices, ChunkLength, ChunkOffset, IcechunkFormatError, IcechunkResult, - ManifestId, NodeId, + ChunkId, ChunkIndices, ChunkLength, ChunkOffset, IcechunkResult, ManifestId, NodeId, }; -type ManifestExtents = Range; +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ManifestExtents(Vec>); #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ManifestRef { @@ -26,12 +27,27 @@ pub struct ManifestRef { pub extents: ManifestExtents, } +impl ManifestExtents { + pub fn new(from: &[u32], to: &[u32]) -> Self { + let v = from + .iter() + .zip(to.iter()) + .map(|(a, b)| Range { start: *a, end: *b }) + .collect(); + Self(v) + } + + pub fn iter(&self) -> impl Iterator> { + self.0.iter() + } +} + #[derive(Debug, Error)] #[non_exhaustive] -pub enum VirtualReferenceError { +pub enum VirtualReferenceErrorKind { #[error("no virtual chunk container can handle the chunk location ({0})")] NoContainerForUrl(String), - #[error("error parsing virtual ref URL {0}")] + #[error("error parsing virtual ref URL")] CannotParseUrl(#[from] url::ParseError), #[error("invalid credentials for virtual reference of type {0}")] InvalidCredentials(String), @@ -41,16 +57,29 @@ pub enum VirtualReferenceError { UnsupportedScheme(String), #[error("error parsing bucket name from virtual ref URL {0}")] CannotParseBucketName(String), - #[error("error fetching virtual reference {0}")] - FetchError(Box), + #[error("error fetching virtual reference")] + FetchError(#[source] Box), #[error("the checksum of the object owning the virtual chunk has changed ({0})")] ObjectModified(String), #[error("error retrieving virtual chunk, not enough data. Expected: ({expected}), available ({available})")] InvalidObjectSize { expected: u64, available: u64 }, - #[error("error parsing virtual reference {0}")] + #[error("error parsing virtual reference")] OtherError(#[from] Box), } +pub type VirtualReferenceError = ICError; + +// it would be great to define this impl in error.rs, but it conflicts with the blanket +// `impl From for T` +impl From for VirtualReferenceError +where + E: Into, +{ + fn from(value: E) -> Self { + Self::new(value.into()) + } +} + #[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub struct VirtualChunkLocation(pub String); @@ -64,7 +93,7 @@ impl VirtualChunkLocation { let scheme = url.scheme(); let new_path: String = url .path_segments() - .ok_or(VirtualReferenceError::NoPathSegments(path.into()))? + .ok_or(VirtualReferenceErrorKind::NoPathSegments(path.into()))? // strip empty segments here, object_store cannot handle them. .filter(|x| !x.is_empty()) .join("/"); @@ -74,7 +103,9 @@ impl VirtualChunkLocation { } else if scheme == "file" { "".to_string() } else { - return Err(VirtualReferenceError::CannotParseBucketName(path.into())); + return Err( + VirtualReferenceErrorKind::CannotParseBucketName(path.into()).into() + ); }; let location = format!("{}://{}/{}", scheme, host, new_path,); @@ -122,65 +153,75 @@ pub struct ChunkInfo { pub payload: ChunkPayload, } -#[derive(Debug, PartialEq)] +#[derive(Debug)] pub struct Manifest { - pub id: ManifestId, - pub(crate) chunks: BTreeMap>, -} - -impl Default for Manifest { - fn default() -> Self { - Self { id: ManifestId::random(), chunks: Default::default() } - } + buffer: Vec, } impl Manifest { - pub fn get_chunk_payload( - &self, - node: &NodeId, - coord: &ChunkIndices, - ) -> IcechunkResult<&ChunkPayload> { - self.chunks.get(node).and_then(|m| m.get(coord)).ok_or_else(|| { - IcechunkFormatError::ChunkCoordinatesNotFound { coords: coord.clone() } - }) + pub fn id(&self) -> ManifestId { + ManifestId::new(self.root().id().0) } - pub fn iter( - self: Arc, - node: NodeId, - ) -> impl Iterator { - PayloadIterator { manifest: self, for_node: node, last_key: None } + pub fn bytes(&self) -> &[u8] { + self.buffer.as_slice() } - pub fn new(chunks: BTreeMap>) -> Self { - Self { chunks, id: ManifestId::random() } + pub fn from_buffer(buffer: Vec) -> Result { + let _ = flatbuffers::root_with_opts::( + &ROOT_OPTIONS, + buffer.as_slice(), + )?; + Ok(Manifest { buffer }) } pub async fn from_stream( stream: impl Stream>, ) -> Result, E> { - pin_mut!(stream); - let mut chunk_map: BTreeMap> = - BTreeMap::new(); - while let Some(chunk) = stream.try_next().await? { - // This could be done with BTreeMap.entry instead, but would require cloning both keys - match chunk_map.get_mut(&chunk.node) { - Some(m) => { - m.insert(chunk.coord, chunk.payload); - } - None => { - chunk_map.insert( - chunk.node, - BTreeMap::from([(chunk.coord, chunk.payload)]), - ); - } - }; + // TODO: what's a good capacity? + let mut builder = flatbuffers::FlatBufferBuilder::with_capacity(1024 * 1024); + let mut all = stream.try_collect::>().await?; + // FIXME: should we sort here or can we sort outside? + all.sort_by(|a, b| (&a.node, &a.coord).cmp(&(&b.node, &b.coord))); + + let mut all = all.iter().peekable(); + + let mut array_manifests = Vec::with_capacity(1); + while let Some(current_node) = all.peek().map(|chunk| &chunk.node).cloned() { + // TODO: what is a good capacity + let mut refs = Vec::with_capacity(8_192); + while let Some(chunk) = all.next_if(|chunk| chunk.node == current_node) { + refs.push(mk_chunk_ref(&mut builder, chunk)); + } + + let node_id = Some(gen::ObjectId8::new(¤t_node.0)); + let refs = Some(builder.create_vector(refs.as_slice())); + let array_manifest = gen::ArrayManifest::create( + &mut builder, + &gen::ArrayManifestArgs { node_id: node_id.as_ref(), refs }, + ); + array_manifests.push(array_manifest); } - if chunk_map.is_empty() { - Ok(None) - } else { - Ok(Some(Self::new(chunk_map))) + + if array_manifests.is_empty() { + // empty manifet + return Ok(None); } + + let arrays = builder.create_vector(array_manifests.as_slice()); + let manifest_id = ManifestId::random(); + let bin_manifest_id = gen::ObjectId12::new(&manifest_id.0); + + let manifest = gen::Manifest::create( + &mut builder, + &gen::ManifestArgs { id: Some(&bin_manifest_id), arrays: Some(arrays) }, + ); + + builder.finish(manifest, Some("Ichk")); + let (mut buffer, offset) = builder.collapse(); + buffer.drain(0..offset); + buffer.shrink_to_fit(); + Ok(Some(Manifest { buffer })) } /// Used for tests @@ -190,105 +231,206 @@ impl Manifest { Self::from_stream(futures::stream::iter(iter.into_iter().map(Ok))).await } - pub fn chunk_payloads(&self) -> impl Iterator { - self.chunks.values().flat_map(|m| m.values()) - } - pub fn len(&self) -> usize { - self.chunks.values().map(|m| m.len()).sum() + self.root().arrays().iter().map(|am| am.refs().len()).sum() } #[must_use] pub fn is_empty(&self) -> bool { self.len() == 0 } + + fn root(&self) -> gen::Manifest { + // without the unsafe version this is too slow + // if we try to keep the root in the Manifest struct, we would need a lifetime + unsafe { flatbuffers::root_unchecked::(&self.buffer) } + } + + pub fn get_chunk_payload( + &self, + node: &NodeId, + coord: &ChunkIndices, + ) -> IcechunkResult { + let manifest = self.root(); + let chunk_ref = lookup_node(manifest, node) + .and_then(|array_manifest| lookup_ref(array_manifest, coord)) + .ok_or_else(|| { + IcechunkFormatError::from( + IcechunkFormatErrorKind::ChunkCoordinatesNotFound { + coords: coord.clone(), + }, + ) + })?; + ref_to_payload(chunk_ref) + } + + pub fn iter( + self: Arc, + node: NodeId, + ) -> impl Iterator> + { + PayloadIterator::new(self, node) + } + + pub fn chunk_payloads( + &self, + ) -> impl Iterator> + '_ { + self.root().arrays().iter().flat_map(move |array_manifest| { + array_manifest.refs().iter().map(|r| ref_to_payload(r)) + }) + } +} + +fn lookup_node<'a>( + manifest: gen::Manifest<'a>, + node: &NodeId, +) -> Option> { + manifest.arrays().lookup_by_key(node.0, |am, id| am.node_id().0.cmp(id)) +} + +fn lookup_ref<'a>( + array_manifest: gen::ArrayManifest<'a>, + coord: &ChunkIndices, +) -> Option> { + let res = + array_manifest.refs().lookup_by_key(coord.0.as_slice(), |chunk_ref, coords| { + chunk_ref.index().iter().cmp(coords.iter().copied()) + }); + res } struct PayloadIterator { manifest: Arc, - for_node: NodeId, - last_key: Option, + node_id: NodeId, + last_ref_index: usize, +} + +impl PayloadIterator { + fn new(manifest: Arc, node_id: NodeId) -> Self { + Self { manifest, node_id, last_ref_index: 0 } + } } impl Iterator for PayloadIterator { - type Item = (ChunkIndices, ChunkPayload); + type Item = Result<(ChunkIndices, ChunkPayload), IcechunkFormatError>; fn next(&mut self) -> Option { - if let Some(map) = self.manifest.chunks.get(&self.for_node) { - match &self.last_key { - None => { - if let Some((coord, payload)) = map.iter().next() { - self.last_key = Some(coord.clone()); - Some((coord.clone(), payload.clone())) - } else { - None - } - } - Some(last_key) => { - if let Some((coord, payload)) = - map.range((Bound::Excluded(last_key), Bound::Unbounded)).next() - { - self.last_key = Some(coord.clone()); - Some((coord.clone(), payload.clone())) - } else { - None - } - } + let manifest = self.manifest.root(); + lookup_node(manifest, &self.node_id).and_then(|array_manifest| { + let refs = array_manifest.refs(); + if self.last_ref_index >= refs.len() { + return None; } - } else { - None - } + + let chunk_ref = refs.get(self.last_ref_index); + self.last_ref_index += 1; + Some( + ref_to_payload(chunk_ref) + .map(|payl| (ChunkIndices(chunk_ref.index().iter().collect()), payl)), + ) + }) } } -#[cfg(test)] -#[allow(clippy::expect_used, clippy::unwrap_used)] -mod tests { - - use std::error::Error; - - use crate::{format::manifest::ChunkInfo, format::ObjectId}; - - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn test_virtual_chunk_location_bad() { - // errors relative chunk location - assert!(matches!( - VirtualChunkLocation::from_absolute_path("abcdef"), - Err(VirtualReferenceError::CannotParseUrl(_)), - )); - // extra / prevents bucket name detection - assert!(matches!( - VirtualChunkLocation::from_absolute_path("s3:///foo/path"), - Err(VirtualReferenceError::CannotParseBucketName(_)), - )); +fn ref_to_payload( + chunk_ref: gen::ChunkRef<'_>, +) -> Result { + if let Some(chunk_id) = chunk_ref.chunk_id() { + let id = ChunkId::new(chunk_id.0); + Ok(ChunkPayload::Ref(ChunkRef { + id, + offset: chunk_ref.offset(), + length: chunk_ref.length(), + })) + } else if let Some(location) = chunk_ref.location() { + let location = VirtualChunkLocation::from_absolute_path(location)?; + Ok(ChunkPayload::Virtual(VirtualChunkRef { + location, + checksum: checksum(&chunk_ref), + offset: chunk_ref.offset(), + length: chunk_ref.length(), + })) + } else if let Some(data) = chunk_ref.inline() { + Ok(ChunkPayload::Inline(Bytes::copy_from_slice(data.bytes()))) + } else { + Err(IcechunkFormatErrorKind::InvalidFlatBuffer( + flatbuffers::InvalidFlatbuffer::InconsistentUnion { + field: Cow::Borrowed("chunk_id+location+inline"), + field_type: Cow::Borrowed("invalid"), + error_trace: Default::default(), + }, + ) + .into()) } +} - #[tokio::test] - async fn test_manifest_chunk_iterator_yields_requested_nodes_only( - ) -> Result<(), Box> { - // This is a regression test for a bug found by hypothesis. - // Because we use a `.range` query on the HashMap, we have to be careful - // to not yield chunks from a node that was not requested. - let mut array_ids = [NodeId::random(), NodeId::random()]; - array_ids.sort(); - - // insert with a chunk in the manifest for the array with the larger NodeId - let chunk1 = ChunkInfo { - node: array_ids[1].clone(), - coord: ChunkIndices(vec![0, 0, 0]), - payload: ChunkPayload::Ref(ChunkRef { - id: ObjectId::random(), - offset: 0, - length: 4, - }), - }; - let manifest = Manifest::from_iter(vec![chunk1]).await?.unwrap(); - let chunks = Arc::new(manifest).iter(array_ids[0].clone()).collect::>(); - assert_eq!(chunks, vec![]); +fn checksum(payload: &gen::ChunkRef<'_>) -> Option { + if let Some(etag) = payload.checksum_etag() { + Some(Checksum::ETag(ETag(etag.to_string()))) + } else if payload.checksum_last_modified() > 0 { + Some(Checksum::LastModified(SecondsSinceEpoch(payload.checksum_last_modified()))) + } else { + None + } +} - Ok(()) +fn mk_chunk_ref<'bldr>( + builder: &mut flatbuffers::FlatBufferBuilder<'bldr>, + chunk: &ChunkInfo, +) -> flatbuffers::WIPOffset> { + let index = Some(builder.create_vector(chunk.coord.0.as_slice())); + match &chunk.payload { + ChunkPayload::Inline(bytes) => { + let bytes = builder.create_vector(bytes.as_ref()); + let args = + gen::ChunkRefArgs { inline: Some(bytes), index, ..Default::default() }; + gen::ChunkRef::create(builder, &args) + } + ChunkPayload::Virtual(virtual_chunk_ref) => { + let args = gen::ChunkRefArgs { + index, + location: Some( + builder.create_string(virtual_chunk_ref.location.0.as_str()), + ), + offset: virtual_chunk_ref.offset, + length: virtual_chunk_ref.length, + checksum_etag: match &virtual_chunk_ref.checksum { + Some(cs) => match cs { + Checksum::LastModified(_) => None, + Checksum::ETag(etag) => { + Some(builder.create_string(etag.0.as_str())) + } + }, + None => None, + }, + checksum_last_modified: match &virtual_chunk_ref.checksum { + Some(cs) => match cs { + Checksum::LastModified(seconds) => seconds.0, + Checksum::ETag(_) => 0, + }, + None => 0, + }, + ..Default::default() + }; + gen::ChunkRef::create(builder, &args) + } + ChunkPayload::Ref(chunk_ref) => { + let id = gen::ObjectId12::new(&chunk_ref.id.0); + let args = gen::ChunkRefArgs { + index, + offset: chunk_ref.offset, + length: chunk_ref.length, + chunk_id: Some(&id), + ..Default::default() + }; + gen::ChunkRef::create(builder, &args) + } } } + +static ROOT_OPTIONS: VerifierOptions = VerifierOptions { + max_depth: 64, + max_tables: 50_000_000, + max_apparent_size: 1 << 31, // taken from the default + ignore_missing_null_terminator: true, +}; diff --git a/icechunk/src/format/mod.rs b/icechunk/src/format/mod.rs index bda5e563..dcb2c877 100644 --- a/icechunk/src/format/mod.rs +++ b/icechunk/src/format/mod.rs @@ -7,19 +7,36 @@ use std::{ ops::Range, }; +use ::flatbuffers::InvalidFlatbuffer; use bytes::Bytes; +use flatbuffers::gen; use format_constants::FileTypeBin; use itertools::Itertools; +use manifest::{VirtualReferenceError, VirtualReferenceErrorKind}; use rand::{rng, Rng}; use serde::{Deserialize, Serialize}; use serde_with::{serde_as, TryFromInto}; use thiserror::Error; use typed_path::Utf8UnixPathBuf; -use crate::{metadata::DataType, private}; +use crate::{error::ICError, private}; pub mod attributes; pub mod manifest; + +#[allow( + dead_code, + unused_imports, + clippy::unwrap_used, + clippy::expect_used, + clippy::needless_lifetimes, + clippy::extra_unused_lifetimes, + clippy::missing_safety_doc, + clippy::derivable_impls +)] +#[path = "./flatbuffers/all_generated.rs"] +pub mod flatbuffers; + pub mod serializers; pub mod snapshot; pub mod transaction_log; @@ -150,6 +167,12 @@ pub struct ChunkIndices(pub Vec); pub type ChunkOffset = u64; pub type ChunkLength = u64; +impl<'a> From> for ChunkIndices { + fn from(value: gen::ChunkIndices<'a>) -> Self { + ChunkIndices(value.coords().iter().collect()) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum ByteRange { /// The fixed length range represented by the given `Range` @@ -213,13 +236,11 @@ impl From<(Option, Option)> for ByteRange { pub type TableOffset = u32; -#[derive(Debug, Clone, Error, PartialEq, Eq)] +#[derive(Debug, Error)] #[non_exhaustive] -pub enum IcechunkFormatError { - #[error("error decoding fill_value from array")] - FillValueDecodeError { found_size: usize, target_size: usize, target_type: DataType }, - #[error("error decoding fill_value from json")] - FillValueParse { data_type: DataType, value: serde_json::Value }, +pub enum IcechunkFormatErrorKind { + #[error(transparent)] + VirtualReferenceError(VirtualReferenceErrorKind), #[error("node not found at `{path:?}`")] NodeNotFound { path: Path }, #[error("chunk coordinates not found `{coords:?}`")] @@ -234,6 +255,46 @@ pub enum IcechunkFormatError { InvalidFileType { expected: FileTypeBin, got: u8 }, // TODO: add more info #[error("Icechunk cannot read file, invalid compression algorithm")] InvalidCompressionAlgorithm, // TODO: add more info + #[error("Invalid Icechunk metadata file")] + InvalidFlatBuffer(#[from] InvalidFlatbuffer), + #[error("error during metadata file deserialization")] + DeserializationError(#[from] rmp_serde::decode::Error), + #[error("error during metadata file serialization")] + SerializationError(#[from] rmp_serde::encode::Error), + #[error("I/O error")] + IO(#[from] std::io::Error), + #[error("path error")] + Path(#[from] PathError), + #[error("invalid timestamp in file")] + InvalidTimestamp, +} + +pub type IcechunkFormatError = ICError; + +// it would be great to define this impl in error.rs, but it conflicts with the blanket +// `impl From for T` +impl From for IcechunkFormatError +where + E: Into, +{ + fn from(value: E) -> Self { + Self::new(value.into()) + } +} + +impl From for IcechunkFormatError { + fn from(value: VirtualReferenceError) -> Self { + Self::with_context( + IcechunkFormatErrorKind::VirtualReferenceError(value.kind), + value.context, + ) + } +} + +impl From for IcechunkFormatErrorKind { + fn from(value: Infallible) -> Self { + match value {} + } } pub type IcechunkResult = Result; diff --git a/icechunk/src/format/serializers/current.rs b/icechunk/src/format/serializers/current.rs deleted file mode 100644 index 77fd2def..00000000 --- a/icechunk/src/format/serializers/current.rs +++ /dev/null @@ -1,141 +0,0 @@ -use std::collections::{BTreeMap, HashMap, HashSet}; - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; - -use crate::format::{ - manifest::{ChunkPayload, Manifest}, - snapshot::{ - AttributeFileInfo, ManifestFileInfo, NodeSnapshot, Snapshot, SnapshotProperties, - }, - transaction_log::TransactionLog, - ChunkIndices, ManifestId, NodeId, Path, SnapshotId, -}; - -#[derive(Debug, Deserialize)] -pub struct SnapshotDeserializer { - id: SnapshotId, - parent_id: Option, - flushed_at: DateTime, - message: String, - metadata: SnapshotProperties, - manifest_files: Vec, - attribute_files: Vec, - nodes: BTreeMap, -} - -#[derive(Debug, Serialize)] -pub struct SnapshotSerializer<'a> { - id: &'a SnapshotId, - parent_id: &'a Option, - flushed_at: &'a DateTime, - message: &'a String, - metadata: &'a SnapshotProperties, - manifest_files: Vec, - attribute_files: &'a Vec, - nodes: &'a BTreeMap, -} - -impl From for Snapshot { - fn from(value: SnapshotDeserializer) -> Self { - Self::from_fields( - value.id, - value.parent_id, - value.flushed_at, - value.message, - value.metadata, - value.manifest_files.into_iter().map(|fi| (fi.id.clone(), fi)).collect(), - value.attribute_files, - value.nodes, - ) - } -} - -impl<'a> From<&'a Snapshot> for SnapshotSerializer<'a> { - fn from(value: &'a Snapshot) -> Self { - Self { - id: value.id(), - parent_id: value.parent_id(), - flushed_at: value.flushed_at(), - message: value.message(), - metadata: value.metadata(), - manifest_files: value.manifest_files().values().cloned().collect(), - attribute_files: value.attribute_files(), - nodes: value.nodes(), - } - } -} - -#[derive(Debug, Deserialize)] -pub struct ManifestDeserializer { - id: ManifestId, - chunks: BTreeMap>, -} - -#[derive(Debug, Serialize)] -pub struct ManifestSerializer<'a> { - id: &'a ManifestId, - chunks: &'a BTreeMap>, -} - -impl From for Manifest { - fn from(value: ManifestDeserializer) -> Self { - Self { id: value.id, chunks: value.chunks } - } -} - -impl<'a> From<&'a Manifest> for ManifestSerializer<'a> { - fn from(value: &'a Manifest) -> Self { - Self { id: &value.id, chunks: &value.chunks } - } -} - -#[derive(Debug, Deserialize)] -pub struct TransactionLogDeserializer { - new_groups: HashSet, - new_arrays: HashSet, - deleted_groups: HashSet, - deleted_arrays: HashSet, - updated_user_attributes: HashSet, - updated_zarr_metadata: HashSet, - updated_chunks: HashMap>, -} - -#[derive(Debug, Serialize)] -pub struct TransactionLogSerializer<'a> { - new_groups: &'a HashSet, - new_arrays: &'a HashSet, - deleted_groups: &'a HashSet, - deleted_arrays: &'a HashSet, - updated_user_attributes: &'a HashSet, - updated_zarr_metadata: &'a HashSet, - updated_chunks: &'a HashMap>, -} - -impl From for TransactionLog { - fn from(value: TransactionLogDeserializer) -> Self { - Self { - new_groups: value.new_groups, - new_arrays: value.new_arrays, - deleted_groups: value.deleted_groups, - deleted_arrays: value.deleted_arrays, - updated_user_attributes: value.updated_user_attributes, - updated_zarr_metadata: value.updated_zarr_metadata, - updated_chunks: value.updated_chunks, - } - } -} - -impl<'a> From<&'a TransactionLog> for TransactionLogSerializer<'a> { - fn from(value: &'a TransactionLog) -> Self { - Self { - new_groups: &value.new_groups, - new_arrays: &value.new_arrays, - deleted_groups: &value.deleted_groups, - deleted_arrays: &value.deleted_arrays, - updated_user_attributes: &value.updated_user_attributes, - updated_zarr_metadata: &value.updated_zarr_metadata, - updated_chunks: &value.updated_chunks, - } - } -} diff --git a/icechunk/src/format/serializers/mod.rs b/icechunk/src/format/serializers/mod.rs index 1d94ec07..81801b16 100644 --- a/icechunk/src/format/serializers/mod.rs +++ b/icechunk/src/format/serializers/mod.rs @@ -42,28 +42,18 @@ //! spec version number and use the right (de)-serializer to do the job. use std::io::{Read, Write}; -use current::{ - ManifestDeserializer, ManifestSerializer, SnapshotDeserializer, SnapshotSerializer, - TransactionLogDeserializer, TransactionLogSerializer, -}; - use super::{ format_constants::SpecVersionBin, manifest::Manifest, snapshot::Snapshot, - transaction_log::TransactionLog, + transaction_log::TransactionLog, IcechunkFormatError, }; -pub mod current; - pub fn serialize_snapshot( snapshot: &Snapshot, version: SpecVersionBin, write: &mut impl Write, -) -> Result<(), rmp_serde::encode::Error> { +) -> Result<(), std::io::Error> { match version { - SpecVersionBin::V0dot1 => { - let serializer = SnapshotSerializer::from(snapshot); - rmp_serde::encode::write(write, &serializer) - } + SpecVersionBin::V0dot1 => write.write_all(snapshot.bytes()), } } @@ -71,12 +61,9 @@ pub fn serialize_manifest( manifest: &Manifest, version: SpecVersionBin, write: &mut impl Write, -) -> Result<(), rmp_serde::encode::Error> { +) -> Result<(), std::io::Error> { match version { - SpecVersionBin::V0dot1 => { - let serializer = ManifestSerializer::from(manifest); - rmp_serde::encode::write(write, &serializer) - } + SpecVersionBin::V0dot1 => write.write_all(manifest.bytes()), } } @@ -84,47 +71,53 @@ pub fn serialize_transaction_log( transaction_log: &TransactionLog, version: SpecVersionBin, write: &mut impl Write, -) -> Result<(), rmp_serde::encode::Error> { +) -> Result<(), std::io::Error> { match version { - SpecVersionBin::V0dot1 => { - let serializer = TransactionLogSerializer::from(transaction_log); - rmp_serde::encode::write(write, &serializer) - } + SpecVersionBin::V0dot1 => write.write_all(transaction_log.bytes()), } } pub fn deserialize_snapshot( version: SpecVersionBin, - read: Box, -) -> Result { + mut read: Box, +) -> Result { match version { SpecVersionBin::V0dot1 => { - let deserializer: SnapshotDeserializer = rmp_serde::from_read(read)?; - Ok(deserializer.into()) + // TODO: what's a good capacity? + let mut buffer = Vec::with_capacity(8_192); + read.read_to_end(&mut buffer)?; + buffer.shrink_to_fit(); + Snapshot::from_buffer(buffer) } } } pub fn deserialize_manifest( version: SpecVersionBin, - read: Box, -) -> Result { + mut read: Box, +) -> Result { match version { SpecVersionBin::V0dot1 => { - let deserializer: ManifestDeserializer = rmp_serde::from_read(read)?; - Ok(deserializer.into()) + // TODO: what's a good capacity? + let mut buffer = Vec::with_capacity(1024 * 1024); + read.read_to_end(&mut buffer)?; + buffer.shrink_to_fit(); + Manifest::from_buffer(buffer) } } } pub fn deserialize_transaction_log( version: SpecVersionBin, - read: Box, -) -> Result { + mut read: Box, +) -> Result { match version { SpecVersionBin::V0dot1 => { - let deserializer: TransactionLogDeserializer = rmp_serde::from_read(read)?; - Ok(deserializer.into()) + // TODO: what's a good capacity? + let mut buffer = Vec::with_capacity(1024 * 1024); + read.read_to_end(&mut buffer)?; + buffer.shrink_to_fit(); + TransactionLog::from_buffer(buffer) } } } diff --git a/icechunk/src/format/snapshot.rs b/icechunk/src/format/snapshot.rs index 0303bf76..c6ebcbfb 100644 --- a/icechunk/src/format/snapshot.rs +++ b/icechunk/src/format/snapshot.rs @@ -1,74 +1,51 @@ -use std::{ - collections::{BTreeMap, HashMap}, - ops::Bound, - sync::Arc, -}; +use std::{collections::BTreeMap, convert::Infallible, num::NonZeroU64, sync::Arc}; +use bytes::Bytes; use chrono::{DateTime, Utc}; +use err_into::ErrorInto; +use flatbuffers::{FlatBufferBuilder, VerifierOptions}; +use itertools::Itertools as _; use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::metadata::{ - ArrayShape, ChunkKeyEncoding, ChunkShape, Codec, DataType, DimensionNames, FillValue, - StorageTransformer, UserAttributes, -}; - use super::{ - manifest::{Manifest, ManifestRef}, - AttributesId, ChunkIndices, IcechunkFormatError, IcechunkResult, ManifestId, NodeId, - Path, SnapshotId, TableOffset, + flatbuffers::gen, + manifest::{Manifest, ManifestExtents, ManifestRef}, + AttributesId, ChunkIndices, IcechunkFormatError, IcechunkFormatErrorKind, + IcechunkResult, ManifestId, NodeId, Path, SnapshotId, }; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct UserAttributesRef { - pub object_id: AttributesId, - pub location: TableOffset, +pub struct DimensionShape { + dim_length: u64, + chunk_length: u64, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum UserAttributesSnapshot { - Inline(UserAttributes), - Ref(UserAttributesRef), -} - -#[derive(Clone, Debug, Hash, PartialEq, Eq)] -pub enum NodeType { - Group, - Array, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ZarrArrayMetadata { - pub shape: ArrayShape, - pub data_type: DataType, - pub chunk_shape: ChunkShape, - pub chunk_key_encoding: ChunkKeyEncoding, - pub fill_value: FillValue, - pub codecs: Vec, - pub storage_transformers: Option>, - pub dimension_names: Option, +impl DimensionShape { + pub fn new(array_length: u64, chunk_length: NonZeroU64) -> Self { + Self { dim_length: array_length, chunk_length: chunk_length.get() } + } + pub fn array_length(&self) -> u64 { + self.dim_length + } + pub fn chunk_length(&self) -> u64 { + self.chunk_length + } } -impl ZarrArrayMetadata { - /// Returns an iterator over the maximum permitted chunk indices for the array. - /// - /// This function calculates the maximum chunk indices based on the shape of the array - /// and the chunk shape, using (shape - 1) / chunk_shape. Given integer division is truncating, - /// this will always result in proper indices at the boundaries. - /// - /// # Returns - /// - /// A ChunkIndices type containing the max chunk index for each dimension. - fn max_chunk_indices_permitted(&self) -> ChunkIndices { - debug_assert_eq!(self.shape.len(), self.chunk_shape.0.len()); - - ChunkIndices( - self.shape - .iter() - .zip(self.chunk_shape.0.iter()) - .map(|(s, cs)| if *s == 0 { 0 } else { ((s - 1) / cs.get()) as u32 }) - .collect(), - ) +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ArrayShape(Vec); + +impl ArrayShape { + pub fn new(it: I) -> Option + where + I: IntoIterator, + { + let v = it.into_iter().map(|(al, cl)| { + let cl = NonZeroU64::new(cl)?; + Some(DimensionShape::new(al, cl)) + }); + v.collect::>>().map(Self) } /// Validates the provided chunk coordinates for the array. @@ -87,27 +64,75 @@ impl ZarrArrayMetadata { /// /// Returns false if the chunk coordinates are invalid. pub fn valid_chunk_coord(&self, coord: &ChunkIndices) -> bool { - debug_assert_eq!(self.shape.len(), coord.0.len()); - coord .0 .iter() - .zip(self.max_chunk_indices_permitted().0) + .zip(self.max_chunk_indices_permitted()) .all(|(index, index_permitted)| *index <= index_permitted) } + + /// Returns an iterator over the maximum permitted chunk indices for the array. + /// + /// This function calculates the maximum chunk indices based on the shape of the array + /// and the chunk shape, using (shape - 1) / chunk_shape. Given integer division is truncating, + /// this will always result in proper indices at the boundaries. + fn max_chunk_indices_permitted(&self) -> impl Iterator + '_ { + self.0.iter().map(|dim_shape| { + if dim_shape.chunk_length == 0 || dim_shape.dim_length == 0 { + 0 + } else { + ((dim_shape.dim_length - 1) / dim_shape.chunk_length) as u32 + } + }) + } } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum DimensionName { + NotSpecified, + Name(String), +} + +impl From> for DimensionName { + fn from(value: Option<&str>) -> Self { + match value { + Some(s) => s.into(), + None => DimensionName::NotSpecified, + } + } +} + +impl From<&str> for DimensionName { + fn from(value: &str) -> Self { + if value.is_empty() { + DimensionName::NotSpecified + } else { + DimensionName::Name(value.to_string()) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum NodeData { - Array(ZarrArrayMetadata, Vec), + Array { + shape: ArrayShape, + dimension_names: Option>, + manifests: Vec, + }, Group, } +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub enum NodeType { + Group, + Array, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct NodeSnapshot { pub id: NodeId, pub path: Path, - pub user_attributes: Option, + pub user_data: Bytes, pub node_data: NodeData, } @@ -115,41 +140,120 @@ impl NodeSnapshot { pub fn node_type(&self) -> NodeType { match &self.node_data { NodeData::Group => NodeType::Group, - NodeData::Array(_, _) => NodeType::Array, + NodeData::Array { .. } => NodeType::Array, } } } -pub type SnapshotProperties = HashMap; +impl From<&gen::ObjectId8> for NodeId { + fn from(value: &gen::ObjectId8) -> Self { + NodeId::new(value.0) + } +} + +impl From<&gen::ObjectId12> for ManifestId { + fn from(value: &gen::ObjectId12) -> Self { + ManifestId::new(value.0) + } +} + +impl From<&gen::ObjectId12> for AttributesId { + fn from(value: &gen::ObjectId12) -> Self { + AttributesId::new(value.0) + } +} + +impl<'a> From> for ManifestRef { + fn from(value: gen::ManifestRef<'a>) -> Self { + let from = value.extents().iter().map(|range| range.from()).collect::>(); + let to = value.extents().iter().map(|range| range.to()).collect::>(); + let extents = ManifestExtents::new(from.as_slice(), to.as_slice()); + ManifestRef { object_id: value.object_id().into(), extents } + } +} + +impl From<&gen::DimensionShape> for DimensionShape { + fn from(value: &gen::DimensionShape) -> Self { + DimensionShape { + dim_length: value.array_length(), + chunk_length: value.chunk_length(), + } + } +} + +impl<'a> From> for NodeData { + fn from(value: gen::ArrayNodeData<'a>) -> Self { + let dimension_names = value + .dimension_names() + .map(|dn| dn.iter().map(|name| name.name().into()).collect()); + let shape = ArrayShape(value.shape().iter().map(|dim| dim.into()).collect()); + let manifests = value.manifests().iter().map(|m| m.into()).collect(); + Self::Array { shape, dimension_names, manifests } + } +} + +impl<'a> From> for NodeData { + fn from(_: gen::GroupNodeData<'a>) -> Self { + Self::Group + } +} + +impl<'a> TryFrom> for NodeSnapshot { + type Error = IcechunkFormatError; + + fn try_from(value: gen::NodeSnapshot<'a>) -> Result { + #[allow(clippy::expect_used, clippy::panic)] + let node_data: NodeData = match value.node_data_type() { + gen::NodeData::Array => { + value.node_data_as_array().expect("Bug in flatbuffers library").into() + } + gen::NodeData::Group => { + value.node_data_as_group().expect("Bug in flatbuffers library").into() + } + x => panic!("Invalid node data type in flatbuffers file {:?}", x), + }; + let res = NodeSnapshot { + id: value.id().into(), + path: value.path().to_string().try_into()?, + node_data, + user_data: Bytes::copy_from_slice(value.user_data().bytes()), + }; + Ok(res) + } +} + +impl From<&gen::ManifestFileInfo> for ManifestFileInfo { + fn from(value: &gen::ManifestFileInfo) -> Self { + Self { + id: value.id().into(), + size_bytes: value.size_bytes(), + num_chunk_refs: value.num_chunk_refs(), + } + } +} + +pub type SnapshotProperties = BTreeMap; #[derive(Debug, PartialEq, Serialize, Deserialize, Clone, Eq, Hash)] pub struct ManifestFileInfo { pub id: ManifestId, pub size_bytes: u64, - pub num_rows: u32, + pub num_chunk_refs: u32, } impl ManifestFileInfo { pub fn new(manifest: &Manifest, size_bytes: u64) -> Self { - Self { id: manifest.id.clone(), num_rows: manifest.len() as u32, size_bytes } + Self { + id: manifest.id().clone(), + num_chunk_refs: manifest.len() as u32, + size_bytes, + } } } -#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] -pub struct AttributeFileInfo { - pub id: AttributesId, -} - #[derive(Debug, PartialEq)] pub struct Snapshot { - id: SnapshotId, - parent_id: Option, - flushed_at: DateTime, - message: String, - metadata: SnapshotProperties, - manifest_files: HashMap, - attribute_files: Vec, - nodes: BTreeMap, + buffer: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -161,166 +265,227 @@ pub struct SnapshotInfo { pub metadata: SnapshotProperties, } -impl From<&Snapshot> for SnapshotInfo { - fn from(value: &Snapshot) -> Self { - Self { +impl TryFrom<&Snapshot> for SnapshotInfo { + type Error = IcechunkFormatError; + + fn try_from(value: &Snapshot) -> Result { + Ok(Self { id: value.id().clone(), parent_id: value.parent_id().clone(), - flushed_at: *value.flushed_at(), + flushed_at: value.flushed_at()?, message: value.message().to_string(), - metadata: value.metadata().clone(), - } + metadata: value.metadata()?.clone(), + }) } } +impl SnapshotInfo { + pub fn is_initial(&self) -> bool { + self.parent_id.is_none() + } +} + +static ROOT_OPTIONS: VerifierOptions = VerifierOptions { + max_depth: 64, + max_tables: 500_000, + max_apparent_size: 1 << 31, // taken from the default + ignore_missing_null_terminator: true, +}; + impl Snapshot { pub const INITIAL_COMMIT_MESSAGE: &'static str = "Repository initialized"; - fn new( - parent_id: Option, - message: String, - metadata: Option, - nodes: BTreeMap, - manifest_files: Vec, - attribute_files: Vec, - ) -> Self { - let metadata = metadata.unwrap_or_default(); - let flushed_at = Utc::now(); - Self { - id: SnapshotId::random(), - parent_id, - flushed_at, - message, - manifest_files: manifest_files - .into_iter() - .map(|fi| (fi.id.clone(), fi)) - .collect(), - attribute_files, - metadata, - nodes, - } + pub fn from_buffer(buffer: Vec) -> IcechunkResult { + let _ = flatbuffers::root_with_opts::( + &ROOT_OPTIONS, + buffer.as_slice(), + )?; + Ok(Snapshot { buffer }) } - #[allow(clippy::too_many_arguments)] - pub fn from_fields( - id: SnapshotId, - parent_id: Option, - flushed_at: DateTime, - message: String, - metadata: SnapshotProperties, - manifest_files: HashMap, - attribute_files: Vec, - nodes: BTreeMap, - ) -> Self { - Self { - id, - parent_id, - flushed_at, - message, - metadata, - manifest_files, - attribute_files, - nodes, - } + pub fn bytes(&self) -> &[u8] { + self.buffer.as_slice() } - pub fn from_iter>( - parent_id: SnapshotId, + pub fn from_iter( + id: Option, + parent_id: Option, message: String, properties: Option, - manifest_files: Vec, - attribute_files: Vec, - iter: T, - ) -> Self { - let nodes = iter.into_iter().map(|node| (node.path.clone(), node)).collect(); - - Self::new( - Some(parent_id), - message, - properties, - nodes, - manifest_files, - attribute_files, - ) + mut manifest_files: Vec, + sorted_iter: I, + ) -> IcechunkResult + where + IcechunkFormatError: From, + I: IntoIterator>, + { + // TODO: what's a good capacity? + let mut builder = flatbuffers::FlatBufferBuilder::with_capacity(4_096); + + manifest_files.sort_by(|a, b| a.id.cmp(&b.id)); + let manifest_files = manifest_files + .iter() + .map(|mfi| { + let id = gen::ObjectId12::new(&mfi.id.0); + gen::ManifestFileInfo::new(&id, mfi.size_bytes, mfi.num_chunk_refs) + }) + .collect::>(); + let manifest_files = builder.create_vector(&manifest_files); + + let metadata_items: Vec<_> = properties + .unwrap_or_default() + .iter() + .map(|(k, v)| { + let name = builder.create_shared_string(k.as_str()); + let serialized = rmp_serde::to_vec(v)?; + let value = builder.create_vector(serialized.as_slice()); + Ok::<_, IcechunkFormatError>(gen::MetadataItem::create( + &mut builder, + &gen::MetadataItemArgs { name: Some(name), value: Some(value) }, + )) + }) + .try_collect()?; + let metadata_items = builder.create_vector(metadata_items.as_slice()); + + let message = builder.create_string(&message); + let parent_id = parent_id.map(|oid| gen::ObjectId12::new(&oid.0)); + let flushed_at = Utc::now().timestamp_micros() as u64; + let id = gen::ObjectId12::new(&id.unwrap_or_else(SnapshotId::random).0); + + let nodes: Vec<_> = sorted_iter + .into_iter() + .map(|node| node.err_into().and_then(|node| mk_node(&mut builder, &node))) + .try_collect()?; + let nodes = builder.create_vector(&nodes); + + let snap = gen::Snapshot::create( + &mut builder, + &gen::SnapshotArgs { + id: Some(&id), + parent_id: parent_id.as_ref(), + nodes: Some(nodes), + flushed_at, + message: Some(message), + metadata: Some(metadata_items), + manifest_files: Some(manifest_files), + }, + ); + + builder.finish(snap, Some("Ichk")); + let (mut buffer, offset) = builder.collapse(); + buffer.drain(0..offset); + buffer.shrink_to_fit(); + Ok(Snapshot { buffer }) } - pub fn initial() -> Self { + pub fn initial() -> IcechunkResult { let properties = [("__root".to_string(), serde_json::Value::from(true))].into(); - Self::new( + let nodes: Vec> = Vec::new(); + Self::from_iter( + None, None, Self::INITIAL_COMMIT_MESSAGE.to_string(), Some(properties), Default::default(), - Default::default(), - Default::default(), + nodes, ) } - pub fn id(&self) -> &SnapshotId { - &self.id + fn root(&self) -> gen::Snapshot { + // without the unsafe version this is too slow + // if we try to keep the root in the Manifest struct, we would need a lifetime + unsafe { flatbuffers::root_unchecked::(&self.buffer) } } - pub fn parent_id(&self) -> &Option { - &self.parent_id + pub fn id(&self) -> SnapshotId { + SnapshotId::new(self.root().id().0) } - pub fn metadata(&self) -> &SnapshotProperties { - &self.metadata + pub fn parent_id(&self) -> Option { + self.root().parent_id().map(|pid| SnapshotId::new(pid.0)) } - pub fn flushed_at(&self) -> &DateTime { - &self.flushed_at + pub fn metadata(&self) -> IcechunkResult { + self.root() + .metadata() + .iter() + .map(|item| { + let key = item.name().to_string(); + let value = rmp_serde::from_slice(item.value().bytes())?; + Ok((key, value)) + }) + .try_collect() } - pub fn message(&self) -> &String { - &self.message + pub fn flushed_at(&self) -> IcechunkResult> { + let ts = self.root().flushed_at(); + let ts: i64 = ts.try_into().map_err(|_| { + IcechunkFormatError::from(IcechunkFormatErrorKind::InvalidTimestamp) + })?; + DateTime::from_timestamp_micros(ts) + .ok_or_else(|| IcechunkFormatErrorKind::InvalidTimestamp.into()) } - pub fn nodes(&self) -> &BTreeMap { - &self.nodes + pub fn message(&self) -> String { + self.root().message().to_string() } - pub fn get_manifest_file(&self, id: &ManifestId) -> Option<&ManifestFileInfo> { - self.manifest_files.get(id) - } + // pub fn nodes(&self) -> &BTreeMap { + // &self.nodes + // } - pub fn manifest_files(&self) -> &HashMap { - &self.manifest_files + pub fn get_manifest_file(&self, id: &ManifestId) -> Option { + self.root().manifest_files().iter().find(|mf| mf.id().0 == id.0.as_slice()).map( + |mf| ManifestFileInfo { + id: ManifestId::new(mf.id().0), + size_bytes: mf.size_bytes(), + num_chunk_refs: mf.num_chunk_refs(), + }, + ) } - pub fn attribute_files(&self) -> &Vec { - &self.attribute_files + + pub fn manifest_files(&self) -> impl Iterator + '_ { + self.root().manifest_files().iter().map(|mf| mf.into()) } - /// Cretase a new `Snapshot` with all the same data as `self` but a different parent - pub fn adopt(&self, parent: &Snapshot) -> Self { - Self { - id: self.id.clone(), - parent_id: Some(parent.id().clone()), - flushed_at: *self.flushed_at(), - message: self.message().clone(), - metadata: self.metadata().clone(), - manifest_files: self.manifest_files().clone(), - attribute_files: self.attribute_files().clone(), - nodes: self.nodes.clone(), - } + /// Cretase a new `Snapshot` with all the same data as `new_child` but `self` as parent + pub fn adopt(&self, new_child: &Snapshot) -> IcechunkResult { + // Rust flatbuffers implementation doesn't allow mutation of scalars, so we need to + // create a whole new buffer and write to it in full + + Snapshot::from_iter( + Some(new_child.id()), + Some(self.id()), + new_child.message().clone(), + Some(new_child.metadata()?.clone()), + new_child.manifest_files().collect(), + new_child.iter(), + ) } - pub fn get_node(&self, path: &Path) -> IcechunkResult<&NodeSnapshot> { - self.nodes - .get(path) - .ok_or(IcechunkFormatError::NodeNotFound { path: path.clone() }) + pub fn get_node(&self, path: &Path) -> IcechunkResult { + let res = self + .root() + .nodes() + .lookup_by_key(path.to_string().as_str(), |node, path| node.path().cmp(path)) + .ok_or(IcechunkFormatError::from(IcechunkFormatErrorKind::NodeNotFound { + path: path.clone(), + }))?; + res.try_into() } - pub fn iter(&self) -> impl Iterator + '_ { - self.nodes.values() + pub fn iter(&self) -> impl Iterator> + '_ { + self.root().nodes().iter().map(|node| node.try_into().err_into()) } - pub fn iter_arc(self: Arc) -> impl Iterator { - NodeIterator { table: self, last_key: None } + pub fn iter_arc( + self: Arc, + ) -> impl Iterator> { + NodeIterator { snapshot: self, last_index: 0 } } pub fn len(&self) -> usize { - self.nodes.len() + self.root().nodes().len() } #[must_use] @@ -328,48 +493,134 @@ impl Snapshot { self.len() == 0 } - pub fn manifest_info(&self, id: &ManifestId) -> Option<&ManifestFileInfo> { - self.manifest_files.get(id) + pub fn manifest_info(&self, id: &ManifestId) -> Option { + self.root() + .manifest_files() + .iter() + .find(|mi| mi.id().0 == id.0) + .map(|man| man.into()) } } -// We need this complex dance because Rust makes it really hard to put together an object and a -// reference to it (in the iterator) in a single self-referential struct struct NodeIterator { - table: Arc, - last_key: Option, + snapshot: Arc, + last_index: usize, } impl Iterator for NodeIterator { - type Item = NodeSnapshot; + type Item = IcechunkResult; fn next(&mut self) -> Option { - match &self.last_key { - None => { - if let Some((k, v)) = self.table.nodes.first_key_value() { - self.last_key = Some(k.clone()); - Some(v.clone()) - } else { - None - } - } - Some(last_key) => { - if let Some((k, v)) = self - .table - .nodes - .range::((Bound::Excluded(last_key), Bound::Unbounded)) - .next() - { - self.last_key = Some(k.clone()); - Some(v.clone()) - } else { - None - } - } + let nodes = self.snapshot.root().nodes(); + if self.last_index < nodes.len() { + let res = Some(nodes.get(self.last_index).try_into().err_into()); + self.last_index += 1; + res + } else { + None } } } +fn mk_node<'bldr>( + builder: &mut flatbuffers::FlatBufferBuilder<'bldr>, + node: &NodeSnapshot, +) -> IcechunkResult>> { + let id = gen::ObjectId8::new(&node.id.0); + let path = builder.create_string(node.path.to_string().as_str()); + let (node_data_type, node_data) = mk_node_data(builder, &node.node_data)?; + let user_data = Some(builder.create_vector(&node.user_data)); + Ok(gen::NodeSnapshot::create( + builder, + &gen::NodeSnapshotArgs { + id: Some(&id), + path: Some(path), + node_data_type, + node_data, + user_data, + }, + )) +} + +fn mk_node_data( + builder: &mut FlatBufferBuilder<'_>, + node_data: &NodeData, +) -> IcechunkResult<( + gen::NodeData, + Option>, +)> { + match node_data { + NodeData::Array { manifests, dimension_names, shape } => { + let manifests = manifests + .iter() + .map(|manref| { + let object_id = gen::ObjectId12::new(&manref.object_id.0); + let extents = manref + .extents + .iter() + .map(|range| gen::ChunkIndexRange::new(range.start, range.end)) + .collect::>(); + let extents = builder.create_vector(&extents); + gen::ManifestRef::create( + builder, + &gen::ManifestRefArgs { + object_id: Some(&object_id), + extents: Some(extents), + }, + ) + }) + .collect::>(); + let manifests = builder.create_vector(manifests.as_slice()); + let dimensions = dimension_names.as_ref().map(|dn| { + let names = dn + .iter() + .map(|n| match n { + DimensionName::Name(s) => { + let n = builder.create_shared_string(s.as_str()); + gen::DimensionName::create( + builder, + &gen::DimensionNameArgs { name: Some(n) }, + ) + } + DimensionName::NotSpecified => gen::DimensionName::create( + builder, + &gen::DimensionNameArgs { name: None }, + ), + }) + .collect::>(); + builder.create_vector(names.as_slice()) + }); + let shape = shape + .0 + .iter() + .map(|ds| gen::DimensionShape::new(ds.dim_length, ds.chunk_length)) + .collect::>(); + let shape = builder.create_vector(shape.as_slice()); + Ok(( + gen::NodeData::Array, + Some( + gen::ArrayNodeData::create( + builder, + &gen::ArrayNodeDataArgs { + manifests: Some(manifests), + shape: Some(shape), + dimension_names: dimensions, + }, + ) + .as_union_value(), + ), + )) + } + NodeData::Group => Ok(( + gen::NodeData::Group, + Some( + gen::GroupNodeData::create(builder, &gen::GroupNodeDataArgs {}) + .as_union_value(), + ), + )), + } +} + #[cfg(test)] #[allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)] mod tests { @@ -377,245 +628,201 @@ mod tests { use super::*; use pretty_assertions::assert_eq; - use std::{ - collections::HashMap, - iter::{self}, - num::NonZeroU64, - }; + use std::iter::{self}; #[test] fn test_get_node() -> Result<(), Box> { - let zarr_meta1 = ZarrArrayMetadata { - shape: vec![10u64, 20, 30], - data_type: DataType::Float32, - chunk_shape: ChunkShape(vec![ - NonZeroU64::new(3).unwrap(), - NonZeroU64::new(2).unwrap(), - NonZeroU64::new(1).unwrap(), - ]), - chunk_key_encoding: ChunkKeyEncoding::Slash, - fill_value: FillValue::Float32(0f32), - - codecs: vec![Codec { - name: "mycodec".to_string(), - configuration: Some(HashMap::from_iter(iter::once(( - "foo".to_string(), - serde_json::Value::from(42), - )))), - }], - storage_transformers: Some(vec![StorageTransformer { - name: "mytransformer".to_string(), - configuration: Some(HashMap::from_iter(iter::once(( - "foo".to_string(), - serde_json::Value::from(42), - )))), - }]), - dimension_names: Some(vec![ - Some("x".to_string()), - Some("y".to_string()), - Some("t".to_string()), - ]), - }; - let zarr_meta2 = ZarrArrayMetadata { - storage_transformers: None, - data_type: DataType::Int32, - dimension_names: Some(vec![None, None, Some("t".to_string())]), - fill_value: FillValue::Int32(0i32), - ..zarr_meta1.clone() - }; - let zarr_meta3 = - ZarrArrayMetadata { dimension_names: None, ..zarr_meta2.clone() }; + let shape1 = ArrayShape::new(vec![(10u64, 3), (20, 2), (30, 1)]).unwrap(); + let dim_names1 = Some(vec!["x".into(), "y".into(), "t".into()]); + + let shape2 = shape1.clone(); + let dim_names2 = Some(vec![ + DimensionName::NotSpecified, + DimensionName::NotSpecified, + "t".into(), + ]); + + let shape3 = shape1.clone(); + let dim_names3 = None; + let man_ref1 = ManifestRef { object_id: ObjectId::random(), - extents: ChunkIndices(vec![0, 0, 0])..ChunkIndices(vec![100, 100, 100]), + extents: ManifestExtents::new(&[0, 0, 0], &[100, 100, 100]), }; let man_ref2 = ManifestRef { object_id: ObjectId::random(), - extents: ChunkIndices(vec![0, 0, 0])..ChunkIndices(vec![100, 100, 100]), + extents: ManifestExtents::new(&[0, 0, 0], &[100, 100, 100]), }; - let oid = ObjectId::random(); let node_ids = iter::repeat_with(NodeId::random).take(7).collect::>(); + // nodes must be sorted by path let nodes = vec![ NodeSnapshot { path: Path::root(), id: node_ids[0].clone(), - user_attributes: None, + user_data: Bytes::new(), node_data: NodeData::Group, }, NodeSnapshot { path: "/a".try_into().unwrap(), id: node_ids[1].clone(), - user_attributes: None, + user_data: Bytes::new(), node_data: NodeData::Group, }, NodeSnapshot { - path: "/b".try_into().unwrap(), - id: node_ids[2].clone(), - user_attributes: None, - node_data: NodeData::Group, + path: "/array2".try_into().unwrap(), + id: node_ids[5].clone(), + user_data: Bytes::new(), + node_data: NodeData::Array { + shape: shape2.clone(), + dimension_names: dim_names2.clone(), + manifests: vec![], + }, }, NodeSnapshot { - path: "/b/c".try_into().unwrap(), - id: node_ids[3].clone(), - user_attributes: Some(UserAttributesSnapshot::Inline( - UserAttributes::try_new(br#"{"foo": "some inline"}"#).unwrap(), - )), + path: "/b".try_into().unwrap(), + id: node_ids[2].clone(), + user_data: Bytes::new(), node_data: NodeData::Group, }, NodeSnapshot { path: "/b/array1".try_into().unwrap(), id: node_ids[4].clone(), - user_attributes: Some(UserAttributesSnapshot::Ref(UserAttributesRef { - object_id: oid.clone(), - location: 42, - })), - node_data: NodeData::Array( - zarr_meta1.clone(), - vec![man_ref1.clone(), man_ref2.clone()], - ), - }, - NodeSnapshot { - path: "/array2".try_into().unwrap(), - id: node_ids[5].clone(), - user_attributes: None, - node_data: NodeData::Array(zarr_meta2.clone(), vec![]), + user_data: Bytes::copy_from_slice(b"hello"), + node_data: NodeData::Array { + shape: shape1.clone(), + dimension_names: dim_names1.clone(), + manifests: vec![man_ref1.clone(), man_ref2.clone()], + }, }, NodeSnapshot { path: "/b/array3".try_into().unwrap(), id: node_ids[6].clone(), - user_attributes: None, - node_data: NodeData::Array(zarr_meta3.clone(), vec![]), + user_data: Bytes::new(), + node_data: NodeData::Array { + shape: shape3.clone(), + dimension_names: dim_names3.clone(), + manifests: vec![], + }, + }, + NodeSnapshot { + path: "/b/c".try_into().unwrap(), + id: node_ids[3].clone(), + user_data: Bytes::copy_from_slice(b"bye"), + node_data: NodeData::Group, }, ]; - let initial = Snapshot::initial(); + let initial = Snapshot::initial().unwrap(); let manifests = vec![ ManifestFileInfo { id: man_ref1.object_id.clone(), size_bytes: 1_000_000, - num_rows: 100_000, + num_chunk_refs: 100_000, }, ManifestFileInfo { id: man_ref2.object_id.clone(), size_bytes: 1_000_000, - num_rows: 100_000, + num_chunk_refs: 100_000, }, ]; let st = Snapshot::from_iter( - initial.id.clone(), + None, + Some(initial.id().clone()), String::default(), Default::default(), manifests, - vec![], - nodes, - ); + nodes.into_iter().map(Ok::), + ) + .unwrap(); - assert_eq!( + assert!(matches!( st.get_node(&"/nonexistent".try_into().unwrap()), - Err(IcechunkFormatError::NodeNotFound { - path: "/nonexistent".try_into().unwrap() - }) - ); - - let node = st.get_node(&"/b/c".try_into().unwrap()); + Err(IcechunkFormatError { + kind: IcechunkFormatErrorKind::NodeNotFound { + path + }, + .. + }) if path == "/nonexistent".try_into().unwrap() + )); + + let node = st.get_node(&"/b/c".try_into().unwrap()).unwrap(); assert_eq!( node, - Ok(&NodeSnapshot { + NodeSnapshot { path: "/b/c".try_into().unwrap(), id: node_ids[3].clone(), - user_attributes: Some(UserAttributesSnapshot::Inline( - UserAttributes::try_new(br#"{"foo": "some inline"}"#).unwrap(), - )), + user_data: Bytes::copy_from_slice(b"bye"), node_data: NodeData::Group, - }), + }, ); - let node = st.get_node(&Path::root()); + let node = st.get_node(&Path::root()).unwrap(); assert_eq!( node, - Ok(&NodeSnapshot { + NodeSnapshot { path: Path::root(), id: node_ids[0].clone(), - user_attributes: None, + user_data: Bytes::new(), node_data: NodeData::Group, - }), + }, ); - let node = st.get_node(&"/b/array1".try_into().unwrap()); + let node = st.get_node(&"/b/array1".try_into().unwrap()).unwrap(); assert_eq!( node, - Ok(&NodeSnapshot { + NodeSnapshot { path: "/b/array1".try_into().unwrap(), id: node_ids[4].clone(), - user_attributes: Some(UserAttributesSnapshot::Ref(UserAttributesRef { - object_id: oid, - location: 42, - })), - node_data: NodeData::Array(zarr_meta1.clone(), vec![man_ref1, man_ref2]), - }), + user_data: Bytes::copy_from_slice(b"hello"), + node_data: NodeData::Array { + shape: shape1.clone(), + dimension_names: dim_names1.clone(), + manifests: vec![man_ref1, man_ref2] + }, + }, ); - let node = st.get_node(&"/array2".try_into().unwrap()); + let node = st.get_node(&"/array2".try_into().unwrap()).unwrap(); assert_eq!( node, - Ok(&NodeSnapshot { + NodeSnapshot { path: "/array2".try_into().unwrap(), id: node_ids[5].clone(), - user_attributes: None, - node_data: NodeData::Array(zarr_meta2.clone(), vec![]), - }), + user_data: Bytes::new(), + node_data: NodeData::Array { + shape: shape2.clone(), + dimension_names: dim_names2.clone(), + manifests: vec![] + }, + }, ); - let node = st.get_node(&"/b/array3".try_into().unwrap()); + let node = st.get_node(&"/b/array3".try_into().unwrap()).unwrap(); assert_eq!( node, - Ok(&NodeSnapshot { + NodeSnapshot { path: "/b/array3".try_into().unwrap(), id: node_ids[6].clone(), - user_attributes: None, - node_data: NodeData::Array(zarr_meta3.clone(), vec![]), - }), + user_data: Bytes::new(), + node_data: NodeData::Array { + shape: shape3.clone(), + dimension_names: dim_names3.clone(), + manifests: vec![] + }, + }, ); Ok(()) } #[test] fn test_valid_chunk_coord() { - let zarr_meta1 = ZarrArrayMetadata { - shape: vec![10000, 10001, 9999], - data_type: DataType::Float32, - chunk_shape: ChunkShape(vec![ - NonZeroU64::new(1000).unwrap(), - NonZeroU64::new(1000).unwrap(), - NonZeroU64::new(1000).unwrap(), - ]), - chunk_key_encoding: ChunkKeyEncoding::Slash, - fill_value: FillValue::Float32(0f32), - - codecs: vec![Codec { - name: "mycodec".to_string(), - configuration: Some(HashMap::from_iter(iter::once(( - "foo".to_string(), - serde_json::Value::from(42), - )))), - }], - storage_transformers: None, - dimension_names: None, - }; - - let zarr_meta2 = ZarrArrayMetadata { - shape: vec![0, 0, 0], - chunk_shape: ChunkShape(vec![ - NonZeroU64::new(1000).unwrap(), - NonZeroU64::new(1000).unwrap(), - NonZeroU64::new(1000).unwrap(), - ]), - ..zarr_meta1.clone() - }; - + let shape1 = + ArrayShape::new(vec![(10_000, 1_000), (10_001, 1_000), (9_999, 1_000)]) + .unwrap(); + let shape2 = ArrayShape::new(vec![(0, 1_000), (0, 1_000), (0, 1_000)]).unwrap(); let coord1 = ChunkIndices(vec![9, 10, 9]); let coord2 = ChunkIndices(vec![10, 11, 10]); let coord3 = ChunkIndices(vec![0, 0, 0]); - assert!(zarr_meta1.valid_chunk_coord(&coord1)); - assert!(!zarr_meta1.valid_chunk_coord(&coord2)); + assert!(shape1.valid_chunk_coord(&coord1)); + assert!(!shape1.valid_chunk_coord(&coord2)); - assert!(zarr_meta2.valid_chunk_coord(&coord3)); + assert!(shape2.valid_chunk_coord(&coord3)); } } diff --git a/icechunk/src/format/transaction_log.rs b/icechunk/src/format/transaction_log.rs index 71cf3635..eb2b4540 100644 --- a/icechunk/src/format/transaction_log.rs +++ b/icechunk/src/format/transaction_log.rs @@ -1,82 +1,349 @@ -use std::collections::{HashMap, HashSet}; +use std::{ + collections::{BTreeMap, BTreeSet, HashMap, HashSet}, + iter, +}; -use crate::change_set::ChangeSet; +use flatbuffers::VerifierOptions; +use itertools::{Either, Itertools as _}; -use super::{ - snapshot::{NodeSnapshot, NodeType}, - ChunkIndices, NodeId, +use crate::{ + change_set::ChangeSet, + format::flatbuffers::gen::ObjectId12, + session::{Session, SessionResult}, }; +use super::{flatbuffers::gen, ChunkIndices, IcechunkResult, NodeId, Path, SnapshotId}; + #[derive(Clone, Debug, PartialEq, Default)] pub struct TransactionLog { - // FIXME: better, more stable on-disk format - pub new_groups: HashSet, - pub new_arrays: HashSet, - pub deleted_groups: HashSet, - pub deleted_arrays: HashSet, - pub updated_user_attributes: HashSet, - pub updated_zarr_metadata: HashSet, - pub updated_chunks: HashMap>, + buffer: Vec, } impl TransactionLog { - pub fn new<'a>( - cs: &ChangeSet, - parent_nodes: impl Iterator, - child_nodes: impl Iterator, - ) -> Self { - let new_groups = cs.new_groups().map(|(_, node_id)| node_id).cloned().collect(); - let new_arrays = cs.new_arrays().map(|(_, node_id)| node_id).cloned().collect(); - let parent_nodes = - parent_nodes.map(|n| (n.id.clone(), n.node_type())).collect::>(); - let child_nodes = - child_nodes.map(|n| (n.id.clone(), n.node_type())).collect::>(); - let mut deleted_groups = HashSet::new(); - let mut deleted_arrays = HashSet::new(); - - for (node_id, node_type) in parent_nodes.difference(&child_nodes) { - // TODO: we shouldn't need the following clones - match node_type { - NodeType::Group => { - deleted_groups.insert(node_id.clone()); + pub fn new(id: &SnapshotId, cs: &ChangeSet) -> Self { + let mut new_groups: Vec<_> = + cs.new_groups().map(|(_, id)| gen::ObjectId8::new(&id.0)).collect(); + let mut new_arrays: Vec<_> = + cs.new_arrays().map(|(_, id)| gen::ObjectId8::new(&id.0)).collect(); + let mut deleted_groups: Vec<_> = + cs.deleted_groups().map(|(_, id)| gen::ObjectId8::new(&id.0)).collect(); + let mut deleted_arrays: Vec<_> = + cs.deleted_arrays().map(|(_, id)| gen::ObjectId8::new(&id.0)).collect(); + + let mut updated_arrays: Vec<_> = + cs.updated_arrays().map(|id| gen::ObjectId8::new(&id.0)).collect(); + let mut updated_groups: Vec<_> = + cs.updated_groups().map(|id| gen::ObjectId8::new(&id.0)).collect(); + + // TODO: what's a good capacity? + let mut builder = flatbuffers::FlatBufferBuilder::with_capacity(1_024 * 1_024); + + // these come sorted from the change set + let updated_chunks = cs + .chunk_changes() + .map(|(node_id, chunks)| { + let node_id = gen::ObjectId8::new(&node_id.0); + let node_id = Some(&node_id); + let chunks = chunks + .keys() + .map(|indices| { + let coords = Some(builder.create_vector(indices.0.as_slice())); + gen::ChunkIndices::create( + &mut builder, + &gen::ChunkIndicesArgs { coords }, + ) + }) + .collect::>(); + let chunks = Some(builder.create_vector(chunks.as_slice())); + gen::ArrayUpdatedChunks::create( + &mut builder, + &gen::ArrayUpdatedChunksArgs { node_id, chunks }, + ) + }) + .collect::>(); + let updated_chunks = builder.create_vector(updated_chunks.as_slice()); + let updated_chunks = Some(updated_chunks); + + new_groups.sort_by(|a, b| a.0.cmp(&b.0)); + new_arrays.sort_by(|a, b| a.0.cmp(&b.0)); + deleted_groups.sort_by(|a, b| a.0.cmp(&b.0)); + deleted_arrays.sort_by(|a, b| a.0.cmp(&b.0)); + updated_groups.sort_by(|a, b| a.0.cmp(&b.0)); + updated_arrays.sort_by(|a, b| a.0.cmp(&b.0)); + + let new_groups = Some(builder.create_vector(new_groups.as_slice())); + let new_arrays = Some(builder.create_vector(new_arrays.as_slice())); + let deleted_groups = Some(builder.create_vector(deleted_groups.as_slice())); + let deleted_arrays = Some(builder.create_vector(deleted_arrays.as_slice())); + let updated_groups = Some(builder.create_vector(updated_groups.as_slice())); + let updated_arrays = Some(builder.create_vector(updated_arrays.as_slice())); + + let id = ObjectId12::new(&id.0); + let id = Some(&id); + let tx = gen::TransactionLog::create( + &mut builder, + &gen::TransactionLogArgs { + id, + new_groups, + new_arrays, + deleted_groups, + deleted_arrays, + updated_groups, + updated_arrays, + updated_chunks, + }, + ); + + builder.finish(tx, Some("Ichk")); + let (mut buffer, offset) = builder.collapse(); + buffer.drain(0..offset); + buffer.shrink_to_fit(); + Self { buffer } + } + + pub fn from_buffer(buffer: Vec) -> IcechunkResult { + let _ = flatbuffers::root_with_opts::( + &ROOT_OPTIONS, + buffer.as_slice(), + )?; + Ok(TransactionLog { buffer }) + } + + pub fn new_groups(&self) -> impl Iterator + '_ { + self.root().new_groups().iter().map(From::from) + } + + pub fn new_arrays(&self) -> impl Iterator + '_ { + self.root().new_arrays().iter().map(From::from) + } + + pub fn deleted_groups(&self) -> impl Iterator + '_ { + self.root().deleted_groups().iter().map(From::from) + } + + pub fn deleted_arrays(&self) -> impl Iterator + '_ { + self.root().deleted_arrays().iter().map(From::from) + } + + pub fn updated_groups(&self) -> impl Iterator + '_ { + self.root().updated_groups().iter().map(From::from) + } + + pub fn updated_arrays(&self) -> impl Iterator + '_ { + self.root().updated_arrays().iter().map(From::from) + } + + pub fn updated_chunks( + &self, + ) -> impl Iterator + '_)> + '_ + { + self.root().updated_chunks().iter().map(|arr_chunks| { + let id: NodeId = arr_chunks.node_id().into(); + let chunks = arr_chunks.chunks().iter().map(|idx| idx.into()); + (id, chunks) + }) + } + + pub fn updated_chunks_for( + &self, + node: &NodeId, + ) -> impl Iterator + '_ { + let arr = self + .root() + .updated_chunks() + .lookup_by_key(node.0, |a, b| a.node_id().0.cmp(b)); + + match arr { + Some(arr) => Either::Left(arr.chunks().iter().map(From::from)), + None => Either::Right(iter::empty()), + } + } + + pub fn group_created(&self, id: &NodeId) -> bool { + self.root().new_groups().lookup_by_key(id.0, |a, b| a.0.cmp(b)).is_some() + } + + pub fn array_created(&self, id: &NodeId) -> bool { + self.root().new_arrays().lookup_by_key(id.0, |a, b| a.0.cmp(b)).is_some() + } + + pub fn group_deleted(&self, id: &NodeId) -> bool { + self.root().deleted_groups().lookup_by_key(id.0, |a, b| a.0.cmp(b)).is_some() + } + + pub fn array_deleted(&self, id: &NodeId) -> bool { + self.root().deleted_arrays().lookup_by_key(id.0, |a, b| a.0.cmp(b)).is_some() + } + + pub fn group_updated(&self, id: &NodeId) -> bool { + self.root().updated_groups().lookup_by_key(id.0, |a, b| a.0.cmp(b)).is_some() + } + + pub fn array_updated(&self, id: &NodeId) -> bool { + self.root().updated_arrays().lookup_by_key(id.0, |a, b| a.0.cmp(b)).is_some() + } + + pub fn chunks_updated(&self, id: &NodeId) -> bool { + self.root() + .updated_chunks() + .lookup_by_key(id.0, |a, b| a.node_id().0.cmp(b)) + .is_some() + } + + fn root(&self) -> gen::TransactionLog { + // without the unsafe version this is too slow + // if we try to keep the root in the TransactionLog struct, we would need a lifetime + unsafe { flatbuffers::root_unchecked::(&self.buffer) } + } + + pub fn bytes(&self) -> &[u8] { + self.buffer.as_slice() + } + + pub fn len(&self) -> usize { + let root = self.root(); + root.new_groups().len() + + root.new_arrays().len() + + root.deleted_groups().len() + + root.deleted_arrays().len() + + root.updated_groups().len() + + root.updated_arrays().len() + + root.updated_chunks().iter().map(|s| s.chunks().len()).sum::() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +static ROOT_OPTIONS: VerifierOptions = VerifierOptions { + max_depth: 64, + max_tables: 50_000_000, + max_apparent_size: 1 << 31, // taken from the default + ignore_missing_null_terminator: true, +}; + +#[derive(Debug, Default)] +pub struct DiffBuilder { + new_groups: HashSet, + new_arrays: HashSet, + deleted_groups: HashSet, + deleted_arrays: HashSet, + updated_groups: HashSet, + updated_arrays: HashSet, + // we use sorted set here to simply move it to a diff without having to rebuild + updated_chunks: HashMap>, +} + +impl DiffBuilder { + pub fn add_changes(&mut self, tx: &TransactionLog) { + self.new_groups.extend(tx.new_groups()); + self.new_arrays.extend(tx.new_arrays()); + self.deleted_groups.extend(tx.deleted_groups()); + self.deleted_arrays.extend(tx.deleted_arrays()); + self.updated_groups.extend(tx.updated_groups()); + self.updated_arrays.extend(tx.updated_arrays()); + + for (node, chunks) in tx.updated_chunks() { + match self.updated_chunks.get_mut(&node) { + Some(all_chunks) => { + all_chunks.extend(chunks); } - NodeType::Array => { - deleted_arrays.insert(node_id.clone()); + None => { + self.updated_chunks.insert(node, BTreeSet::from_iter(chunks)); } } } + } - let updated_user_attributes = - cs.user_attributes_updated_nodes().cloned().collect(); - let updated_zarr_metadata = cs.zarr_updated_arrays().cloned().collect(); - let updated_chunks = cs - .chunk_changes() - .map(|(k, v)| (k.clone(), v.keys().cloned().collect())) - .collect(); + pub async fn to_diff(self, from: &Session, to: &Session) -> SessionResult { + let nodes: HashMap = from + .list_nodes() + .await? + .chain(to.list_nodes().await?) + .map_ok(|n| (n.id, n.path)) + .try_collect()?; + Ok(Diff::from_diff_builder(self, nodes)) + } +} +#[derive(Clone, Debug, PartialEq)] +pub struct Diff { + pub new_groups: BTreeSet, + pub new_arrays: BTreeSet, + pub deleted_groups: BTreeSet, + pub deleted_arrays: BTreeSet, + pub updated_groups: BTreeSet, + pub updated_arrays: BTreeSet, + pub updated_chunks: BTreeMap>, +} + +impl Diff { + fn from_diff_builder(tx: DiffBuilder, nodes: HashMap) -> Self { + let new_groups = tx + .new_groups + .iter() + .flat_map(|node_id| nodes.get(node_id)) + .cloned() + .collect(); + let new_arrays = tx + .new_arrays + .iter() + .flat_map(|node_id| nodes.get(node_id)) + .cloned() + .collect(); + let deleted_groups = tx + .deleted_groups + .iter() + .flat_map(|node_id| nodes.get(node_id)) + .cloned() + .collect(); + let deleted_arrays = tx + .deleted_arrays + .iter() + .flat_map(|node_id| nodes.get(node_id)) + .cloned() + .collect(); + let updated_groups = tx + .updated_groups + .iter() + .flat_map(|node_id| nodes.get(node_id)) + .cloned() + .collect(); + let updated_arrays = tx + .updated_arrays + .iter() + .flat_map(|node_id| nodes.get(node_id)) + .cloned() + .collect(); + let updated_chunks = tx + .updated_chunks + .into_iter() + .flat_map(|(node_id, chunks)| { + let path = nodes.get(&node_id).cloned()?; + Some((path, chunks)) + }) + .collect(); Self { new_groups, new_arrays, deleted_groups, deleted_arrays, - updated_user_attributes, - updated_zarr_metadata, + updated_groups, + updated_arrays, updated_chunks, } } - pub fn len(&self) -> usize { - self.new_groups.len() - + self.new_arrays.len() - + self.deleted_groups.len() - + self.deleted_arrays.len() - + self.updated_user_attributes.len() - + self.updated_zarr_metadata.len() - + self.updated_chunks.values().map(|s| s.len()).sum::() - } - #[must_use] pub fn is_empty(&self) -> bool { - self.len() == 0 + self.new_groups.is_empty() + && self.new_arrays.is_empty() + && self.deleted_groups.is_empty() + && self.deleted_arrays.is_empty() + && self.updated_groups.is_empty() + && self.updated_arrays.is_empty() + && self.updated_chunks.is_empty() } } diff --git a/icechunk/src/lib.rs b/icechunk/src/lib.rs index ef0ed78e..beaf4d40 100644 --- a/icechunk/src/lib.rs +++ b/icechunk/src/lib.rs @@ -21,8 +21,8 @@ pub mod asset_manager; pub mod change_set; pub mod config; pub mod conflicts; +pub mod error; pub mod format; -pub mod metadata; pub mod ops; pub mod refs; pub mod repository; @@ -46,3 +46,26 @@ mod private { /// See https://rust-lang.github.io/api-guidelines/future-proofing.html#sealed-traits-protect-against-downstream-implementations-c-sealed pub trait Sealed {} } + +#[cfg(feature = "logs")] +pub fn initialize_tracing() { + use tracing_error::ErrorLayer; + use tracing_subscriber::{ + layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer, Registry, + }; + + // We have two Layers. One keeps track of the spans to feed the ICError instances. + // The other is the one spitting logs to stdout. Filtering only applies to the second Layer. + + let stdout_layer = tracing_subscriber::fmt::layer() + .pretty() + .with_filter(EnvFilter::from_env("ICECHUNK_LOG")); + + let error_span_layer = ErrorLayer::default(); + + if let Err(err) = + Registry::default().with(error_span_layer).with(stdout_layer).try_init() + { + println!("Warning: {}", err); + } +} diff --git a/icechunk/src/metadata/data_type.rs b/icechunk/src/metadata/data_type.rs deleted file mode 100644 index 4349c5f2..00000000 --- a/icechunk/src/metadata/data_type.rs +++ /dev/null @@ -1,99 +0,0 @@ -use std::fmt::Display; - -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[non_exhaustive] -#[serde(rename_all = "lowercase")] -pub enum DataType { - Bool, - Int8, - Int16, - Int32, - Int64, - UInt8, - UInt16, - UInt32, - UInt64, - Float16, - Float32, - Float64, - Complex64, - Complex128, - String, - Bytes, -} - -impl DataType { - pub fn fits_i64(&self, n: i64) -> bool { - use DataType::*; - match self { - Int8 => n >= i8::MIN as i64 && n <= i8::MAX as i64, - Int16 => n >= i16::MIN as i64 && n <= i16::MAX as i64, - Int32 => n >= i32::MIN as i64 && n <= i32::MAX as i64, - Int64 => true, - _ => false, - } - } - - pub fn fits_u64(&self, n: u64) -> bool { - use DataType::*; - match self { - UInt8 => n >= u8::MIN as u64 && n <= u8::MAX as u64, - UInt16 => n >= u16::MIN as u64 && n <= u16::MAX as u64, - UInt32 => n >= u32::MIN as u64 && n <= u32::MAX as u64, - UInt64 => true, - _ => false, - } - } -} - -impl TryFrom<&str> for DataType { - type Error = &'static str; - - fn try_from(value: &str) -> Result { - match value { - "bool" => Ok(DataType::Bool), - "int8" => Ok(DataType::Int8), - "int16" => Ok(DataType::Int16), - "int32" => Ok(DataType::Int32), - "int64" => Ok(DataType::Int64), - "uint8" => Ok(DataType::UInt8), - "uint16" => Ok(DataType::UInt16), - "uint32" => Ok(DataType::UInt32), - "uint64" => Ok(DataType::UInt64), - "float16" => Ok(DataType::Float16), - "float32" => Ok(DataType::Float32), - "float64" => Ok(DataType::Float64), - "complex64" => Ok(DataType::Complex64), - "complex128" => Ok(DataType::Complex128), - "string" => Ok(DataType::String), - "bytes" => Ok(DataType::Bytes), - _ => Err("Unknown data type, cannot parse"), - } - } -} - -impl Display for DataType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - use DataType::*; - match self { - Bool => f.write_str("bool"), - Int8 => f.write_str("int8"), - Int16 => f.write_str("int16"), - Int32 => f.write_str("int32"), - Int64 => f.write_str("int64"), - UInt8 => f.write_str("uint8"), - UInt16 => f.write_str("uint16"), - UInt32 => f.write_str("uint32"), - UInt64 => f.write_str("uint64"), - Float16 => f.write_str("float16"), - Float32 => f.write_str("float32"), - Float64 => f.write_str("float64"), - Complex64 => f.write_str("complex64"), - Complex128 => f.write_str("complex128"), - String => f.write_str("string"), - Bytes => f.write_str("bytes"), - } - } -} diff --git a/icechunk/src/metadata/fill_value.rs b/icechunk/src/metadata/fill_value.rs deleted file mode 100644 index 31910248..00000000 --- a/icechunk/src/metadata/fill_value.rs +++ /dev/null @@ -1,280 +0,0 @@ -use itertools::Itertools; -use serde::{Deserialize, Serialize}; -use test_strategy::Arbitrary; - -use crate::format::IcechunkFormatError; - -use super::DataType; - -#[derive(Arbitrary, Clone, Debug, PartialEq, Serialize, Deserialize)] -pub enum FillValue { - // FIXME: test all json (de)serializations - Bool(bool), - Int8(i8), - Int16(i16), - Int32(i32), - Int64(i64), - UInt8(u8), - UInt16(u16), - UInt32(u32), - UInt64(u64), - Float16(f32), - Float32(f32), - Float64(f64), - Complex64(f32, f32), - Complex128(f64, f64), - String(String), - Bytes(Vec), -} - -impl FillValue { - pub const NAN_STR: &'static str = "NaN"; - pub const INF_STR: &'static str = "Infinity"; - pub const NEG_INF_STR: &'static str = "-Infinity"; - - pub fn from_data_type_and_json( - dt: &DataType, - value: &serde_json::Value, - ) -> Result { - #![allow(clippy::expect_used)] // before calling `as_foo` we check with `fits_foo` - match (dt, value) { - (DataType::Bool, serde_json::Value::Bool(b)) => Ok(FillValue::Bool(*b)), - (DataType::Int8, serde_json::Value::Number(n)) - if n.as_i64().map(|n| dt.fits_i64(n)) == Some(true) => - { - Ok(FillValue::Int8( - n.as_i64().expect("bug in from_data_type_and_json") as i8 - )) - } - (DataType::Int16, serde_json::Value::Number(n)) - if n.as_i64().map(|n| dt.fits_i64(n)) == Some(true) => - { - Ok(FillValue::Int16( - n.as_i64().expect("bug in from_data_type_and_json") as i16 - )) - } - (DataType::Int32, serde_json::Value::Number(n)) - if n.as_i64().map(|n| dt.fits_i64(n)) == Some(true) => - { - Ok(FillValue::Int32( - n.as_i64().expect("bug in from_data_type_and_json") as i32 - )) - } - (DataType::Int64, serde_json::Value::Number(n)) - if n.as_i64().map(|n| dt.fits_i64(n)) == Some(true) => - { - Ok(FillValue::Int64(n.as_i64().expect("bug in from_data_type_and_json"))) - } - (DataType::UInt8, serde_json::Value::Number(n)) - if n.as_u64().map(|n| dt.fits_u64(n)) == Some(true) => - { - Ok(FillValue::UInt8( - n.as_u64().expect("bug in from_data_type_and_json") as u8 - )) - } - (DataType::UInt16, serde_json::Value::Number(n)) - if n.as_u64().map(|n| dt.fits_u64(n)) == Some(true) => - { - Ok(FillValue::UInt16( - n.as_u64().expect("bug in from_data_type_and_json") as u16 - )) - } - (DataType::UInt32, serde_json::Value::Number(n)) - if n.as_u64().map(|n| dt.fits_u64(n)) == Some(true) => - { - Ok(FillValue::UInt32( - n.as_u64().expect("bug in from_data_type_and_json") as u32 - )) - } - (DataType::UInt64, serde_json::Value::Number(n)) - if n.as_u64().map(|n| dt.fits_u64(n)) == Some(true) => - { - Ok(FillValue::UInt64(n.as_u64().expect("bug in from_data_type_and_json"))) - } - (DataType::Float16, serde_json::Value::Number(n)) if n.as_f64().is_some() => { - // FIXME: limits logic - Ok(FillValue::Float16( - n.as_f64().expect("bug in from_data_type_and_json") as f32, - )) - } - (DataType::Float16, serde_json::Value::String(s)) - if s.as_str() == FillValue::NAN_STR => - { - Ok(FillValue::Float16(f32::NAN)) - } - (DataType::Float16, serde_json::Value::String(s)) - if s.as_str() == FillValue::INF_STR => - { - Ok(FillValue::Float16(f32::INFINITY)) - } - (DataType::Float16, serde_json::Value::String(s)) - if s.as_str() == FillValue::NEG_INF_STR => - { - Ok(FillValue::Float16(f32::NEG_INFINITY)) - } - - (DataType::Float32, serde_json::Value::Number(n)) if n.as_f64().is_some() => { - // FIXME: limits logic - Ok(FillValue::Float32( - n.as_f64().expect("bug in from_data_type_and_json") as f32, - )) - } - (DataType::Float32, serde_json::Value::String(s)) - if s.as_str() == FillValue::NAN_STR => - { - Ok(FillValue::Float32(f32::NAN)) - } - (DataType::Float32, serde_json::Value::String(s)) - if s.as_str() == FillValue::INF_STR => - { - Ok(FillValue::Float32(f32::INFINITY)) - } - (DataType::Float32, serde_json::Value::String(s)) - if s.as_str() == FillValue::NEG_INF_STR => - { - Ok(FillValue::Float32(f32::NEG_INFINITY)) - } - - (DataType::Float64, serde_json::Value::Number(n)) if n.as_f64().is_some() => { - // FIXME: limits logic - Ok(FillValue::Float64( - n.as_f64().expect("bug in from_data_type_and_json"), - )) - } - (DataType::Float64, serde_json::Value::String(s)) - if s.as_str() == FillValue::NAN_STR => - { - Ok(FillValue::Float64(f64::NAN)) - } - (DataType::Float64, serde_json::Value::String(s)) - if s.as_str() == FillValue::INF_STR => - { - Ok(FillValue::Float64(f64::INFINITY)) - } - (DataType::Float64, serde_json::Value::String(s)) - if s.as_str() == FillValue::NEG_INF_STR => - { - Ok(FillValue::Float64(f64::NEG_INFINITY)) - } - (DataType::Complex64, serde_json::Value::Array(arr)) if arr.len() == 2 => { - let r = FillValue::from_data_type_and_json(&DataType::Float32, &arr[0])?; - let i = FillValue::from_data_type_and_json(&DataType::Float32, &arr[1])?; - match (r, i) { - (FillValue::Float32(r), FillValue::Float32(i)) => { - Ok(FillValue::Complex64(r, i)) - } - _ => Err(IcechunkFormatError::FillValueParse { - data_type: dt.clone(), - value: value.clone(), - }), - } - } - (DataType::Complex128, serde_json::Value::Array(arr)) if arr.len() == 2 => { - let r = FillValue::from_data_type_and_json(&DataType::Float64, &arr[0])?; - let i = FillValue::from_data_type_and_json(&DataType::Float64, &arr[1])?; - match (r, i) { - (FillValue::Float64(r), FillValue::Float64(i)) => { - Ok(FillValue::Complex128(r, i)) - } - _ => Err(IcechunkFormatError::FillValueParse { - data_type: dt.clone(), - value: value.clone(), - }), - } - } - - (DataType::String, serde_json::Value::String(s)) => { - Ok(FillValue::String(s.clone())) - } - - (DataType::Bytes, serde_json::Value::Array(arr)) => { - let bytes = arr - .iter() - .map(|b| FillValue::from_data_type_and_json(&DataType::UInt8, b)) - .collect::, _>>()?; - Ok(FillValue::Bytes( - bytes - .iter() - .map(|b| match b { - FillValue::UInt8(n) => Ok(*n), - _ => Err(IcechunkFormatError::FillValueParse { - data_type: dt.clone(), - value: value.clone(), - }), - }) - .try_collect()?, - )) - } - - _ => Err(IcechunkFormatError::FillValueParse { - data_type: dt.clone(), - value: value.clone(), - }), - } - } - - pub fn get_data_type(&self) -> DataType { - match self { - FillValue::Bool(_) => DataType::Bool, - FillValue::Int8(_) => DataType::Int8, - FillValue::Int16(_) => DataType::Int16, - FillValue::Int32(_) => DataType::Int32, - FillValue::Int64(_) => DataType::Int64, - FillValue::UInt8(_) => DataType::UInt8, - FillValue::UInt16(_) => DataType::UInt16, - FillValue::UInt32(_) => DataType::UInt32, - FillValue::UInt64(_) => DataType::UInt64, - FillValue::Float16(_) => DataType::Float16, - FillValue::Float32(_) => DataType::Float32, - FillValue::Float64(_) => DataType::Float64, - FillValue::Complex64(_, _) => DataType::Complex64, - FillValue::Complex128(_, _) => DataType::Complex128, - FillValue::String(_) => DataType::String, - FillValue::Bytes(_) => DataType::Bytes, - } - } -} - -#[cfg(test)] -#[allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)] -mod tests { - use super::*; - - #[test] - fn test_nan_inf_parsing() { - assert_eq!( - FillValue::from_data_type_and_json(&DataType::Float64, &55f64.into()) - .unwrap(), - FillValue::Float64(55.0) - ); - - assert_eq!( - FillValue::from_data_type_and_json(&DataType::Float64, &55.into()).unwrap(), - FillValue::Float64(55.0) - ); - - assert!(matches!(FillValue::from_data_type_and_json( - &DataType::Float64, - &"NaN".into() - ) - .unwrap(), - FillValue::Float64(n) if n.is_nan() - )); - - assert!(matches!(FillValue::from_data_type_and_json( - &DataType::Float64, - &"Infinity".into() - ) - .unwrap(), - FillValue::Float64(n) if n == f64::INFINITY - )); - - assert!(matches!(FillValue::from_data_type_and_json( - &DataType::Float64, - &"-Infinity".into() - ) - .unwrap(), - FillValue::Float64(n) if n == f64::NEG_INFINITY - )); - } -} diff --git a/icechunk/src/metadata/mod.rs b/icechunk/src/metadata/mod.rs deleted file mode 100644 index 4735b527..00000000 --- a/icechunk/src/metadata/mod.rs +++ /dev/null @@ -1,83 +0,0 @@ -use std::{collections::HashMap, num::NonZeroU64}; - -use bytes::Bytes; -use serde::{Deserialize, Serialize}; -use test_strategy::Arbitrary; - -pub mod data_type; -pub mod fill_value; - -pub use data_type::DataType; -pub use fill_value::FillValue; - -/// The shape of an array. -/// 0 is a valid shape member -pub type ArrayShape = Vec; -// each dimension name can be null in Zarr -pub type DimensionName = Option; -pub type DimensionNames = Vec; - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct Codec { - pub name: String, - pub configuration: Option>, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct StorageTransformer { - pub name: String, - pub configuration: Option>, -} - -#[derive(Clone, Eq, PartialEq, Debug, Serialize, Deserialize)] -pub struct ChunkShape(pub Vec); - -#[derive(Arbitrary, Copy, Clone, Eq, PartialEq, Debug, Serialize, Deserialize)] -pub enum ChunkKeyEncoding { - Slash, - Dot, - Default, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct UserAttributes { - #[serde(flatten)] - pub parsed: serde_json::Value, -} - -impl UserAttributes { - pub fn try_new(json: &[u8]) -> Result { - serde_json::from_slice(json).map(|json| UserAttributes { parsed: json }) - } - - pub fn to_bytes(&self) -> Bytes { - // We can unwrap because a Value is always valid json - #[allow(clippy::expect_used)] - serde_json::to_vec(&self.parsed) - .expect("Bug in UserAttributes serialization") - .into() - } -} - -impl TryFrom for ChunkKeyEncoding { - type Error = &'static str; - - fn try_from(value: u8) -> Result { - match value { - b'/' => Ok(ChunkKeyEncoding::Slash), - b'.' => Ok(ChunkKeyEncoding::Dot), - b'x' => Ok(ChunkKeyEncoding::Default), - _ => Err("Invalid chunk key encoding character"), - } - } -} - -impl From for u8 { - fn from(value: ChunkKeyEncoding) -> Self { - match value { - ChunkKeyEncoding::Slash => b'/', - ChunkKeyEncoding::Dot => b'.', - ChunkKeyEncoding::Default => b'x', - } - } -} diff --git a/icechunk/src/ops/gc.rs b/icechunk/src/ops/gc.rs index 3ff0f7b7..73bbc422 100644 --- a/icechunk/src/ops/gc.rs +++ b/icechunk/src/ops/gc.rs @@ -7,9 +7,10 @@ use tokio::pin; use crate::{ asset_manager::AssetManager, format::{ - manifest::ChunkPayload, ChunkId, IcechunkFormatError, ManifestId, SnapshotId, + manifest::ChunkPayload, ChunkId, IcechunkFormatError, IcechunkFormatErrorKind, + ManifestId, SnapshotId, }, - refs::{list_refs, Ref, RefError}, + refs::{delete_branch, delete_tag, list_refs, Ref, RefError}, repository::RepositoryError, storage::{self, ListInfo}, Storage, StorageError, @@ -181,23 +182,33 @@ pub async fn garbage_collect( } if config.deletes_manifests() { - keep_manifests.extend(snap.manifest_files().keys().cloned()); + keep_manifests.extend(snap.manifest_files().map(|mf| mf.id)); } if config.deletes_chunks() { - for manifest_id in snap.manifest_files().keys() { - let manifest_info = snap.manifest_info(manifest_id).ok_or_else(|| { - IcechunkFormatError::ManifestInfoNotFound { - manifest_id: manifest_id.clone(), - } - })?; + for manifest_id in snap.manifest_files().map(|mf| mf.id) { + let manifest_info = + snap.manifest_info(&manifest_id).ok_or_else(|| { + IcechunkFormatError::from( + IcechunkFormatErrorKind::ManifestInfoNotFound { + manifest_id: manifest_id.clone(), + }, + ) + })?; let manifest = asset_manager - .fetch_manifest(manifest_id, manifest_info.size_bytes) + .fetch_manifest(&manifest_id, manifest_info.size_bytes) .await?; let chunk_ids = manifest.chunk_payloads().filter_map(|payload| match payload { - ChunkPayload::Ref(chunk_ref) => Some(chunk_ref.id.clone()), - _ => None, + Ok(ChunkPayload::Ref(chunk_ref)) => Some(chunk_ref.id.clone()), + Ok(_) => None, + Err(err) => { + tracing::error!( + error = %err, + "Error in chunk payload iterator" + ); + None + } }); keep_chunks.extend(chunk_ids); } @@ -257,7 +268,7 @@ async fn pointed_snapshots<'a>( async move { let snap = asset_manager.fetch_snapshot(&snap_id).await?; let parents = Arc::clone(&asset_manager) - .snapshot_ancestry(snap.id()) + .snapshot_ancestry(&snap.id()) .await? .map_ok(|parent| parent.id) .err_into(); @@ -363,6 +374,13 @@ async fn gc_transaction_logs( Ok(storage.delete_transaction_logs(storage_settings, to_delete).await?) } +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum ExpireRefResult { + RefIsExpired, + NothingToDo, + Done { released_snapshots: HashSet, edited_snapshot: SnapshotId }, +} + /// Expire snapshots older than a threshold. /// /// This only processes snapshots found by navigating `reference` @@ -388,7 +406,7 @@ pub async fn expire_ref( asset_manager: Arc, reference: &Ref, older_than: DateTime, -) -> GCResult> { +) -> GCResult { let snap_id = reference .fetch(storage, storage_settings) .await @@ -411,7 +429,7 @@ pub async fn expire_ref( // If we point to an expired snapshot already, there is nothing to do if let Some(Ok(info)) = ancestry.as_mut().peek().await { if info.flushed_at < older_than { - return Ok(HashSet::new()); + return Ok(ExpireRefResult::RefIsExpired); } } @@ -430,18 +448,34 @@ pub async fn expire_ref( let editable_snap = asset_manager.fetch_snapshot(&editable_snap).await?; let parent_id = editable_snap.parent_id(); - if editable_snap.id() == &root || Some(&root) == parent_id.as_ref() { + if editable_snap.id() == root || Some(&root) == parent_id.as_ref() { // Either the reference is the root, or it is pointing to the root as first parent // Nothing to do - return Ok(released); + return Ok(ExpireRefResult::NothingToDo); } let root = asset_manager.fetch_snapshot(&root).await?; // TODO: add properties to the snapshot that tell us it was history edited - let new_snapshot = Arc::new(editable_snap.adopt(root.as_ref())); + let new_snapshot = Arc::new(root.adopt(&editable_snap)?); asset_manager.write_snapshot(new_snapshot).await?; - Ok(released) + Ok(ExpireRefResult::Done { + released_snapshots: released, + edited_snapshot: editable_snap.id().clone(), + }) +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum ExpiredRefAction { + Delete, + Ignore, +} + +#[derive(Debug, PartialEq, Eq, Clone, Default)] +pub struct ExpireResult { + pub released_snapshots: HashSet, + pub edited_snapshots: HashSet, + pub deleted_refs: HashSet, } /// Expire all snapshots older than a threshold. @@ -457,8 +491,7 @@ pub async fn expire_ref( /// passed `asset_manager` is invalidated here, but other caches /// may exist, for example, in [`Repository`] instances. /// -/// Returns the ids of all snapshots considered expired and skipped -/// from history. Notice that this snapshot are not necessarily +/// Notice that the snapshot returned as released, are not necessarily /// available for garbage collection, they could still be pointed by /// ether refs. /// @@ -468,7 +501,9 @@ pub async fn expire( storage_settings: &storage::Settings, asset_manager: Arc, older_than: DateTime, -) -> GCResult> { + expired_branches: ExpiredRefAction, + expired_tags: ExpiredRefAction, +) -> GCResult { let all_refs = stream::iter(list_refs(storage, storage_settings).await?); let asset_manager = Arc::clone(&asset_manager.clone()); @@ -476,15 +511,43 @@ pub async fn expire( .then(move |r| { let asset_manager = asset_manager.clone(); async move { - let released_snaps = + let ref_result = expire_ref(storage, storage_settings, asset_manager, &r, older_than) .await?; - Ok::, GCError>(released_snaps) + Ok::<(Ref, ExpireRefResult), GCError>((r, ref_result)) } }) - .try_fold(HashSet::new(), |mut accum, new_set| async move { - accum.extend(new_set); - Ok(accum) + .try_fold(ExpireResult::default(), |mut result, (r, ref_result)| async move { + match ref_result { + ExpireRefResult::Done { released_snapshots, edited_snapshot } => { + result.released_snapshots.extend(released_snapshots.into_iter()); + result.edited_snapshots.insert(edited_snapshot); + Ok(result) + } + ExpireRefResult::RefIsExpired => match &r { + Ref::Tag(name) => { + if expired_tags == ExpiredRefAction::Delete { + delete_tag(storage, storage_settings, name.as_str()) + .await + .map_err(GCError::Ref)?; + result.deleted_refs.insert(r); + } + Ok(result) + } + Ref::Branch(name) => { + if expired_branches == ExpiredRefAction::Delete + && name != Ref::DEFAULT_BRANCH + { + delete_branch(storage, storage_settings, name.as_str()) + .await + .map_err(GCError::Ref)?; + result.deleted_refs.insert(r); + } + Ok(result) + } + }, + ExpireRefResult::NothingToDo => Ok(result), + } }) .await } diff --git a/icechunk/src/refs.rs b/icechunk/src/refs.rs index 96631084..e1309374 100644 --- a/icechunk/src/refs.rs +++ b/icechunk/src/refs.rs @@ -8,31 +8,25 @@ use async_recursion::async_recursion; use bytes::Bytes; use futures::{ stream::{FuturesOrdered, FuturesUnordered}, - FutureExt, Stream, StreamExt, TryStreamExt, + FutureExt, StreamExt, }; use itertools::Itertools; use serde::{Deserialize, Serialize}; use serde_with::{serde_as, TryFromInto}; use thiserror::Error; +use tracing::instrument; -use crate::{format::SnapshotId, storage, Storage, StorageError}; - -fn crock_encode_int(n: u64) -> String { - // skip the first 3 bytes (zeroes) - base32::encode(base32::Alphabet::Crockford, &n.to_be_bytes()[3..=7]) -} - -fn crock_decode_int(data: &str) -> Option { - // re insert the first 3 bytes removed during encoding - let mut bytes = vec![0, 0, 0]; - bytes.extend(base32::decode(base32::Alphabet::Crockford, data)?); - Some(u64::from_be_bytes(bytes.as_slice().try_into().ok()?)) -} +use crate::{ + error::ICError, + format::SnapshotId, + storage::{self, GetRefResult, StorageErrorKind, VersionInfo, WriteRefResult}, + Storage, StorageError, +}; #[derive(Debug, Error)] -pub enum RefError { - #[error("storage error `{0:?}`")] - Storage(#[from] StorageError), +pub enum RefErrorKind { + #[error(transparent)] + Storage(StorageErrorKind), #[error("ref not found `{0}`")] RefNotFound(String), @@ -43,19 +37,35 @@ pub enum RefError { #[error("invalid ref name `{0}`")] InvalidRefName(String), - #[error("invalid branch version `{0}`")] - InvalidBranchVersion(String), - #[error("tag already exists, tags are immutable: `{0}`")] TagAlreadyExists(String), - #[error("cannot serialize ref json: `{0}`")] + #[error("cannot serialize ref json")] Serialization(#[from] serde_json::Error), #[error("branch update conflict: `({expected_parent:?}) != ({actual_parent:?})`")] Conflict { expected_parent: Option, actual_parent: Option }, } +pub type RefError = ICError; + +// it would be great to define this impl in error.rs, but it conflicts with the blanket +// `impl From for T` +impl From for RefError +where + E: Into, +{ + fn from(value: E) -> Self { + Self::new(value.into()) + } +} + +impl From for RefError { + fn from(value: StorageError) -> Self { + Self::with_context(RefErrorKind::Storage(value.kind), value.context) + } +} + pub type RefResult = Result; #[derive(Debug, Clone, Eq, PartialEq, Hash, PartialOrd, Ord)] @@ -72,7 +82,7 @@ impl Ref { Some(name) => Ok(Ref::Tag(name.to_string())), None => match path.strip_prefix("branch.") { Some(name) => Ok(Ref::Branch(name.to_string())), - None => Err(RefError::InvalidRefType(path.to_string())), + None => Err(RefErrorKind::InvalidRefType(path.to_string()).into()), }, } } @@ -103,35 +113,6 @@ impl Ref { } } -#[derive(Debug, Clone, Eq, PartialEq)] -pub struct BranchVersion(pub u64); - -impl BranchVersion { - const MAX_VERSION_NUMBER: u64 = 1099511627775; - - fn decode(version: &str) -> RefResult { - let n = crock_decode_int(version) - .ok_or(RefError::InvalidBranchVersion(version.to_string()))?; - Ok(BranchVersion(BranchVersion::MAX_VERSION_NUMBER - n)) - } - - fn encode(&self) -> String { - crock_encode_int(BranchVersion::MAX_VERSION_NUMBER - self.0) - } - - fn to_path(&self, branch_name: &str) -> RefResult { - branch_key(branch_name, self.encode().as_str()) - } - - fn initial() -> Self { - Self(0) - } - - fn inc(&self) -> Self { - Self(self.0 + 1) - } -} - #[serde_as] #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub struct RefData { @@ -139,107 +120,101 @@ pub struct RefData { pub snapshot: SnapshotId, } -const TAG_KEY_NAME: &str = "ref.json"; +const REF_KEY_NAME: &str = "ref.json"; const TAG_DELETE_MARKER_KEY_NAME: &str = "ref.json.deleted"; fn tag_key(tag_name: &str) -> RefResult { if tag_name.contains('/') { - return Err(RefError::InvalidRefName(tag_name.to_string())); + return Err(RefErrorKind::InvalidRefName(tag_name.to_string()).into()); } - Ok(format!("tag.{}/{}", tag_name, TAG_KEY_NAME)) + Ok(format!("tag.{}/{}", tag_name, REF_KEY_NAME)) } fn tag_delete_marker_key(tag_name: &str) -> RefResult { if tag_name.contains('/') { - return Err(RefError::InvalidRefName(tag_name.to_string())); + return Err(RefErrorKind::InvalidRefName(tag_name.to_string()).into()); } Ok(format!("tag.{}/{}", tag_name, TAG_DELETE_MARKER_KEY_NAME)) } -fn branch_root(branch_name: &str) -> RefResult { +fn branch_key(branch_name: &str) -> RefResult { if branch_name.contains('/') { - return Err(RefError::InvalidRefName(branch_name.to_string())); + return Err(RefErrorKind::InvalidRefName(branch_name.to_string()).into()); } - Ok(format!("branch.{}", branch_name)) -} - -fn branch_key(branch_name: &str, version_id: &str) -> RefResult { - branch_root(branch_name).map(|root| format!("{}/{}.json", root, version_id)) + Ok(format!("branch.{}/{}", branch_name, REF_KEY_NAME)) } +#[instrument(skip(storage, storage_settings))] pub async fn create_tag( storage: &(dyn Storage + Send + Sync), storage_settings: &storage::Settings, name: &str, snapshot: SnapshotId, - overwrite_refs: bool, ) -> RefResult<()> { let key = tag_key(name)?; let data = RefData { snapshot }; let content = serde_json::to_vec(&data)?; - storage + match storage .write_ref( storage_settings, key.as_str(), - overwrite_refs, Bytes::copy_from_slice(&content), + &VersionInfo::for_creation(), ) .await - .map_err(|e| match e { - StorageError::RefAlreadyExists(_) => { - RefError::TagAlreadyExists(name.to_string()) - } - err => err.into(), - })?; - Ok(()) + { + Ok(WriteRefResult::Written) => Ok(()), + Ok(WriteRefResult::WontOverwrite) => { + Err(RefErrorKind::TagAlreadyExists(name.to_string()).into()) + } + Err(err) => Err(err.into()), + } } #[async_recursion] +#[instrument(skip(storage, storage_settings))] pub async fn update_branch( storage: &(dyn Storage + Send + Sync), storage_settings: &storage::Settings, name: &str, new_snapshot: SnapshotId, current_snapshot: Option<&SnapshotId>, - overwrite_refs: bool, -) -> RefResult { - let last_version = last_branch_version(storage, storage_settings, name).await; - let last_ref_data = match last_version { - Ok(version) => fetch_branch(storage, storage_settings, name, &version) - .await - .map(|d| Some((version, d))), - Err(RefError::RefNotFound(_)) => Ok(None), - Err(err) => Err(err), - }?; - let last_snapshot = last_ref_data.as_ref().map(|d| &d.1.snapshot); - if last_snapshot != current_snapshot { - return Err(RefError::Conflict { +) -> RefResult<()> { + let (ref_data, version) = match fetch_branch(storage, storage_settings, name).await { + Ok((ref_data, version)) => (Some(ref_data), version), + Err(RefError { kind: RefErrorKind::RefNotFound(..), .. }) => { + (None, VersionInfo::for_creation()) + } + Err(err) => { + return Err(err); + } + }; + + if ref_data.as_ref().map(|rd| &rd.snapshot) != current_snapshot { + return Err(RefErrorKind::Conflict { expected_parent: current_snapshot.cloned(), - actual_parent: last_snapshot.cloned(), - }); + actual_parent: ref_data.map(|rd| rd.snapshot), + } + .into()); } - let new_version = match last_ref_data { - Some((version, _)) => version.inc(), - None => BranchVersion::initial(), - }; - let key = new_version.to_path(name)?; + let key = branch_key(name)?; let data = RefData { snapshot: new_snapshot }; let content = serde_json::to_vec(&data)?; match storage .write_ref( storage_settings, key.as_str(), - overwrite_refs, Bytes::copy_from_slice(&content), + &version, ) .await { - Ok(_) => Ok(new_version), - Err(StorageError::RefAlreadyExists(_)) => { - // If the branch version already exists, an update happened since we checked + Ok(WriteRefResult::Written) => Ok(()), + Ok(WriteRefResult::WontOverwrite) => { + // If the already exists, an update happened since we checked // we can just try again and the conflict will be reported update_branch( storage, @@ -247,14 +222,14 @@ pub async fn update_branch( name, data.snapshot, current_snapshot, - overwrite_refs, ) .await } - Err(err) => Err(RefError::Storage(err)), + Err(err) => Err(err.into()), } } +#[instrument(skip(storage, storage_settings))] pub async fn list_refs( storage: &(dyn Storage + Send + Sync), storage_settings: &storage::Settings, @@ -316,75 +291,49 @@ pub async fn list_branches( Ok(branches) } -async fn branch_history<'a>( - storage: &'a (dyn Storage + Send + Sync), - storage_settings: &storage::Settings, - branch: &str, -) -> RefResult> + 'a> { - let key = branch_root(branch)?; - let all = storage.ref_versions(storage_settings, key.as_str()).await?; - Ok(all.map_err(|e| e.into()).and_then(move |version_id| async move { - let version = version_id - .strip_suffix(".json") - .ok_or(RefError::InvalidRefName(version_id.clone()))?; - BranchVersion::decode(version) - })) -} - -async fn last_branch_version( - storage: &(dyn Storage + Send + Sync), - storage_settings: &storage::Settings, - branch: &str, -) -> RefResult { - // TODO! optimize - let mut all = Box::pin(branch_history(storage, storage_settings, branch).await?); - all.try_next().await?.ok_or(RefError::RefNotFound(branch.to_string())) -} - +#[instrument(skip(storage, storage_settings))] pub async fn delete_branch( storage: &(dyn Storage + Send + Sync), storage_settings: &storage::Settings, branch: &str, ) -> RefResult<()> { - let key = branch_root(branch)?; - let key_ref = key.as_str(); - let refs = storage - .ref_versions(storage_settings, key_ref) - .await? - .filter_map(|v| async move { - v.ok().map(|v| format!("{}/{}", key_ref, v).as_str().to_string()) - }) - .boxed(); - storage.delete_refs(storage_settings, refs).await?; + // we make sure the branch exists + _ = fetch_branch_tip(storage, storage_settings, branch).await?; + + let key = branch_key(branch)?; + storage.delete_refs(storage_settings, futures::stream::iter([key]).boxed()).await?; Ok(()) } +#[instrument(skip(storage, storage_settings))] pub async fn delete_tag( storage: &(dyn Storage + Send + Sync), storage_settings: &storage::Settings, tag: &str, - overwrite_refs: bool, ) -> RefResult<()> { // we make sure the tag exists _ = fetch_tag(storage, storage_settings, tag).await?; // no race condition: delete_tag ^ 2 = delete_tag let key = tag_delete_marker_key(tag)?; - storage + match storage .write_ref( storage_settings, key.as_str(), - overwrite_refs, Bytes::from_static(&[]), + &VersionInfo::for_creation(), ) .await - .map_err(|e| match e { - StorageError::RefAlreadyExists(_) => RefError::RefNotFound(tag.to_string()), - err => err.into(), - })?; - Ok(()) + { + Ok(WriteRefResult::Written) => Ok(()), + Ok(WriteRefResult::WontOverwrite) => { + Err(RefErrorKind::RefNotFound(tag.to_string()).into()) + } + Err(err) => Err(err.into()), + } } +#[instrument(skip(storage, storage_settings))] pub async fn fetch_tag( storage: &(dyn Storage + Send + Sync), storage_settings: &storage::Settings, @@ -395,9 +344,9 @@ pub async fn fetch_tag( let fut1: Pin>>> = async move { match storage.get_ref(storage_settings, ref_path.as_str()).await { - Ok(data) => Ok(data), - Err(StorageError::RefNotFound(..)) => { - Err(RefError::RefNotFound(name.to_string())) + Ok(GetRefResult::Found { bytes, .. }) => Ok(bytes), + Ok(GetRefResult::NotFound) => { + Err(RefErrorKind::RefNotFound(name.to_string()).into()) } Err(err) => Err(err.into()), } @@ -405,9 +354,9 @@ pub async fn fetch_tag( .boxed(); let fut2 = async move { match storage.get_ref(storage_settings, delete_marker_path.as_str()).await { - Ok(_) => Ok(Bytes::new()), - Err(StorageError::RefNotFound(..)) => { - Err(RefError::RefNotFound(name.to_string())) + Ok(GetRefResult::Found { .. }) => Ok(Bytes::new()), + Ok(GetRefResult::NotFound) => { + Err(RefErrorKind::RefNotFound(name.to_string()).into()) } Err(err) => Err(err.into()), } @@ -421,62 +370,50 @@ pub async fn fetch_tag( .next_tuple() { match is_deleted { - Ok(_) => Err(RefError::RefNotFound(name.to_string())), - Err(RefError::RefNotFound(_)) => { + Ok(_) => Err(RefErrorKind::RefNotFound(name.to_string()).into()), + Err(RefError { kind: RefErrorKind::RefNotFound(_), .. }) => { let data = serde_json::from_slice(content?.as_ref())?; Ok(data) } Err(err) => Err(err), } } else { - Err(RefError::RefNotFound(name.to_string())) + Err(RefErrorKind::RefNotFound(name.to_string()).into()) } } +#[instrument(skip(storage, storage_settings))] async fn fetch_branch( storage: &(dyn Storage + Send + Sync), storage_settings: &storage::Settings, name: &str, - version: &BranchVersion, -) -> RefResult { - let path = version.to_path(name)?; - match storage.get_ref(storage_settings, path.as_str()).await { - Ok(data) => Ok(serde_json::from_slice(data.as_ref())?), - Err(StorageError::RefNotFound(..)) => { - Err(RefError::RefNotFound(name.to_string())) +) -> RefResult<(RefData, VersionInfo)> { + let ref_key = branch_key(name)?; + match storage.get_ref(storage_settings, ref_key.as_str()).await { + Ok(GetRefResult::Found { bytes, version }) => { + let data = serde_json::from_slice(bytes.as_ref())?; + Ok((data, version)) + } + Ok(GetRefResult::NotFound) => { + Err(RefErrorKind::RefNotFound(name.to_string()).into()) } Err(err) => Err(err.into()), } } +#[instrument(skip(storage, storage_settings))] pub async fn fetch_branch_tip( storage: &(dyn Storage + Send + Sync), storage_settings: &storage::Settings, name: &str, ) -> RefResult { - let version = last_branch_version(storage, storage_settings, name).await?; - fetch_branch(storage, storage_settings, name, &version).await -} - -pub async fn fetch_ref( - storage: &(dyn Storage + Send + Sync), - storage_settings: &storage::Settings, - ref_name: &str, -) -> RefResult<(Ref, RefData)> { - match fetch_tag(storage, storage_settings, ref_name).await { - Ok(from_ref) => Ok((Ref::Tag(ref_name.to_string()), from_ref)), - Err(RefError::RefNotFound(_)) => { - let data = fetch_branch_tip(storage, storage_settings, ref_name).await?; - Ok((Ref::Branch(ref_name.to_string()), data)) - } - Err(err) => Err(err), - } + Ok(fetch_branch(storage, storage_settings, name).await?.0) } #[cfg(test)] #[allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)] mod tests { - use std::{iter::once, sync::Arc}; + use std::sync::Arc; use futures::Future; use pretty_assertions::assert_eq; @@ -486,31 +423,6 @@ mod tests { use super::*; - #[tokio::test] - async fn test_branch_version_encoding() -> Result<(), Box> { - let targets = (0..10u64).chain(once(BranchVersion::MAX_VERSION_NUMBER)); - let encodings = [ - "ZZZZZZZZ", "ZZZZZZZY", "ZZZZZZZX", "ZZZZZZZW", "ZZZZZZZV", - // no U - "ZZZZZZZT", "ZZZZZZZS", "ZZZZZZZR", "ZZZZZZZQ", "ZZZZZZZP", - ]; - - for n in targets { - let encoded = BranchVersion(n).encode(); - - if n < 100 { - assert_eq!(encoded, encodings[n as usize]); - } - if n == BranchVersion::MAX_VERSION_NUMBER { - assert_eq!(encoded, "00000000"); - } - - let round = BranchVersion::decode(encoded.as_str())?; - assert_eq!(round, BranchVersion(n)); - } - Ok(()) - } - /// Execute the passed block with all test implementations of Storage. /// /// Currently this function executes against the in-memory and local filesystem object_store @@ -522,13 +434,16 @@ mod tests { >( mut f: F, ) -> ((Arc, R), (Arc, R, TempDir)) { - let mem_storage = new_in_memory_storage().unwrap(); + let mem_storage = new_in_memory_storage().await.unwrap(); + println!("Using mem storage"); let res1 = f(Arc::clone(&mem_storage) as Arc).await; let dir = tempdir().expect("cannot create temp dir"); let local_storage = new_local_filesystem_storage(dir.path()) + .await .expect("Cannot create local Storage"); + println!("Using local file system storage"); let res2 = f(Arc::clone(&local_storage) as Arc).await; ((mem_storage, res1), (local_storage, res2, dir)) } @@ -541,28 +456,18 @@ mod tests { let s2 = SnapshotId::random(); let res = fetch_tag(storage.as_ref(), &storage_settings, "tag1").await; - assert!(matches!(res, Err(RefError::RefNotFound(name)) if name == *"tag1")); + assert!(matches!(res, Err(RefError{kind: RefErrorKind::RefNotFound(name),..}) if name == "tag1")); assert_eq!(list_refs(storage.as_ref(), &storage_settings).await?, BTreeSet::new()); - create_tag(storage.as_ref(), &storage_settings, "tag1", s1.clone(), false).await?; - create_tag(storage.as_ref(), &storage_settings, "tag2", s2.clone(), false).await?; + create_tag(storage.as_ref(), &storage_settings, "tag1", s1.clone()).await?; + create_tag(storage.as_ref(), &storage_settings, "tag2", s2.clone()).await?; let res = fetch_tag(storage.as_ref(), &storage_settings, "tag1").await?; assert_eq!(res.snapshot, s1); - assert_eq!( - fetch_tag(storage.as_ref(), &storage_settings, "tag1").await?, - fetch_ref(storage.as_ref(), &storage_settings, "tag1").await?.1 - ); - let res = fetch_tag(storage.as_ref(), &storage_settings, "tag2").await?; assert_eq!(res.snapshot, s2); - assert_eq!( - fetch_tag(storage.as_ref(), &storage_settings, "tag2").await?, - fetch_ref(storage.as_ref(), &storage_settings, "tag2").await?.1 - ); - assert_eq!( list_refs(storage.as_ref(), &storage_settings).await?, BTreeSet::from([Ref::Tag("tag1".to_string()), Ref::Tag("tag2".to_string())]) @@ -570,8 +475,8 @@ mod tests { // attempts to recreate a tag fail assert!(matches!( - create_tag(storage.as_ref(), &storage_settings, "tag1", s1.clone(), false).await, - Err(RefError::TagAlreadyExists(name)) if name == *"tag1" + create_tag(storage.as_ref(), &storage_settings, "tag1", s1.clone()).await, + Err(RefError{kind: RefErrorKind::TagAlreadyExists(name), ..}) if name == "tag1" )); assert_eq!( list_refs(storage.as_ref(), &storage_settings).await?, @@ -580,7 +485,7 @@ mod tests { // attempting to create a branch that doesn't exist, with a fake parent let res = - update_branch(storage.as_ref(), &storage_settings, "branch0", s1.clone(), Some(&s2), false) + update_branch(storage.as_ref(), &storage_settings, "branch0", s1.clone(), Some(&s2)) .await; assert!(res.is_err()); assert_eq!( @@ -589,34 +494,21 @@ mod tests { ); // create a branch successfully - update_branch(storage.as_ref(), &storage_settings, "branch1", s1.clone(), None, false).await?; + update_branch(storage.as_ref(), &storage_settings, "branch1", s1.clone(), None).await?; + assert_eq!( - branch_history(storage.as_ref(), &storage_settings, "branch1") - .await? - .try_collect::>() - .await?, - vec![BranchVersion(0)] - ); - assert_eq!( - last_branch_version(storage.as_ref(), &storage_settings, "branch1").await?, - BranchVersion(0) - ); - assert_eq!( - fetch_branch(storage.as_ref(), &storage_settings, "branch1", &BranchVersion(0)).await?, + fetch_branch_tip(storage.as_ref(), &storage_settings, "branch1").await?, RefData { snapshot: s1.clone() } ); - assert_eq!( - fetch_branch(storage.as_ref(), &storage_settings, "branch1", &BranchVersion(0)).await?, - fetch_ref(storage.as_ref(), &storage_settings, "branch1").await?.1 - ); + assert_eq!( list_refs(storage.as_ref(), &storage_settings).await?, BTreeSet::from([ Ref::Branch("branch1".to_string()), - Ref::Tag("tag1".to_string()), - Ref::Tag("tag2".to_string()) + Ref::Tag("tag1".to_string()), + Ref::Tag("tag2".to_string()) ]) ); @@ -626,73 +518,39 @@ mod tests { "branch1", s2.clone(), Some(&s1.clone()), - false, ) .await?; assert_eq!( - branch_history(storage.as_ref(), &storage_settings, "branch1") - .await? - .try_collect::>() - .await?, - vec![BranchVersion(1), BranchVersion(0)] - ); - assert_eq!( - last_branch_version(storage.as_ref(), &storage_settings, "branch1").await?, - BranchVersion(1) - ); - - assert_eq!( - fetch_branch(storage.as_ref(), &storage_settings, "branch1", &BranchVersion(1)).await?, + fetch_branch_tip(storage.as_ref(), &storage_settings, "branch1").await?, RefData { snapshot: s2.clone() } ); - assert_eq!( - fetch_branch(storage.as_ref(), &storage_settings, "branch1", &BranchVersion(1)).await?, - fetch_ref(storage.as_ref(), &storage_settings, "branch1").await?.1 - ); - let sid = SnapshotId::random(); // update a branch with the wrong parent let res = - update_branch(storage.as_ref(), &storage_settings, "branch1", sid.clone(), Some(&s1), false) + update_branch(storage.as_ref(), &storage_settings, "branch1", sid.clone(), Some(&s1)) .await; assert!(matches!(res, - Err(RefError::Conflict { expected_parent, actual_parent }) + Err(RefError{kind: RefErrorKind::Conflict { expected_parent, actual_parent }, ..}) if expected_parent == Some(s1.clone()) && actual_parent == Some(s2.clone()) )); // update the branch again but now with the right parent - update_branch(storage.as_ref(), &storage_settings, "branch1", sid.clone(), Some(&s2), false) + update_branch(storage.as_ref(), &storage_settings, "branch1", sid.clone(), Some(&s2)) .await?; assert_eq!( - branch_history(storage.as_ref(), &storage_settings, "branch1") - .await? - .try_collect::>() - .await?, - vec![BranchVersion(2), BranchVersion(1), BranchVersion(0)] - ); - assert_eq!( - last_branch_version(storage.as_ref(), &storage_settings, "branch1").await?, - BranchVersion(2) - ); - - assert_eq!( - fetch_branch(storage.as_ref(), &storage_settings, "branch1", &BranchVersion(2)).await?, - fetch_ref(storage.as_ref(), &storage_settings, "branch1").await?.1 + fetch_branch_tip(storage.as_ref(), &storage_settings, "branch1").await?, + RefData { snapshot: sid.clone() } ); - assert_eq!( - fetch_ref(storage.as_ref(), &storage_settings, "branch1").await?, - (Ref::Branch("branch1".to_string()), RefData { snapshot: sid.clone() }) - ); // delete a branch delete_branch(storage.as_ref(), &storage_settings, "branch1").await?; assert!(matches!( - fetch_ref(storage.as_ref(), &storage_settings, "branch1").await, - Err(RefError::RefNotFound(name)) if name == "branch1" + fetch_branch_tip(storage.as_ref(), &storage_settings, "branch1").await, + Err(RefError{kind: RefErrorKind::RefNotFound(name),..}) if name == "branch1" )); Ok(()) @@ -712,13 +570,13 @@ mod tests { let storage_settings = storage.default_settings(); let s1 = SnapshotId::random(); let s2 = SnapshotId::random(); - create_tag(storage.as_ref(), &storage_settings, "tag1", s1, false).await?; + create_tag(storage.as_ref(), &storage_settings, "tag1", s1).await?; // we can delete tags - delete_tag(storage.as_ref(), &storage_settings, "tag1", false).await?; + delete_tag(storage.as_ref(), &storage_settings, "tag1").await?; // cannot delete twice - assert!(delete_tag(storage.as_ref(), &storage_settings, "tag1", false) + assert!(delete_tag(storage.as_ref(), &storage_settings, "tag1") .await .is_err()); @@ -727,7 +585,6 @@ mod tests { storage.as_ref(), &storage_settings, "doesnt_exist", - false ) .await .is_err()); @@ -738,14 +595,13 @@ mod tests { &storage_settings, "tag1", s2.clone(), - false ) - .await, Err(RefError::TagAlreadyExists(name)) if name == "tag1"); + .await, Err(RefError{kind: RefErrorKind::TagAlreadyExists(name),..}) if name == "tag1"); assert!(list_tags(storage.as_ref(), &storage_settings).await?.is_empty()); // can create different tag - create_tag(storage.as_ref(), &storage_settings, "tag2", s2, false).await?; + create_tag(storage.as_ref(), &storage_settings, "tag2", s2).await?; // listing doesn't include deleted tags assert_eq!( diff --git a/icechunk/src/repository.rs b/icechunk/src/repository.rs index e0e6e362..d1f9d801 100644 --- a/icechunk/src/repository.rs +++ b/icechunk/src/repository.rs @@ -1,88 +1,133 @@ use std::{ collections::{BTreeSet, HashMap, HashSet}, + future::ready, ops::RangeBounds, sync::Arc, }; +use async_recursion::async_recursion; use bytes::Bytes; -use futures::{stream::FuturesUnordered, Stream, StreamExt}; +use chrono::{DateTime, Utc}; +use err_into::ErrorInto as _; +use futures::{ + stream::{FuturesOrdered, FuturesUnordered}, + Stream, StreamExt, TryStreamExt, +}; use regex::bytes::Regex; use serde::{Deserialize, Serialize}; use thiserror::Error; use tokio::task::JoinError; +use tracing::{debug, error, instrument, trace, Instrument}; use crate::{ asset_manager::AssetManager, config::{Credentials, ManifestPreloadCondition, RepositoryConfig}, + error::ICError, format::{ snapshot::{ManifestFileInfo, NodeData, Snapshot, SnapshotInfo}, - IcechunkFormatError, ManifestId, NodeId, Path, SnapshotId, + transaction_log::{Diff, DiffBuilder}, + IcechunkFormatError, IcechunkFormatErrorKind, ManifestId, NodeId, Path, + SnapshotId, }, refs::{ create_tag, delete_branch, delete_tag, fetch_branch_tip, fetch_tag, - list_branches, list_tags, update_branch, BranchVersion, Ref, RefError, + list_branches, list_tags, update_branch, Ref, RefError, RefErrorKind, }, - session::Session, - storage::{self, ETag}, + session::{Session, SessionErrorKind, SessionResult}, + storage::{self, FetchConfigResult, StorageErrorKind, UpdateConfigResult}, virtual_chunks::{ContainerName, VirtualChunkResolver}, Storage, StorageError, }; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub enum VersionInfo { - #[serde(rename = "snapshot_id")] SnapshotId(SnapshotId), - #[serde(rename = "tag")] TagRef(String), - #[serde(rename = "branch")] BranchTipRef(String), + AsOf { branch: String, at: DateTime }, } #[derive(Debug, Error)] #[non_exhaustive] -pub enum RepositoryError { - #[error("error contacting storage {0}")] - StorageError(#[from] StorageError), +pub enum RepositoryErrorKind { + #[error(transparent)] + StorageError(StorageErrorKind), + #[error(transparent)] + FormatError(IcechunkFormatErrorKind), + #[error(transparent)] + Ref(RefErrorKind), + #[error("snapshot not found: `{id}`")] SnapshotNotFound { id: SnapshotId }, + #[error("branch {branch} does not have a snapshots before or at {at}")] + InvalidAsOfSpec { branch: String, at: DateTime }, #[error("invalid snapshot id: `{0}`")] InvalidSnapshotId(String), - #[error("error in icechunk file")] - FormatError(#[from] IcechunkFormatError), - #[error("ref error: `{0}`")] - Ref(#[from] RefError), #[error("tag error: `{0}`")] Tag(String), #[error("repositories can only be created in clean prefixes")] ParentDirectoryNotClean, #[error("the repository doesn't exist")] RepositoryDoesntExist, - #[error("error in repository serialization `{0}`")] + #[error("error in repository serialization")] SerializationError(#[from] rmp_serde::encode::Error), - #[error("error in repository deserialization `{0}`")] + #[error("error in repository deserialization")] DeserializationError(#[from] rmp_serde::decode::Error), #[error("error finding conflicting path for node `{0}`, this probably indicades a bug in `rebase`")] ConflictingPathNotFound(NodeId), - #[error("error in config deserialization `{0}`")] - ConfigDeserializationError(#[from] serde_yml::Error), + #[error("error in config deserialization")] + ConfigDeserializationError(#[from] serde_yaml_ng::Error), + #[error("config was updated by other session")] + ConfigWasUpdated, #[error("branch update conflict: `({expected_parent:?}) != ({actual_parent:?})`")] Conflict { expected_parent: Option, actual_parent: Option }, - #[error("I/O error `{0}`")] + #[error("I/O error")] IOError(#[from] std::io::Error), - #[error("a concurrent task failed {0}")] + #[error("a concurrent task failed")] ConcurrencyError(#[from] JoinError), #[error("main branch cannot be deleted")] CannotDeleteMain, } +pub type RepositoryError = ICError; + +// it would be great to define this impl in error.rs, but it conflicts with the blanket +// `impl From for T` +impl From for RepositoryError +where + E: Into, +{ + fn from(value: E) -> Self { + Self::new(value.into()) + } +} + +impl From for RepositoryError { + fn from(value: StorageError) -> Self { + Self::with_context(RepositoryErrorKind::StorageError(value.kind), value.context) + } +} + +impl From for RepositoryError { + fn from(value: RefError) -> Self { + Self::with_context(RepositoryErrorKind::Ref(value.kind), value.context) + } +} + +impl From for RepositoryError { + fn from(value: IcechunkFormatError) -> Self { + Self::with_context(RepositoryErrorKind::FormatError(value.kind), value.context) + } +} + pub type RepositoryResult = Result; #[derive(Debug, Serialize, Deserialize)] pub struct Repository { config: RepositoryConfig, storage_settings: storage::Settings, - config_etag: Option, + config_version: storage::VersionInfo, storage: Arc, asset_manager: Arc, virtual_resolver: Arc, @@ -90,6 +135,7 @@ pub struct Repository { } impl Repository { + #[instrument(skip_all)] pub async fn create( config: Option, storage: Arc, @@ -103,89 +149,109 @@ impl Repository { let config = config.map(|c| RepositoryConfig::default().merge(c)).unwrap_or_default(); let compression = config.compression().level(); - let overwrite_refs = config.unsafe_overwrite_refs(); let storage_c = Arc::clone(&storage); let storage_settings = config.storage().cloned().unwrap_or_else(|| storage.default_settings()); if !storage.root_is_clean().await? { - return Err(RepositoryError::ParentDirectoryNotClean); + return Err(RepositoryErrorKind::ParentDirectoryNotClean.into()); } - let handle1 = tokio::spawn(async move { - // TODO: we could cache this first snapshot - let asset_manager = AssetManager::new_no_cache( - Arc::clone(&storage_c), - storage_settings.clone(), - compression, - ); - // On create we need to create the default branch - let new_snapshot = Arc::new(Snapshot::initial()); - asset_manager.write_snapshot(Arc::clone(&new_snapshot)).await?; - - update_branch( - storage_c.as_ref(), - &storage_settings, - Ref::DEFAULT_BRANCH, - new_snapshot.id().clone(), - None, - overwrite_refs, - ) - .await?; - Ok::<(), RepositoryError>(()) - }); + let handle1 = tokio::spawn( + async move { + // TODO: we could cache this first snapshot + let asset_manager = AssetManager::new_no_cache( + Arc::clone(&storage_c), + storage_settings.clone(), + compression, + ); + // On create we need to create the default branch + let new_snapshot = Arc::new(Snapshot::initial()?); + asset_manager.write_snapshot(Arc::clone(&new_snapshot)).await?; + + update_branch( + storage_c.as_ref(), + &storage_settings, + Ref::DEFAULT_BRANCH, + new_snapshot.id().clone(), + None, + ) + .await?; + Ok::<(), RepositoryError>(()) + } + .in_current_span(), + ); let storage_c = Arc::clone(&storage); let config_c = config.clone(); - let handle2 = tokio::spawn(async move { - if has_overriden_config { - let etag = - Repository::store_config(storage_c.as_ref(), &config_c, None).await?; - Ok::<_, RepositoryError>(Some(etag)) - } else { - Ok(None) + let handle2 = tokio::spawn( + async move { + if has_overriden_config { + let version = Repository::store_config( + storage_c.as_ref(), + &config_c, + &storage::VersionInfo::for_creation(), + ) + .await?; + Ok::<_, RepositoryError>(version) + } else { + Ok(storage::VersionInfo::for_creation()) + } } - }); + .in_current_span(), + ); handle1.await??; - let config_etag = handle2.await??; + let config_version = handle2.await??; debug_assert!(Self::exists(storage.as_ref()).await.unwrap_or(false)); - Self::new(config, config_etag, storage, virtual_chunk_credentials) + Self::new(config, config_version, storage, virtual_chunk_credentials) } + #[instrument(skip_all)] pub async fn open( config: Option, storage: Arc, virtual_chunk_credentials: HashMap, ) -> RepositoryResult { let storage_c = Arc::clone(&storage); - let handle1 = - tokio::spawn(async move { Self::fetch_config(storage_c.as_ref()).await }); + let handle1 = tokio::spawn( + async move { Self::fetch_config(storage_c.as_ref()).await }.in_current_span(), + ); let storage_c = Arc::clone(&storage); - let handle2 = tokio::spawn(async move { - if !Self::exists(storage_c.as_ref()).await? { - return Err(RepositoryError::RepositoryDoesntExist); + let handle2 = tokio::spawn( + async move { + if !Self::exists(storage_c.as_ref()).await? { + return Err(RepositoryError::from( + RepositoryErrorKind::RepositoryDoesntExist, + )); + } + Ok(()) } - Ok(()) - }); + .in_current_span(), + ); #[allow(clippy::expect_used)] handle2.await.expect("Error checking if repo exists")?; #[allow(clippy::expect_used)] - if let Some((default_config, config_etag)) = + if let Some((default_config, config_version)) = handle1.await.expect("Error fetching repo config")? { // Merge the given config with the defaults let config = config.map(|c| default_config.merge(c)).unwrap_or(default_config); - Self::new(config, Some(config_etag), storage, virtual_chunk_credentials) + Self::new(config, config_version, storage, virtual_chunk_credentials) } else { let config = config.unwrap_or_default(); - Self::new(config, None, storage, virtual_chunk_credentials) + Self::new( + config, + storage::VersionInfo::for_creation(), + storage, + virtual_chunk_credentials, + ) } } @@ -203,7 +269,7 @@ impl Repository { fn new( config: RepositoryConfig, - config_etag: Option, + config_version: storage::VersionInfo, storage: Arc, virtual_chunk_credentials: HashMap, ) -> RepositoryResult { @@ -224,7 +290,7 @@ impl Repository { )); Ok(Self { config, - config_etag, + config_version, storage, storage_settings, virtual_resolver, @@ -233,17 +299,19 @@ impl Repository { }) } + #[instrument(skip_all)] pub async fn exists(storage: &(dyn Storage + Send + Sync)) -> RepositoryResult { match fetch_branch_tip(storage, &storage.default_settings(), Ref::DEFAULT_BRANCH) .await { Ok(_) => Ok(true), - Err(RefError::RefNotFound(_)) => Ok(false), + Err(RefError { kind: RefErrorKind::RefNotFound(_), .. }) => Ok(false), Err(err) => Err(err.into()), } } /// Reopen the repository changing its config and or virtual chunk credentials + #[instrument(skip_all)] pub fn reopen( &self, config: Option, @@ -256,48 +324,62 @@ impl Repository { Self::new( config, - self.config_etag.clone(), + self.config_version.clone(), Arc::clone(&self.storage), virtual_chunk_credentials .unwrap_or_else(|| self.virtual_chunk_credentials.clone()), ) } + #[instrument(skip(bytes))] + pub fn from_bytes(bytes: Vec) -> RepositoryResult { + rmp_serde::from_slice(&bytes).err_into() + } + + #[instrument(skip(self))] + pub fn as_bytes(&self) -> RepositoryResult> { + rmp_serde::to_vec(self).err_into() + } + + #[instrument(skip_all)] pub async fn fetch_config( storage: &(dyn Storage + Send + Sync), - ) -> RepositoryResult> { + ) -> RepositoryResult> { match storage.fetch_config(&storage.default_settings()).await? { - Some((bytes, etag)) => { - let config = serde_yml::from_slice(&bytes)?; - Ok(Some((config, etag))) + FetchConfigResult::Found { bytes, version } => { + let config = serde_yaml_ng::from_slice(&bytes)?; + Ok(Some((config, version))) } - None => Ok(None), + FetchConfigResult::NotFound => Ok(None), } } - pub async fn save_config(&self) -> RepositoryResult { + #[instrument(skip_all)] + pub async fn save_config(&self) -> RepositoryResult { Repository::store_config( self.storage().as_ref(), self.config(), - self.config_etag.as_ref(), + &self.config_version, ) .await } + #[instrument(skip(storage, config))] pub(crate) async fn store_config( storage: &(dyn Storage + Send + Sync), config: &RepositoryConfig, - config_etag: Option<&ETag>, - ) -> RepositoryResult { - let bytes = Bytes::from(serde_yml::to_string(config)?); - let res = storage - .update_config( - &storage.default_settings(), - bytes, - config_etag.map(|e| e.as_str()), - ) - .await?; - Ok(res) + previous_version: &storage::VersionInfo, + ) -> RepositoryResult { + let bytes = Bytes::from(serde_yaml_ng::to_string(config)?); + match storage + .update_config(&storage.default_settings(), bytes, previous_version) + .await? + { + UpdateConfigResult::Updated { new_version } => Ok(new_version), + UpdateConfigResult::NotOnLatestVersion => { + Err(RepositoryErrorKind::ConfigWasUpdated.into()) + } + } } pub fn config(&self) -> &RepositoryConfig { @@ -317,6 +399,7 @@ impl Repository { } /// Returns the sequence of parents of the current session, in order of latest first. + #[instrument(skip(self))] pub async fn snapshot_ancestry( &self, snapshot_id: &SnapshotId, @@ -324,6 +407,7 @@ impl Repository { Arc::clone(&self.asset_manager).snapshot_ancestry(snapshot_id).await } + #[instrument(skip(self))] pub async fn snapshot_ancestry_arc( self: Arc, snapshot_id: &SnapshotId, @@ -332,14 +416,17 @@ impl Repository { } /// Returns the sequence of parents of the snapshot pointed by the given version - pub async fn ancestry( - &self, + #[async_recursion(?Send)] + #[instrument(skip(self))] + pub async fn ancestry<'a>( + &'a self, version: &VersionInfo, - ) -> RepositoryResult> + '_> { + ) -> RepositoryResult> + 'a> { let snapshot_id = self.resolve_version(version).await?; self.snapshot_ancestry(&snapshot_id).await } + #[instrument(skip(self))] pub async fn ancestry_arc( self: Arc, version: &VersionInfo, @@ -349,33 +436,32 @@ impl Repository { } /// Create a new branch in the repository at the given snapshot id + #[instrument(skip(self))] pub async fn create_branch( &self, branch_name: &str, snapshot_id: &SnapshotId, - ) -> RepositoryResult { + ) -> RepositoryResult<()> { // TODO: The parent snapshot should exist? - let version = match update_branch( + update_branch( self.storage.as_ref(), &self.storage_settings, branch_name, snapshot_id.clone(), None, - self.config().unsafe_overwrite_refs(), ) .await - { - Ok(branch_version) => Ok(branch_version), - Err(RefError::Conflict { expected_parent, actual_parent }) => { - Err(RepositoryError::Conflict { expected_parent, actual_parent }) - } - Err(err) => Err(err.into()), - }?; - - Ok(version) + .map_err(|e| match e { + RefError { + kind: RefErrorKind::Conflict { expected_parent, actual_parent }, + .. + } => RepositoryErrorKind::Conflict { expected_parent, actual_parent }.into(), + err => err.into(), + }) } /// List all branches in the repository. + #[instrument(skip(self))] pub async fn list_branches(&self) -> RepositoryResult> { let branches = list_branches(self.storage.as_ref(), &self.storage_settings).await?; @@ -383,6 +469,7 @@ impl Repository { } /// Get the snapshot id of the tip of a branch + #[instrument(skip(self))] pub async fn lookup_branch(&self, branch: &str) -> RepositoryResult { let branch_version = fetch_branch_tip(self.storage.as_ref(), &self.storage_settings, branch) @@ -393,11 +480,12 @@ impl Repository { /// Make a branch point to the specified snapshot. /// After execution, history of the branch will be altered, and the current /// store will point to a different base snapshot_id + #[instrument(skip(self))] pub async fn reset_branch( &self, branch: &str, snapshot_id: &SnapshotId, - ) -> RepositoryResult { + ) -> RepositoryResult<()> { raise_if_invalid_snapshot_id( self.storage.as_ref(), &self.storage_settings, @@ -405,45 +493,40 @@ impl Repository { ) .await?; let branch_tip = self.lookup_branch(branch).await?; - let version = update_branch( + update_branch( self.storage.as_ref(), &self.storage_settings, branch, snapshot_id.clone(), Some(&branch_tip), - self.config().unsafe_overwrite_refs(), ) - .await?; - - Ok(version) + .await + .err_into() } /// Delete a branch from the repository. /// This will remove the branch reference and the branch history. It will not remove the /// chunks or snapshots associated with the branch. + #[instrument(skip(self))] pub async fn delete_branch(&self, branch: &str) -> RepositoryResult<()> { if branch != Ref::DEFAULT_BRANCH { delete_branch(self.storage.as_ref(), &self.storage_settings, branch).await?; Ok(()) } else { - Err(RepositoryError::CannotDeleteMain) + Err(RepositoryErrorKind::CannotDeleteMain.into()) } } /// Delete a tag from the repository. /// This will remove the tag reference. It will not remove the /// chunks or snapshots associated with the tag. + #[instrument(skip(self))] pub async fn delete_tag(&self, tag: &str) -> RepositoryResult<()> { - Ok(delete_tag( - self.storage.as_ref(), - &self.storage_settings, - tag, - self.config().unsafe_overwrite_refs(), - ) - .await?) + Ok(delete_tag(self.storage.as_ref(), &self.storage_settings, tag).await?) } /// Create a new tag in the repository at the given snapshot id + #[instrument(skip(self))] pub async fn create_tag( &self, tag_name: &str, @@ -454,24 +537,26 @@ impl Repository { &self.storage_settings, tag_name, snapshot_id.clone(), - self.config().unsafe_overwrite_refs(), ) .await?; Ok(()) } /// List all tags in the repository. + #[instrument(skip(self))] pub async fn list_tags(&self) -> RepositoryResult> { let tags = list_tags(self.storage.as_ref(), &self.storage_settings).await?; Ok(tags) } + #[instrument(skip(self))] pub async fn lookup_tag(&self, tag: &str) -> RepositoryResult { let ref_data = fetch_tag(self.storage.as_ref(), &self.storage_settings, tag).await?; Ok(ref_data.snapshot) } + #[instrument(skip(self))] async fn resolve_version( &self, version: &VersionInfo, @@ -500,9 +585,86 @@ impl Repository { .await?; Ok(ref_data.snapshot) } + VersionInfo::AsOf { branch, at } => { + let tip = VersionInfo::BranchTipRef(branch.clone()); + let snap = self + .ancestry(&tip) + .await? + .try_skip_while(|parent| ready(Ok(&parent.flushed_at > at))) + .take(1) + .try_collect::>() + .await?; + match snap.into_iter().next() { + Some(snap) => Ok(snap.id), + None => Err(RepositoryErrorKind::InvalidAsOfSpec { + branch: branch.clone(), + at: *at, + } + .into()), + } + } + } + } + + #[instrument(skip(self))] + /// Compute the diff between `from` and `to` snapshots. + /// + /// If `from` is not in the ancestry of `to`, `RepositoryErrorKind::BadSnapshotChainForDiff` + /// will be returned. + /// + /// Result includes the diffs in `to` snapshot but not in `from`. + pub async fn diff( + &self, + from: &VersionInfo, + to: &VersionInfo, + ) -> SessionResult { + let from = self.resolve_version(from).await?; + let all_snaps = self + .ancestry(to) + .await? + .try_take_while(|snap_info| ready(Ok(snap_info.id != from))) + .try_collect::>() + .await?; + + if all_snaps.last().and_then(|info| info.parent_id.as_ref()) != Some(&from) { + return Err(SessionErrorKind::BadSnapshotChainForDiff.into()); + } + + // we don't include the changes in from + let fut: FuturesOrdered<_> = all_snaps + .iter() + .filter_map(|snap_info| { + if snap_info.is_initial() { + None + } else { + Some( + self.asset_manager + .fetch_transaction_log(&snap_info.id) + .in_current_span(), + ) + } + }) + .collect(); + + let builder = fut + .try_fold(DiffBuilder::default(), |mut res, log| { + res.add_changes(log.as_ref()); + ready(Ok(res)) + }) + .await?; + + if let Some(to_snap) = all_snaps.first().as_ref().map(|snap| snap.id.clone()) { + let from_session = + self.readonly_session(&VersionInfo::SnapshotId(from)).await?; + let to_session = + self.readonly_session(&VersionInfo::SnapshotId(to_snap)).await?; + builder.to_diff(&from_session, &to_session).await + } else { + Err(SessionErrorKind::BadSnapshotChainForDiff.into()) } } + #[instrument(skip(self))] pub async fn readonly_session( &self, version: &VersionInfo, @@ -516,12 +678,12 @@ impl Repository { self.virtual_resolver.clone(), snapshot_id.clone(), ); - self.preload_manifests(snapshot_id); Ok(session) } + #[instrument(skip(self))] pub async fn writable_session(&self, branch: &str) -> RepositoryResult { let ref_data = fetch_branch_tip(self.storage.as_ref(), &self.storage_settings, branch) @@ -541,7 +703,9 @@ impl Repository { Ok(session) } + #[instrument(skip(self))] fn preload_manifests(&self, snapshot_id: SnapshotId) { + debug!("Preloading manifests"); let asset_manager = Arc::clone(self.asset_manager()); let preload_config = self.config().manifest().preload().clone(); if preload_config.max_total_refs() == 0 @@ -557,44 +721,59 @@ impl Repository { if let Ok(snap) = asset_manager.fetch_snapshot(&snapshot_id).await { let snap_c = Arc::clone(&snap); for node in snap.iter_arc() { - match node.node_data { - NodeData::Group => {} - NodeData::Array(_, manifests) => { - for manifest in manifests { - if !loaded_manifests.contains(&manifest.object_id) { - let manifest_id = manifest.object_id; - if let Some(manifest_info) = - snap_c.manifest_info(&manifest_id) - { - if loaded_refs + manifest_info.num_rows - <= preload_config.max_total_refs() - && preload_config - .preload_if() - .matches(&node.path, manifest_info) + match node { + Err(err) => { + error!(error=%err, "Error retrieving snapshot nodes"); + } + Ok(node) => match node.node_data { + NodeData::Group => {} + NodeData::Array { manifests, .. } => { + for manifest in manifests { + if !loaded_manifests.contains(&manifest.object_id) { + let manifest_id = manifest.object_id; + if let Some(manifest_info) = + snap_c.manifest_info(&manifest_id) { - let size_bytes = manifest_info.size_bytes; - let asset_manager = - Arc::clone(&asset_manager); - let manifest_id_c = manifest_id.clone(); - futures.push(async move { - let _ = asset_manager - .fetch_manifest( - &manifest_id_c, - size_bytes, - ) - .await; - }); - loaded_manifests.insert(manifest_id); - loaded_refs += manifest_info.num_rows; + if loaded_refs + manifest_info.num_chunk_refs + <= preload_config.max_total_refs() + && preload_config + .preload_if() + .matches(&node.path, &manifest_info) + { + let size_bytes = manifest_info.size_bytes; + let asset_manager = + Arc::clone(&asset_manager); + let manifest_id_c = manifest_id.clone(); + let path = node.path.clone(); + futures.push(async move { + trace!("Preloading manifest {} for array {}", &manifest_id_c, path); + if let Err(err) = asset_manager + .fetch_manifest( + &manifest_id_c, + size_bytes, + ) + .await + { + error!( + "Failure pre-loading manifest {}: {}", + &manifest_id_c, err + ); + } + }); + loaded_manifests.insert(manifest_id); + loaded_refs += + manifest_info.num_chunk_refs; + } } } } } - } + }, } } - } - futures.collect::<()>().await; + futures.collect::<()>().await; + }; + ().in_current_span() }); } } @@ -621,7 +800,7 @@ impl ManifestPreloadCondition { }) .unwrap_or(false), ManifestPreloadCondition::NumRefs { from, to } => { - (*from, *to).contains(&info.num_rows) + (*from, *to).contains(&info.num_chunk_refs) } ManifestPreloadCondition::True => true, ManifestPreloadCondition::False => false, @@ -636,7 +815,10 @@ fn validate_credentials( for (cont, cred) in creds { if let Some(cont) = config.get_virtual_chunk_container(cont) { if let Err(error) = cont.validate_credentials(cred) { - return Err(RepositoryError::StorageError(StorageError::Other(error))); + return Err(RepositoryErrorKind::StorageError(StorageErrorKind::Other( + error, + )) + .into()); } } } @@ -651,16 +833,14 @@ pub async fn raise_if_invalid_snapshot_id( storage .fetch_snapshot(storage_settings, snapshot_id) .await - .map_err(|_| RepositoryError::SnapshotNotFound { id: snapshot_id.clone() })?; + .map_err(|_| RepositoryErrorKind::SnapshotNotFound { id: snapshot_id.clone() })?; Ok(()) } #[cfg(test)] #[allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)] mod tests { - use std::{ - collections::HashMap, error::Error, num::NonZeroU64, path::PathBuf, sync::Arc, - }; + use std::{collections::HashMap, error::Error, path::PathBuf, sync::Arc}; use storage::logging::LoggingStorage; use tempfile::TempDir; @@ -669,10 +849,7 @@ mod tests { config::{ CachingConfig, ManifestConfig, ManifestPreloadConfig, RepositoryConfig, }, - format::{manifest::ChunkPayload, snapshot::ZarrArrayMetadata, ChunkIndices}, - metadata::{ - ChunkKeyEncoding, ChunkShape, Codec, DataType, FillValue, StorageTransformer, - }, + format::{manifest::ChunkPayload, snapshot::ArrayShape, ChunkIndices}, new_local_filesystem_storage, storage::new_in_memory_storage, Repository, Storage, @@ -682,7 +859,7 @@ mod tests { #[tokio::test] async fn test_repository_persistent_config() -> Result<(), Box> { - let storage: Arc = new_in_memory_storage()?; + let storage: Arc = new_in_memory_storage().await?; let repo = Repository::create(None, Arc::clone(&storage), HashMap::new()).await?; @@ -691,8 +868,8 @@ mod tests { // it inits with the default config assert_eq!(repo.config(), &RepositoryConfig::default()); // updating the persistent config create a new file with default values - let etag = repo.save_config().await?; - assert_ne!(etag, ""); + let version = repo.save_config().await?; + assert_ne!(version, storage::VersionInfo::for_creation()); assert_eq!( Repository::fetch_config(storage.as_ref()).await?.unwrap().0, RepositoryConfig::default() @@ -712,8 +889,8 @@ mod tests { assert_eq!(repo.config().inline_chunk_threshold_bytes(), 42); // update the persistent config - let etag = repo.save_config().await?; - assert_ne!(etag, ""); + let version = repo.save_config().await?; + assert_ne!(version, storage::VersionInfo::for_creation()); assert_eq!( Repository::fetch_config(storage.as_ref()) .await? @@ -728,7 +905,7 @@ mod tests { assert_eq!(repo.config().inline_chunk_threshold_bytes(), 42); // creating a repo we can override certain config atts: - let storage: Arc = new_in_memory_storage()?; + let storage: Arc = new_in_memory_storage().await?; let config = RepositoryConfig { inline_chunk_threshold_bytes: Some(20), caching: Some(CachingConfig { @@ -755,8 +932,8 @@ mod tests { assert_eq!(repo.config().caching().num_chunk_refs(), 100); // and we can save the merge - let etag = repo.save_config().await?; - assert_ne!(etag, ""); + let version = repo.save_config().await?; + assert_ne!(version, storage::VersionInfo::for_creation()); assert_eq!( &Repository::fetch_config(storage.as_ref()).await?.unwrap().0, repo.config() @@ -767,7 +944,7 @@ mod tests { #[tokio::test] async fn test_manage_refs() -> Result<(), Box> { - let storage: Arc = new_in_memory_storage()?; + let storage: Arc = new_in_memory_storage().await?; let repo = Repository::create(None, Arc::clone(&storage), HashMap::new()).await?; @@ -795,7 +972,7 @@ mod tests { // Main branch cannot be deleted assert!(matches!( repo.delete_branch("main").await, - Err(RepositoryError::CannotDeleteMain) + Err(RepositoryError { kind: RepositoryErrorKind::CannotDeleteMain, .. }) )); // Delete a branch @@ -817,6 +994,39 @@ mod tests { Ok(()) } + #[test] + fn test_manifest_preload_default_condition() { + let condition = + RepositoryConfig::default().manifest().preload().preload_if().clone(); + // no name match + assert!(!condition.matches( + &"/array".try_into().unwrap(), + &ManifestFileInfo { + id: ManifestId::random(), + size_bytes: 1, + num_chunk_refs: 1 + } + )); + // partial match only + assert!(!condition.matches( + &"/nottime".try_into().unwrap(), + &ManifestFileInfo { + id: ManifestId::random(), + size_bytes: 1, + num_chunk_refs: 1 + } + )); + // too large to match + assert!(!condition.matches( + &"/time".try_into().unwrap(), + &ManifestFileInfo { + id: ManifestId::random(), + size_bytes: 1, + num_chunk_refs: 1_000_000 + } + )); + } + #[tokio::test] /// Writes four arrays to a repo arrays, checks preloading of the manifests /// @@ -825,41 +1035,55 @@ mod tests { /// /// We verify only the correct two arrays are preloaded async fn test_manifest_preload_known_manifests() -> Result<(), Box> { - let backend: Arc = new_in_memory_storage()?; + let backend: Arc = new_in_memory_storage().await?; let storage = Arc::clone(&backend); let repository = Repository::create(None, storage, HashMap::new()).await?; let mut session = repository.writable_session("main").await?; - session.add_group(Path::root()).await?; - - let zarr_meta = ZarrArrayMetadata { - shape: vec![1_000, 1, 1], - data_type: DataType::Float16, - chunk_shape: ChunkShape(vec![ - NonZeroU64::new(1).unwrap(), - NonZeroU64::new(1).unwrap(), - NonZeroU64::new(1).unwrap(), - ]), - chunk_key_encoding: ChunkKeyEncoding::Slash, - fill_value: FillValue::Float16(f32::NEG_INFINITY), - codecs: vec![Codec { name: "mycodec".to_string(), configuration: None }], - storage_transformers: Some(vec![StorageTransformer { - name: "mytransformer".to_string(), - configuration: None, - }]), - dimension_names: Some(vec![Some("t".to_string())]), - }; + let def = Bytes::from_static(br#"{"this":"array"}"#); + session.add_group(Path::root(), def.clone()).await?; + + let shape = ArrayShape::new(vec![(1_000, 1), (1, 1), (1, 1)]).unwrap(); + let dimension_names = Some(vec!["t".into()]); let time_path: Path = "/time".try_into().unwrap(); let temp_path: Path = "/temperature".try_into().unwrap(); let lat_path: Path = "/latitude".try_into().unwrap(); let lon_path: Path = "/longitude".try_into().unwrap(); - session.add_array(time_path.clone(), zarr_meta.clone()).await?; - session.add_array(temp_path.clone(), zarr_meta.clone()).await?; - session.add_array(lat_path.clone(), zarr_meta.clone()).await?; - session.add_array(lon_path.clone(), zarr_meta.clone()).await?; + session + .add_array( + time_path.clone(), + shape.clone(), + dimension_names.clone(), + def.clone(), + ) + .await?; + session + .add_array( + temp_path.clone(), + shape.clone(), + dimension_names.clone(), + def.clone(), + ) + .await?; + session + .add_array( + lat_path.clone(), + shape.clone(), + dimension_names.clone(), + def.clone(), + ) + .await?; + session + .add_array( + lon_path.clone(), + shape.clone(), + dimension_names.clone(), + def.clone(), + ) + .await?; session .set_chunk_ref( @@ -941,11 +1165,11 @@ mod tests { let ops = logging.fetch_operations(); let lat_manifest_id = match session.get_node(&lat_path).await?.node_data { - NodeData::Array(_, vec) => vec[0].object_id.to_string(), + NodeData::Array { manifests, .. } => manifests[0].object_id.to_string(), NodeData::Group => panic!(), }; let lon_manifest_id = match session.get_node(&lon_path).await?.node_data { - NodeData::Array(_, vec) => vec[0].object_id.to_string(), + NodeData::Array { manifests, .. } => manifests[0].object_id.to_string(), NodeData::Group => panic!(), }; assert_eq!(ops[0].0, "fetch_snapshot"); @@ -962,6 +1186,7 @@ mod tests { let storage: Arc = new_local_filesystem_storage(repo_dir.path()) + .await .expect("Creating local storage failed"); Repository::create(None, Arc::clone(&storage), HashMap::new()).await?; @@ -975,6 +1200,7 @@ mod tests { .collect(); let storage: Arc = new_local_filesystem_storage(&inner_path) + .await .expect("Creating local storage failed"); assert!(Repository::create(None, Arc::clone(&storage), HashMap::new()) diff --git a/icechunk/src/session.rs b/icechunk/src/session.rs index 07769e1f..93988195 100644 --- a/icechunk/src/session.rs +++ b/icechunk/src/session.rs @@ -11,49 +11,58 @@ use std::{ use async_stream::try_stream; use bytes::Bytes; use chrono::{DateTime, Utc}; +use err_into::ErrorInto; use futures::{future::Either, stream, FutureExt, Stream, StreamExt, TryStreamExt}; +use itertools::Itertools as _; use serde::{Deserialize, Serialize}; use thiserror::Error; use tokio::task::JoinError; +use tracing::{debug, info, instrument, trace, warn, Instrument}; use crate::{ asset_manager::AssetManager, - change_set::ChangeSet, + change_set::{ArrayData, ChangeSet}, conflicts::{Conflict, ConflictResolution, ConflictSolver}, + error::ICError, format::{ manifest::{ - ChunkInfo, ChunkPayload, ChunkRef, Manifest, ManifestRef, + ChunkInfo, ChunkPayload, ChunkRef, Manifest, ManifestExtents, ManifestRef, VirtualChunkLocation, VirtualChunkRef, VirtualReferenceError, + VirtualReferenceErrorKind, }, snapshot::{ - ManifestFileInfo, NodeData, NodeSnapshot, NodeType, Snapshot, - SnapshotProperties, UserAttributesSnapshot, ZarrArrayMetadata, + ArrayShape, DimensionName, ManifestFileInfo, NodeData, NodeSnapshot, + NodeType, Snapshot, SnapshotProperties, }, - transaction_log::TransactionLog, - ByteRange, ChunkIndices, ChunkOffset, IcechunkFormatError, ManifestId, NodeId, - ObjectId, Path, SnapshotId, + transaction_log::{Diff, DiffBuilder, TransactionLog}, + ByteRange, ChunkIndices, ChunkOffset, IcechunkFormatError, + IcechunkFormatErrorKind, ManifestId, NodeId, ObjectId, Path, SnapshotId, }, - metadata::UserAttributes, - refs::{fetch_branch_tip, update_branch, RefError}, - repository::RepositoryError, - storage, + refs::{fetch_branch_tip, update_branch, RefError, RefErrorKind}, + repository::{RepositoryError, RepositoryErrorKind}, + storage::{self, StorageErrorKind}, virtual_chunks::{VirtualChunkContainer, VirtualChunkResolver}, RepositoryConfig, Storage, StorageError, }; #[derive(Debug, Error)] #[non_exhaustive] -pub enum SessionError { +pub enum SessionErrorKind { + #[error(transparent)] + RepositoryError(RepositoryErrorKind), + #[error(transparent)] + StorageError(StorageErrorKind), + #[error(transparent)] + FormatError(IcechunkFormatErrorKind), + #[error(transparent)] + Ref(RefErrorKind), + #[error(transparent)] + VirtualReferenceError(VirtualReferenceErrorKind), + #[error("Read only sessions cannot modify the repository")] ReadOnlySession, - #[error("Repository error: {0}")] - RepositoryError(#[from] RepositoryError), - #[error("error contacting storage {0}")] - StorageError(#[from] StorageError), #[error("snapshot not found: `{id}`")] SnapshotNotFound { id: SnapshotId }, - #[error("error in icechunk file")] - FormatError(#[from] IcechunkFormatError), #[error("no ancestor node was found for `{prefix}`")] AncestorNodeNotFound { prefix: Path }, #[error("node not found at `{path}`: {message}")] @@ -79,19 +88,15 @@ pub enum SessionError { }, #[error("unknown flush error")] OtherFlushError, - #[error("a concurrent task failed {0}")] + #[error("a concurrent task failed")] ConcurrencyError(#[from] JoinError), - #[error("ref error: `{0}`")] - Ref(#[from] RefError), #[error("branch update conflict: `({expected_parent:?}) != ({actual_parent:?})`")] Conflict { expected_parent: Option, actual_parent: Option }, #[error("cannot rebase snapshot {snapshot} on top of the branch")] RebaseFailed { snapshot: SnapshotId, conflicts: Vec }, - #[error("error when handling virtual reference {0}")] - VirtualReferenceError(#[from] VirtualReferenceError), - #[error("error in session serialization `{0}`")] + #[error("error in session serialization")] SerializationError(#[from] rmp_serde::encode::Error), - #[error("error in session deserialization `{0}`")] + #[error("error in session deserialization")] DeserializationError(#[from] rmp_serde::decode::Error), #[error("error finding conflicting path for node `{0}`, this probably indicades a bug in `rebase`")] ConflictingPathNotFound(NodeId), @@ -99,6 +104,54 @@ pub enum SessionError { "invalid chunk index: coordinates {coords:?} are not valid for array at {path}" )] InvalidIndex { coords: ChunkIndices, path: Path }, + #[error("`to` snapshot ancestry doesn't include `from`")] + BadSnapshotChainForDiff, +} + +pub type SessionError = ICError; + +// it would be great to define this impl in error.rs, but it conflicts with the blanket +// `impl From for T` +impl From for SessionError +where + E: Into, +{ + fn from(value: E) -> Self { + Self::new(value.into()) + } +} + +impl From for SessionError { + fn from(value: StorageError) -> Self { + Self::with_context(SessionErrorKind::StorageError(value.kind), value.context) + } +} + +impl From for SessionError { + fn from(value: RepositoryError) -> Self { + Self::with_context(SessionErrorKind::RepositoryError(value.kind), value.context) + } +} + +impl From for SessionError { + fn from(value: RefError) -> Self { + Self::with_context(SessionErrorKind::Ref(value.kind), value.context) + } +} + +impl From for SessionError { + fn from(value: IcechunkFormatError) -> Self { + Self::with_context(SessionErrorKind::FormatError(value.kind), value.context) + } +} + +impl From for SessionError { + fn from(value: VirtualReferenceError) -> Self { + Self::with_context( + SessionErrorKind::VirtualReferenceError(value.kind), + value.context, + ) + } } pub type SessionResult = Result; @@ -157,12 +210,14 @@ impl Session { } } + #[instrument(skip(bytes))] pub fn from_bytes(bytes: Vec) -> SessionResult { - rmp_serde::from_slice(&bytes).map_err(SessionError::DeserializationError) + rmp_serde::from_slice(&bytes).err_into() } + #[instrument(skip(self))] pub fn as_bytes(&self) -> SessionResult> { - rmp_serde::to_vec(self).map_err(SessionError::SerializationError) + rmp_serde::to_vec(self).err_into() } pub fn branch(&self) -> Option<&str> { @@ -196,45 +251,70 @@ impl Session { self.virtual_resolver.matching_container(chunk_location.0.as_str()) } + /// Compute an overview of the current session changes + pub async fn status(&self) -> SessionResult { + // it doesn't really matter what Id we give to the tx log, it's not going to be persisted + let tx_log = TransactionLog::new(&SnapshotId::random(), &self.change_set); + let from_session = Self::create_readonly_session( + self.config().clone(), + self.storage_settings.as_ref().clone(), + Arc::clone(&self.storage), + Arc::clone(&self.asset_manager), + Arc::clone(&self.virtual_resolver), + self.snapshot_id.clone(), + ); + let mut builder = DiffBuilder::default(); + builder.add_changes(&tx_log); + builder.to_diff(&from_session, self).await + } + /// Add a group to the store. /// /// Calling this only records the operation in memory, doesn't have any consequence on the storage - pub async fn add_group(&mut self, path: Path) -> SessionResult<()> { + #[instrument(skip(self, definition))] + pub async fn add_group( + &mut self, + path: Path, + definition: Bytes, + ) -> SessionResult<()> { match self.get_node(&path).await { - Err(SessionError::NodeNotFound { .. }) => { + Err(SessionError { kind: SessionErrorKind::NodeNotFound { .. }, .. }) => { let id = NodeId::random(); - self.change_set.add_group(path.clone(), id); + self.change_set.add_group(path.clone(), id, definition); Ok(()) } - Ok(node) => Err(SessionError::AlreadyExists { + Ok(node) => Err(SessionErrorKind::AlreadyExists { node, message: "trying to add group".to_string(), - }), + } + .into()), Err(err) => Err(err), } } + #[instrument(skip(self))] pub async fn delete_node(&mut self, node: NodeSnapshot) -> SessionResult<()> { match node { NodeSnapshot { node_data: NodeData::Group, path: node_path, .. } => { Ok(self.delete_group(node_path).await?) } - NodeSnapshot { node_data: NodeData::Array(..), path: node_path, .. } => { - Ok(self.delete_array(node_path).await?) - } + NodeSnapshot { + node_data: NodeData::Array { .. }, path: node_path, .. + } => Ok(self.delete_array(node_path).await?), } } /// Delete a group in the hierarchy /// /// Deletes of non existing groups will succeed. + #[instrument(skip(self))] pub async fn delete_group(&mut self, path: Path) -> SessionResult<()> { match self.get_group(&path).await { Ok(parent) => { let nodes_iter: Vec = self .list_nodes() .await? - .filter(|node| node.path.starts_with(&parent.path)) - .collect(); + .filter_ok(|node| node.path.starts_with(&parent.path)) + .try_collect()?; for node in nodes_iter { match node.node_type() { NodeType::Group => { @@ -246,7 +326,7 @@ impl Session { } } } - Err(SessionError::NodeNotFound { .. }) => {} + Err(SessionError { kind: SessionErrorKind::NodeNotFound { .. }, .. }) => {} Err(err) => Err(err)?, } Ok(()) @@ -255,21 +335,29 @@ impl Session { /// Add an array to the store. /// /// Calling this only records the operation in memory, doesn't have any consequence on the storage + #[instrument(skip(self, user_data))] pub async fn add_array( &mut self, path: Path, - metadata: ZarrArrayMetadata, + shape: ArrayShape, + dimension_names: Option>, + user_data: Bytes, ) -> SessionResult<()> { match self.get_node(&path).await { - Err(SessionError::NodeNotFound { .. }) => { + Err(SessionError { kind: SessionErrorKind::NodeNotFound { .. }, .. }) => { let id = NodeId::random(); - self.change_set.add_array(path, id, metadata); + self.change_set.add_array( + path, + id, + ArrayData { shape, dimension_names, user_data }, + ); Ok(()) } - Ok(node) => Err(SessionError::AlreadyExists { + Ok(node) => Err(SessionErrorKind::AlreadyExists { node, message: "trying to add array".to_string(), - }), + } + .into()), Err(err) => Err(err), } } @@ -277,30 +365,53 @@ impl Session { // Updates an array Zarr metadata /// /// Calling this only records the operation in memory, doesn't have any consequence on the storage + #[instrument(skip(self, user_data))] pub async fn update_array( &mut self, - path: Path, - metadata: ZarrArrayMetadata, + path: &Path, + shape: ArrayShape, + dimension_names: Option>, + user_data: Bytes, + ) -> SessionResult<()> { + self.get_array(path).await.map(|node| { + self.change_set.update_array( + &node.id, + path, + ArrayData { shape, dimension_names, user_data }, + ) + }) + } + + // Updates an group Zarr metadata + /// + /// Calling this only records the operation in memory, doesn't have any consequence on the storage + #[instrument(skip(self, definition))] + pub async fn update_group( + &mut self, + path: &Path, + definition: Bytes, ) -> SessionResult<()> { - self.get_array(&path) + self.get_group(path) .await - .map(|node| self.change_set.update_array(node.id, metadata)) + .map(|node| self.change_set.update_group(&node.id, path, definition)) } /// Delete an array in the hierarchy /// /// Deletes of non existing array will succeed. + #[instrument(skip(self))] pub async fn delete_array(&mut self, path: Path) -> SessionResult<()> { match self.get_array(&path).await { Ok(node) => { self.change_set.delete_array(node.path, &node.id); } - Err(SessionError::NodeNotFound { .. }) => {} + Err(SessionError { kind: SessionErrorKind::NodeNotFound { .. }, .. }) => {} Err(err) => Err(err)?, } Ok(()) } + #[instrument(skip(self, coords))] pub async fn delete_chunks( &mut self, node_path: &Path, @@ -313,20 +424,10 @@ impl Session { Ok(()) } - /// Record the write or delete of user attributes to array or group - pub async fn set_user_attributes( - &mut self, - path: Path, - atts: Option, - ) -> SessionResult<()> { - let node = self.get_node(&path).await?; - self.change_set.update_user_attributes(node.id, atts); - Ok(()) - } - // Record the write, referencing or delete of a chunk // // Caller has to write the chunk before calling this. + #[instrument(skip(self))] pub async fn set_chunk_ref( &mut self, path: Path, @@ -339,27 +440,34 @@ impl Session { // Helper function that accepts a NodeSnapshot instead of a path, // this lets us do bulk sets (and deletes) without repeatedly grabbing the node. + #[instrument(skip(self))] async fn set_node_chunk_ref( &mut self, node: NodeSnapshot, coord: ChunkIndices, data: Option, ) -> SessionResult<()> { - if let NodeData::Array(zarr_metadata, _) = node.node_data { - if zarr_metadata.valid_chunk_coord(&coord) { + if let NodeData::Array { shape, .. } = node.node_data { + if shape.valid_chunk_coord(&coord) { self.change_set.set_chunk_ref(node.id, coord, data); Ok(()) } else { - Err(SessionError::InvalidIndex { coords: coord, path: node.path.clone() }) + Err(SessionErrorKind::InvalidIndex { + coords: coord, + path: node.path.clone(), + } + .into()) } } else { - Err(SessionError::NotAnArray { + Err(SessionErrorKind::NotAnArray { node: node.clone(), message: "getting an array".to_string(), - }) + } + .into()) } } + #[instrument(skip(self))] pub async fn get_closest_ancestor_node( &self, path: &Path, @@ -373,20 +481,22 @@ impl Session { return node; } } - Err(SessionError::AncestorNodeNotFound { prefix: path.clone() }) + Err(SessionErrorKind::AncestorNodeNotFound { prefix: path.clone() }.into()) } + #[instrument(skip(self))] pub async fn get_node(&self, path: &Path) -> SessionResult { get_node(&self.asset_manager, &self.change_set, self.snapshot_id(), path).await } pub async fn get_array(&self, path: &Path) -> SessionResult { match self.get_node(path).await { - res @ Ok(NodeSnapshot { node_data: NodeData::Array(..), .. }) => res, - Ok(node @ NodeSnapshot { .. }) => Err(SessionError::NotAnArray { + res @ Ok(NodeSnapshot { node_data: NodeData::Array { .. }, .. }) => res, + Ok(node @ NodeSnapshot { .. }) => Err(SessionErrorKind::NotAnArray { node, message: "getting an array".to_string(), - }), + } + .into()), other => other, } } @@ -394,14 +504,16 @@ impl Session { pub async fn get_group(&self, path: &Path) -> SessionResult { match self.get_node(path).await { res @ Ok(NodeSnapshot { node_data: NodeData::Group, .. }) => res, - Ok(node @ NodeSnapshot { .. }) => Err(SessionError::NotAGroup { + Ok(node @ NodeSnapshot { .. }) => Err(SessionErrorKind::NotAGroup { node, message: "getting a group".to_string(), - }), + } + .into()), other => other, } } + #[instrument(skip(self))] pub async fn array_chunk_iterator<'a>( &'a self, path: &Path, @@ -415,6 +527,7 @@ impl Session { .await } + #[instrument(skip(self))] pub async fn get_chunk_ref( &self, path: &Path, @@ -424,11 +537,12 @@ impl Session { // TODO: it's ugly to have to do this destructuring even if we could be calling `get_array` // get_array should return the array data, not a node match node.node_data { - NodeData::Group => Err(SessionError::NotAnArray { + NodeData::Group => Err(SessionErrorKind::NotAnArray { node, message: "getting chunk reference".to_string(), - }), - NodeData::Array(_, manifests) => { + } + .into()), + NodeData::Array { manifests, .. } => { // check the chunks modified in this session first // TODO: I hate rust forces me to clone to search in a hashmap. How to do better? let session_chunk = @@ -472,6 +586,8 @@ impl Session { /// /// The helper function [`get_chunk`] manages the pattern matching of the result and returns /// the bytes. + #[instrument(skip(self))] + #[allow(clippy::type_complexity)] pub async fn get_chunk_reader( &self, path: &Path, @@ -537,6 +653,7 @@ impl Session { /// ``` /// /// As shown, the result of the returned function must be awaited to finish the upload. + #[instrument(skip(self))] pub fn get_chunk_writer( &self, ) -> impl FnOnce(Bytes) -> Pin> + Send>> @@ -556,10 +673,14 @@ impl Session { } } + #[instrument(skip(self))] pub async fn clear(&mut self) -> SessionResult<()> { // TODO: can this be a delete_group("/") instead? - let to_delete: Vec<(NodeType, Path)> = - self.list_nodes().await?.map(|node| (node.node_type(), node.path)).collect(); + let to_delete: Vec<(NodeType, Path)> = self + .list_nodes() + .await? + .map_ok(|node| (node.node_type(), node.path)) + .try_collect()?; for (t, p) in to_delete { match t { @@ -583,7 +704,10 @@ impl Session { Ok(payload) => { return Ok(Some(payload.clone())); } - Err(IcechunkFormatError::ChunkCoordinatesNotFound { .. }) => {} + Err(IcechunkFormatError { + kind: IcechunkFormatErrorKind::ChunkCoordinatesNotFound { .. }, + .. + }) => {} Err(err) => return Err(err.into()), } } @@ -594,18 +718,21 @@ impl Session { fetch_manifest(id, self.snapshot_id(), self.asset_manager.as_ref()).await } + #[instrument(skip(self))] pub async fn list_nodes( &self, - ) -> SessionResult + '_> { + ) -> SessionResult> + '_> { updated_nodes(&self.asset_manager, &self.change_set, &self.snapshot_id).await } + #[instrument(skip(self))] pub async fn all_chunks( &self, ) -> SessionResult> + '_> { all_chunks(&self.asset_manager, &self.change_set, self.snapshot_id()).await } + #[instrument(skip(self))] pub async fn chunk_coordinates<'a, 'b: 'a>( &'a self, array_path: &'b Path, @@ -637,6 +764,7 @@ impl Session { Ok(res) } + #[instrument(skip(self))] pub async fn all_virtual_chunk_locations( &self, ) -> SessionResult> + '_> { @@ -649,26 +777,29 @@ impl Session { } /// Discard all uncommitted changes and return them as a `ChangeSet` + #[instrument(skip(self))] pub fn discard_changes(&mut self) -> ChangeSet { std::mem::take(&mut self.change_set) } /// Merge a set of `ChangeSet`s into the repository without committing them + #[instrument(skip(self, changes))] pub async fn merge(&mut self, changes: ChangeSet) -> SessionResult<()> { if self.read_only() { - return Err(SessionError::ReadOnlySession); + return Err(SessionErrorKind::ReadOnlySession.into()); } self.change_set.merge(changes); Ok(()) } + #[instrument(skip(self, properties))] pub async fn commit( &mut self, message: &str, properties: Option, ) -> SessionResult { let Some(branch_name) = &self.branch_name else { - return Err(SessionError::ReadOnlySession); + return Err(SessionErrorKind::ReadOnlySession.into()); }; let current = fetch_branch_tip( @@ -679,9 +810,8 @@ impl Session { .await; let id = match current { - Err(RefError::RefNotFound(_)) => { + Err(RefError { kind: RefErrorKind::RefNotFound(_), .. }) => { do_commit( - &self.config, self.storage.as_ref(), Arc::clone(&self.asset_manager), self.storage_settings.as_ref(), @@ -697,13 +827,13 @@ impl Session { Ok(ref_data) => { // we can detect there will be a conflict before generating the new snapshot if ref_data.snapshot != self.snapshot_id { - Err(SessionError::Conflict { + Err(SessionErrorKind::Conflict { expected_parent: Some(self.snapshot_id.clone()), actual_parent: Some(ref_data.snapshot.clone()), - }) + } + .into()) } else { do_commit( - &self.config, self.storage.as_ref(), Arc::clone(&self.asset_manager), self.storage_settings.as_ref(), @@ -792,11 +922,13 @@ impl Session { /// If at some point it finds a conflict it cannot recover from, `rebase` leaves the /// `Repository` in a consistent state, that would successfully commit on top /// of the latest successfully fast-forwarded commit. + #[instrument(skip(self, solver))] pub async fn rebase(&mut self, solver: &dyn ConflictSolver) -> SessionResult<()> { let Some(branch_name) = &self.branch_name else { - return Err(SessionError::ReadOnlySession); + return Err(SessionErrorKind::ReadOnlySession.into()); }; + debug!("Rebase started"); let ref_data = fetch_branch_tip( self.storage.as_ref(), self.storage_settings.as_ref(), @@ -806,13 +938,16 @@ impl Session { if ref_data.snapshot == self.snapshot_id { // nothing to do, commit should work without rebasing + warn!( + branch = &self.branch_name, + "No rebase is needed, parent snapshot is at the top of the branch. Aborting rebase." + ); Ok(()) } else { let current_snapshot = self.asset_manager.fetch_snapshot(&ref_data.snapshot).await?; - // FIXME: this should be the whole ancestry not local let ancestry = Arc::clone(&self.asset_manager) - .snapshot_ancestry(current_snapshot.id()) + .snapshot_ancestry(¤t_snapshot.id()) .await? .map_ok(|meta| meta.id); let new_commits = @@ -822,6 +957,7 @@ impl Session { })) .try_collect::>() .await?; + trace!("Found {} commits to rebase", new_commits.len()); // TODO: this clone is expensive // we currently need it to be able to process commits one by one without modifying the @@ -830,6 +966,7 @@ impl Session { // we need to reverse the iterator to process them in order of oldest first for snap_id in new_commits.into_iter().rev() { + debug!("Rebasing snapshot {}", &snap_id); let tx_log = self.asset_manager.fetch_transaction_log(&snap_id).await?; let session = Self::create_readonly_session( @@ -845,19 +982,22 @@ impl Session { // TODO: this should probably execute in a worker thread match solver.solve(&tx_log, &session, change_set, self).await? { ConflictResolution::Patched(patched_changeset) => { + trace!("Snapshot rebased"); self.change_set = patched_changeset; self.snapshot_id = snap_id; } ConflictResolution::Unsolvable { reason, unmodified } => { + warn!("Snapshot cannot be rebased. Aborting rebase."); self.change_set = unmodified; - return Err(SessionError::RebaseFailed { + return Err(SessionErrorKind::RebaseFailed { snapshot: snap_id, conflicts: reason, - }); + } + .into()); } } } - + debug!("Rebase done"); Ok(()) } } @@ -871,10 +1011,11 @@ async fn updated_chunk_iterator<'a>( ) -> SessionResult> + 'a> { let snapshot = asset_manager.fetch_snapshot(snapshot_id).await?; let nodes = futures::stream::iter(snapshot.iter_arc()); - let res = nodes.then(move |node| async move { - updated_node_chunks_iterator(asset_manager, change_set, snapshot_id, node).await + let res = nodes.and_then(move |node| async move { + Ok(updated_node_chunks_iterator(asset_manager, change_set, snapshot_id, node) + .await) }); - Ok(res.flatten()) + Ok(res.try_flatten()) } async fn updated_node_chunks_iterator<'a>( @@ -924,7 +1065,7 @@ async fn verified_node_chunk_iterator<'a>( ) -> impl Stream> + 'a { match node.node_data { NodeData::Group => futures::future::Either::Left(futures::stream::empty()), - NodeData::Array(_, manifests) => { + NodeData::Array { manifests, .. } => { let new_chunk_indices: Box> = Box::new( change_set .array_chunks_iterator(&node.id, &node.path) @@ -964,10 +1105,10 @@ async fn verified_node_chunk_iterator<'a>( Ok(manifest) => { let old_chunks = manifest .iter(node_id_c.clone()) - .filter(move |(coord, _)| { + .filter_ok(move |(coord, _)| { !new_chunk_indices.contains(coord) }) - .map(move |(coord, payload)| ChunkInfo { + .map_ok(move |(coord, payload)| ChunkInfo { node: node_id_c2.clone(), coord, payload, @@ -978,7 +1119,8 @@ async fn verified_node_chunk_iterator<'a>( node_id_c3, old_chunks, ); futures::future::Either::Left( - futures::stream::iter(old_chunks.map(Ok)), + futures::stream::iter(old_chunks) + .map_err(|e| e.into()), ) } // if we cannot even fetch the manifest, we generate a @@ -1045,12 +1187,16 @@ async fn updated_existing_nodes<'a>( asset_manager: &AssetManager, change_set: &'a ChangeSet, parent_id: &SnapshotId, -) -> SessionResult + 'a> { +) -> SessionResult> + 'a> { let updated_nodes = asset_manager .fetch_snapshot(parent_id) .await? .iter_arc() - .filter_map(move |node| change_set.update_existing_node(node)); + .filter_map_ok(move |node| change_set.update_existing_node(node)) + .map(|n| match n { + Ok(n) => Ok(n), + Err(err) => Err(SessionError::from(err)), + }); Ok(updated_nodes) } @@ -1061,10 +1207,10 @@ async fn updated_nodes<'a>( asset_manager: &AssetManager, change_set: &'a ChangeSet, parent_id: &SnapshotId, -) -> SessionResult + 'a> { +) -> SessionResult> + 'a> { Ok(updated_existing_nodes(asset_manager, change_set, parent_id) .await? - .chain(change_set.new_nodes_iterator())) + .chain(change_set.new_nodes_iterator().map(Ok))) } async fn get_node( @@ -1079,10 +1225,11 @@ async fn get_node( let node = get_existing_node(asset_manager, change_set, snapshot_id, path).await?; if change_set.is_deleted(&node.path, &node.id) { - Err(SessionError::NodeNotFound { + Err(SessionErrorKind::NodeNotFound { path: path.clone(), message: "getting node".to_string(), - }) + } + .into()) } else { Ok(node) } @@ -1102,31 +1249,41 @@ async fn get_existing_node( let node = snapshot.get_node(path).map_err(|err| match err { // A missing node here is not really a format error, so we need to // generate the correct error for repositories - IcechunkFormatError::NodeNotFound { path } => SessionError::NodeNotFound { + IcechunkFormatError { + kind: IcechunkFormatErrorKind::NodeNotFound { path }, + .. + } => SessionErrorKind::NodeNotFound { path, message: "existing node not found".to_string(), - }, - err => SessionError::FormatError(err), + } + .into(), + err => SessionError::from(err), })?; - let session_atts = change_set - .get_user_attributes(&node.id) - .cloned() - .map(|a| a.map(UserAttributesSnapshot::Inline)); - let res = NodeSnapshot { - user_attributes: session_atts.unwrap_or_else(|| node.user_attributes.clone()), - ..node.clone() - }; - if let Some(session_meta) = change_set.get_updated_zarr_metadata(&node.id).cloned() { - if let NodeData::Array(_, manifests) = res.node_data { - Ok(NodeSnapshot { - node_data: NodeData::Array(session_meta, manifests), - ..res - }) - } else { - Ok(res) + + match node.node_data { + NodeData::Array { ref manifests, .. } => { + if let Some(new_data) = change_set.get_updated_array(&node.id) { + let node_data = NodeData::Array { + shape: new_data.shape.clone(), + dimension_names: new_data.dimension_names.clone(), + manifests: manifests.clone(), + }; + Ok(NodeSnapshot { + user_data: new_data.user_data.clone(), + node_data, + ..node + }) + } else { + Ok(node) + } + } + NodeData::Group => { + if let Some(updated_definition) = change_set.get_updated_group(&node.id) { + Ok(NodeSnapshot { user_data: updated_definition.clone(), ..node }) + } else { + Ok(node) + } } - } else { - Ok(res) } } @@ -1150,7 +1307,7 @@ pub async fn raise_if_invalid_snapshot_id( storage .fetch_snapshot(storage_settings, snapshot_id) .await - .map_err(|_| SessionError::SnapshotNotFound { id: snapshot_id.clone() })?; + .map_err(|_| SessionErrorKind::SnapshotNotFound { id: snapshot_id.clone() })?; Ok(()) } @@ -1217,8 +1374,8 @@ impl<'a> FlushProcess<'a> { node_id: &NodeId, node_path: &Path, ) -> SessionResult<()> { - let mut from = ChunkIndices(vec![]); - let mut to = ChunkIndices(vec![]); + let mut from = vec![]; + let mut to = vec![]; let chunks = stream::iter( self.change_set .new_array_chunk_iterator(node_id, node_path) @@ -1235,8 +1392,10 @@ impl<'a> FlushProcess<'a> { ManifestFileInfo::new(new_manifest.as_ref(), new_manifest_size); self.manifest_files.insert(file_info); - let new_ref = - ManifestRef { object_id: new_manifest.id.clone(), extents: from..to }; + let new_ref = ManifestRef { + object_id: new_manifest.id().clone(), + extents: ManifestExtents::new(&from, &to), + }; self.manifest_refs .entry(node_id.clone()) @@ -1261,8 +1420,8 @@ impl<'a> FlushProcess<'a> { ) .await .map_ok(|(_path, chunk_info)| chunk_info); - let mut from = ChunkIndices(vec![]); - let mut to = ChunkIndices(vec![]); + let mut from = vec![]; + let mut to = vec![]; let updated_chunks = aggregate_extents(&mut from, &mut to, updated_chunks, |ci| &ci.coord); @@ -1275,8 +1434,10 @@ impl<'a> FlushProcess<'a> { ManifestFileInfo::new(new_manifest.as_ref(), new_manifest_size); self.manifest_files.insert(file_info); - let new_ref = - ManifestRef { object_id: new_manifest.id.clone(), extents: from..to }; + let new_ref = ManifestRef { + object_id: new_manifest.id().clone(), + extents: ManifestExtents::new(&from, &to), + }; self.manifest_refs .entry(node.id.clone()) .and_modify(|v| v.push(new_ref.clone())) @@ -1288,7 +1449,7 @@ impl<'a> FlushProcess<'a> { /// Record the previous manifests for an array that was not modified in the session fn copy_previous_manifest(&mut self, node: &NodeSnapshot, old_snapshot: &Snapshot) { match &node.node_data { - NodeData::Array(_, array_refs) => { + NodeData::Array { manifests: array_refs, .. } => { self.manifest_files.extend(array_refs.iter().map(|mr| { // It's ok to unwrap here, the snapshot had the node, it has to have the // manifest file info @@ -1319,7 +1480,7 @@ async fn flush( properties: SnapshotProperties, ) -> SessionResult { if flush_data.change_set.is_empty() { - return Err(SessionError::NoChangesToCommit); + return Err(SessionErrorKind::NoChangesToCommit.into()); } let old_snapshot = @@ -1327,83 +1488,109 @@ async fn flush( // We first go through all existing nodes to see if we need to rewrite any manifests - for node in old_snapshot.iter().filter(|node| node.node_type() == NodeType::Array) { + for node in old_snapshot.iter().filter_ok(|node| node.node_type() == NodeType::Array) + { + let node = node?; + trace!(path=%node.path, "Flushing node"); let node_id = &node.id; if flush_data.change_set.array_is_deleted(&(node.path.clone(), node_id.clone())) { + trace!(path=%node.path, "Node deleted, not writing a manifest"); continue; } if flush_data.change_set.has_chunk_changes(node_id) { + trace!(path=%node.path, "Node has changes, writing a new manifest"); // Array wasn't deleted and has changes in this session - flush_data.write_manifest_for_existing_node(node).await?; + flush_data.write_manifest_for_existing_node(&node).await?; } else { + trace!(path=%node.path, "Node has no changes, keeping the previous manifest"); // Array wasn't deleted but has no changes in this session // FIXME: deal with the case of metadata shrinking an existing array, we should clear // extra chunks that no longer fit in the array - flush_data.copy_previous_manifest(node, old_snapshot.as_ref()); + flush_data.copy_previous_manifest(&node, old_snapshot.as_ref()); } } // Now we need to go through all the new arrays, and generate manifests for them for (node_path, node_id) in flush_data.change_set.new_arrays() { + trace!(path=%node_path, "New node, writing a manifest"); flush_data.write_manifest_for_new_node(node_id, node_path).await?; } - let all_nodes = updated_nodes( + trace!("Building new snapshot"); + // gather and sort nodes: + // this is a requirement for Snapshot::from_iter + let mut all_nodes: Vec<_> = updated_nodes( flush_data.asset_manager.as_ref(), flush_data.change_set, flush_data.parent_id, ) .await? - .map(|node| { + .map_ok(|node| { let id = &node.id; // TODO: many clones - if let NodeData::Array(meta, _) = node.node_data { + if let NodeData::Array { shape, dimension_names, .. } = node.node_data { NodeSnapshot { - node_data: NodeData::Array( - meta.clone(), - flush_data.manifest_refs.get(id).cloned().unwrap_or_default(), - ), + node_data: NodeData::Array { + shape, + dimension_names, + manifests: flush_data + .manifest_refs + .get(id) + .cloned() + .unwrap_or_default(), + }, ..node } } else { node } - }); + }) + .try_collect()?; + + all_nodes.sort_by(|a, b| a.path.cmp(&b.path)); let new_snapshot = Snapshot::from_iter( - old_snapshot.id().clone(), + None, + Some(old_snapshot.id().clone()), message.to_string(), Some(properties), flush_data.manifest_files.into_iter().collect(), - vec![], - all_nodes, - ); - - if new_snapshot.flushed_at() <= old_snapshot.flushed_at() { - return Err(SessionError::InvalidSnapshotTimestampOrdering { - parent: *old_snapshot.flushed_at(), - child: *new_snapshot.flushed_at(), - }); + all_nodes.into_iter().map(Ok::<_, Infallible>), + )?; + + let new_ts = new_snapshot.flushed_at()?; + let old_ts = old_snapshot.flushed_at()?; + if new_ts <= old_ts { + tracing::error!( + new_timestamp = %new_ts, + old_timestamp = %old_ts, + "Snapshot timestamp older than parent, aborting commit" + ); + return Err(SessionErrorKind::InvalidSnapshotTimestampOrdering { + parent: old_ts, + child: new_ts, + } + .into()); } let new_snapshot = Arc::new(new_snapshot); let new_snapshot_c = Arc::clone(&new_snapshot); let asset_manager = Arc::clone(&flush_data.asset_manager); - let snapshot_timestamp = tokio::spawn(async move { - asset_manager.write_snapshot(Arc::clone(&new_snapshot_c)).await?; - asset_manager.get_snapshot_last_modified(new_snapshot_c.id()).await - }); - - // FIXME: this should execute in a non-blocking context - let tx_log = TransactionLog::new( - flush_data.change_set, - old_snapshot.iter(), - new_snapshot.iter(), + let snapshot_timestamp = tokio::spawn( + async move { + asset_manager.write_snapshot(Arc::clone(&new_snapshot_c)).await?; + asset_manager.get_snapshot_last_modified(&new_snapshot_c.id()).await + } + .in_current_span(), ); + + trace!(transaction_log_id = %new_snapshot.id(), "Creating transaction log"); let new_snapshot_id = new_snapshot.id(); + // FIXME: this should execute in a non-blocking context + let tx_log = TransactionLog::new(&new_snapshot_id, flush_data.change_set); flush_data .asset_manager @@ -1412,16 +1599,22 @@ async fn flush( let snapshot_timestamp = snapshot_timestamp .await - .map_err(SessionError::ConcurrencyError)? - .map_err(SessionError::RepositoryError)?; + .map_err(SessionError::from)? + .map_err(SessionError::from)?; // Fail if there is too much clock difference with the object store // This is to prevent issues with snapshot ordering and expiration - if (snapshot_timestamp - new_snapshot.flushed_at()).num_seconds().abs() > 600 { - return Err(SessionError::InvalidSnapshotTimestamp { + if (snapshot_timestamp - new_ts).num_seconds().abs() > 600 { + tracing::error!( + snapshot_timestamp = %new_ts, + object_store_timestamp = %snapshot_timestamp, + "Snapshot timestamp drifted from object store clock, aborting commit" + ); + return Err(SessionErrorKind::InvalidSnapshotTimestamp { object_store_time: snapshot_timestamp, - snapshot_time: *new_snapshot.flushed_at(), - }); + snapshot_time: new_ts, + } + .into()); } Ok(new_snapshot_id.clone()) @@ -1429,7 +1622,6 @@ async fn flush( #[allow(clippy::too_many_arguments)] async fn do_commit( - config: &RepositoryConfig, storage: &(dyn Storage + Send + Sync), asset_manager: Arc, storage_settings: &storage::Settings, @@ -1439,28 +1631,34 @@ async fn do_commit( message: &str, properties: Option, ) -> SessionResult { + info!(branch_name, old_snapshot_id=%snapshot_id, "Commit started"); let parent_snapshot = snapshot_id.clone(); let properties = properties.unwrap_or_default(); let flush_data = FlushProcess::new(asset_manager, change_set, snapshot_id); let new_snapshot = flush(flush_data, message, properties).await?; + debug!(branch_name, new_snapshot_id=%new_snapshot, "Updating branch"); let id = match update_branch( storage, storage_settings, branch_name, new_snapshot.clone(), Some(&parent_snapshot), - config.unsafe_overwrite_refs(), ) .await { - Ok(_) => Ok(new_snapshot), - Err(RefError::Conflict { expected_parent, actual_parent }) => { - Err(SessionError::Conflict { expected_parent, actual_parent }) - } + Ok(_) => Ok(new_snapshot.clone()), + Err(RefError { + kind: RefErrorKind::Conflict { expected_parent, actual_parent }, + .. + }) => Err(SessionError::from(SessionErrorKind::Conflict { + expected_parent, + actual_parent, + })), Err(err) => Err(err.into()), }?; + info!(branch_name, old_snapshot_id=%snapshot_id, new_snapshot_id=%new_snapshot, "Commit done"); Ok(id) } async fn fetch_manifest( @@ -1470,7 +1668,9 @@ async fn fetch_manifest( ) -> SessionResult> { let snapshot = asset_manager.fetch_snapshot(snapshot_id).await?; let manifest_info = snapshot.manifest_info(manifest_id).ok_or_else(|| { - IcechunkFormatError::ManifestInfoNotFound { manifest_id: manifest_id.clone() } + IcechunkFormatError::from(IcechunkFormatErrorKind::ManifestInfoNotFound { + manifest_id: manifest_id.clone(), + }) })?; Ok(asset_manager.fetch_manifest(manifest_id, manifest_info.size_bytes).await?) } @@ -1490,15 +1690,15 @@ async fn fetch_manifest( /// /// Yes, this is horrible. fn aggregate_extents<'a, T: std::fmt::Debug, E>( - from: &'a mut ChunkIndices, - to: &'a mut ChunkIndices, + from: &'a mut Vec, + to: &'a mut Vec, it: impl Stream> + 'a, extract_index: impl for<'b> Fn(&'b T) -> &'b ChunkIndices + 'a, ) -> impl Stream> + 'a { // we initialize the destination with an empty array, because we don't know // the dimensions of the array yet. On the first element we will re-initialize - from.0 = Vec::new(); - to.0 = Vec::new(); + *from = Vec::new(); + *to = Vec::new(); it.map_ok(move |t| { // these are the coordinates for the chunk let idx = extract_index(&t); @@ -1507,20 +1707,20 @@ fn aggregate_extents<'a, T: std::fmt::Debug, E>( // we initialize with the value of the first element // this obviously doesn't work for empty streams // but we never generate manifests for them - if from.0.is_empty() { - from.0 = idx.0.clone(); + if from.is_empty() { + *from = idx.0.clone(); // important to remember that `to` is not inclusive, so we need +1 - to.0 = idx.0.iter().map(|n| n + 1).collect(); + *to = idx.0.iter().map(|n| n + 1).collect(); } else { // We need to iterate over coordinates, and update the // minimum and maximum for each if needed for (coord_idx, value) in idx.0.iter().enumerate() { - if let Some(from_current) = from.0.get_mut(coord_idx) { + if let Some(from_current) = from.get_mut(coord_idx) { if value < from_current { *from_current = *value } } - if let Some(to_current) = to.0.get_mut(coord_idx) { + if let Some(to_current) = to.get_mut(coord_idx) { let range_value = value + 1; if range_value > *to_current { *to_current = range_value @@ -1535,21 +1735,19 @@ fn aggregate_extents<'a, T: std::fmt::Debug, E>( #[cfg(test)] #[allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)] mod tests { - use std::{collections::HashMap, error::Error, num::NonZeroU64}; + use std::{collections::HashMap, error::Error}; use crate::{ conflicts::{ basic_solver::{BasicConflictSolver, VersionSelection}, detector::ConflictDetector, }, - metadata::{ - ChunkKeyEncoding, ChunkShape, Codec, DataType, FillValue, StorageTransformer, - }, - refs::{fetch_ref, Ref}, + format::manifest::ManifestExtents, + refs::{fetch_tag, Ref}, repository::VersionInfo, - storage::{logging::LoggingStorage, new_in_memory_storage}, + storage::new_in_memory_storage, strategies::{ - chunk_indices, empty_writable_session, node_paths, zarr_array_metadata, + chunk_indices, empty_writable_session, node_paths, shapes_and_dims, ShapeDim, }, ObjectStorage, Repository, }; @@ -1558,11 +1756,13 @@ mod tests { use itertools::Itertools; use pretty_assertions::assert_eq; use proptest::prelude::{prop_assert, prop_assert_eq}; + use storage::logging::LoggingStorage; use test_strategy::proptest; use tokio::sync::Barrier; async fn create_memory_store_repository() -> Repository { - let storage = new_in_memory_storage().expect("failed to create in-memory store"); + let storage = + new_in_memory_storage().await.expect("failed to create in-memory store"); Repository::create(None, storage, HashMap::new()).await.unwrap() } @@ -1571,11 +1771,13 @@ mod tests { #[strategy(node_paths())] path: Path, #[strategy(empty_writable_session())] mut session: Session, ) { + let user_data = Bytes::new(); + // getting any path from an empty repository must fail prop_assert!(session.get_node(&path).await.is_err()); // adding a new group must succeed - prop_assert!(session.add_group(path.clone()).await.is_ok()); + prop_assert!(session.add_group(path.clone(), user_data.clone()).await.is_ok()); // Getting a group just added must succeed let node = session.get_node(&path).await; @@ -1586,8 +1788,8 @@ mod tests { // adding an existing group fails let matches = matches!( - session.add_group(path.clone()).await.unwrap_err(), - SessionError::AlreadyExists{node, ..} if node.path == path + session.add_group(path.clone(), user_data.clone()).await.unwrap_err(), + SessionError{kind: SessionErrorKind::AlreadyExists{node, ..},..} if node.path == path ); prop_assert!(matches); @@ -1601,7 +1803,7 @@ mod tests { prop_assert!(session.get_node(&path).await.is_err()); // adding again must succeed - prop_assert!(session.add_group(path.clone()).await.is_ok()); + prop_assert!(session.add_group(path.clone(), user_data.clone()).await.is_ok()); // deleting again must succeed prop_assert!(session.delete_group(path.clone()).await.is_ok()); @@ -1610,14 +1812,30 @@ mod tests { #[proptest(async = "tokio")] async fn test_add_delete_array( #[strategy(node_paths())] path: Path, - #[strategy(zarr_array_metadata())] metadata: ZarrArrayMetadata, + #[strategy(shapes_and_dims(None))] metadata: ShapeDim, #[strategy(empty_writable_session())] mut session: Session, ) { // new array must always succeed - prop_assert!(session.add_array(path.clone(), metadata.clone()).await.is_ok()); + prop_assert!(session + .add_array( + path.clone(), + metadata.shape.clone(), + metadata.dimension_names.clone(), + Bytes::new() + ) + .await + .is_ok()); // adding to the same path must fail - prop_assert!(session.add_array(path.clone(), metadata.clone()).await.is_err()); + prop_assert!(session + .add_array( + path.clone(), + metadata.shape.clone(), + metadata.dimension_names.clone(), + Bytes::new() + ) + .await + .is_err()); // first delete must succeed prop_assert!(session.delete_array(path.clone()).await.is_ok()); @@ -1626,7 +1844,15 @@ mod tests { prop_assert!(session.delete_array(path.clone()).await.is_ok()); // adding again must succeed - prop_assert!(session.add_array(path.clone(), metadata.clone()).await.is_ok()); + prop_assert!(session + .add_array( + path.clone(), + metadata.shape.clone(), + metadata.dimension_names.clone(), + Bytes::new() + ) + .await + .is_ok()); // deleting again must succeed prop_assert!(session.delete_array(path.clone()).await.is_ok()); @@ -1635,34 +1861,46 @@ mod tests { #[proptest(async = "tokio")] async fn test_add_array_group_clash( #[strategy(node_paths())] path: Path, - #[strategy(zarr_array_metadata())] metadata: ZarrArrayMetadata, + #[strategy(shapes_and_dims(None))] metadata: ShapeDim, #[strategy(empty_writable_session())] mut session: Session, ) { // adding a group at an existing array node must fail - prop_assert!(session.add_array(path.clone(), metadata.clone()).await.is_ok()); + prop_assert!(session + .add_array( + path.clone(), + metadata.shape.clone(), + metadata.dimension_names.clone(), + Bytes::new() + ) + .await + .is_ok()); let matches = matches!( - session.add_group(path.clone()).await.unwrap_err(), - SessionError::AlreadyExists{node, ..} if node.path == path + session.add_group(path.clone(), Bytes::new()).await.unwrap_err(), + SessionError{kind: SessionErrorKind::AlreadyExists{node, ..},..} if node.path == path ); prop_assert!(matches); let matches = matches!( session.delete_group(path.clone()).await.unwrap_err(), - SessionError::NotAGroup{node, ..} if node.path == path + SessionError{kind: SessionErrorKind::NotAGroup{node, ..},..} if node.path == path ); prop_assert!(matches); prop_assert!(session.delete_array(path.clone()).await.is_ok()); // adding an array at an existing group node must fail - prop_assert!(session.add_group(path.clone()).await.is_ok()); + prop_assert!(session.add_group(path.clone(), Bytes::new()).await.is_ok()); let matches = matches!( - session.add_array(path.clone(), metadata.clone()).await.unwrap_err(), - SessionError::AlreadyExists{node, ..} if node.path == path + session.add_array(path.clone(), + metadata.shape.clone(), + metadata.dimension_names.clone(), + Bytes::new() + ).await.unwrap_err(), + SessionError{kind: SessionErrorKind::AlreadyExists{node, ..},..} if node.path == path ); prop_assert!(matches); let matches = matches!( session.delete_array(path.clone()).await.unwrap_err(), - SessionError::NotAnArray{node, ..} if node.path == path + SessionError{kind: SessionErrorKind::NotAnArray{node, ..},..}if node.path == path ); prop_assert!(matches); prop_assert!(session.delete_group(path.clone()).await.is_ok()); @@ -1673,19 +1911,19 @@ mod tests { #[strategy(proptest::collection::vec(chunk_indices(3, 0..1_000_000), 1..50))] indices: Vec, ) { - let mut from = ChunkIndices(vec![]); - let mut to = ChunkIndices(vec![]); + let mut from = vec![]; + let mut to = vec![]; - let expected_from = ChunkIndices(vec![ + let expected_from = vec![ indices.iter().map(|i| i.0[0]).min().unwrap(), indices.iter().map(|i| i.0[1]).min().unwrap(), indices.iter().map(|i| i.0[2]).min().unwrap(), - ]); - let expected_to = ChunkIndices(vec![ + ]; + let expected_to = vec![ indices.iter().map(|i| i.0[0]).max().unwrap() + 1, indices.iter().map(|i| i.0[1]).max().unwrap() + 1, indices.iter().map(|i| i.0[2]).max().unwrap() + 1, - ]); + ]; let _ = aggregate_extents( &mut from, @@ -1702,7 +1940,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_repository_with_updates() -> Result<(), Box> { - let storage: Arc = new_in_memory_storage()?; + let storage: Arc = new_in_memory_storage().await?; let storage_settings = storage.default_settings(); let asset_manager = AssetManager::new_no_cache(Arc::clone(&storage), storage_settings.clone(), 1); @@ -1727,63 +1965,51 @@ mod tests { let manifest = Manifest::from_iter(vec![chunk1.clone(), chunk2.clone()]).await?.unwrap(); let manifest = Arc::new(manifest); - let manifest_id = &manifest.id; + let manifest_id = manifest.id(); let manifest_size = asset_manager.write_manifest(Arc::clone(&manifest)).await?; - let zarr_meta1 = ZarrArrayMetadata { - shape: vec![2, 2, 2], - data_type: DataType::Int32, - chunk_shape: ChunkShape(vec![ - NonZeroU64::new(1).unwrap(), - NonZeroU64::new(1).unwrap(), - NonZeroU64::new(1).unwrap(), - ]), - chunk_key_encoding: ChunkKeyEncoding::Slash, - fill_value: FillValue::Int32(0), - codecs: vec![Codec { name: "mycodec".to_string(), configuration: None }], - storage_transformers: Some(vec![StorageTransformer { - name: "mytransformer".to_string(), - configuration: None, - }]), - dimension_names: Some(vec![ - Some("x".to_string()), - Some("y".to_string()), - Some("t".to_string()), - ]), - }; + let shape = ArrayShape::new(vec![(2, 1), (2, 1), (2, 1)]).unwrap(); + let dimension_names = Some(vec!["x".into(), "y".into(), "t".into()]); + let manifest_ref = ManifestRef { object_id: manifest_id.clone(), - extents: ChunkIndices(vec![0, 0, 0])..ChunkIndices(vec![1, 1, 2]), + extents: ManifestExtents::new(&[0, 0, 0], &[1, 1, 2]), }; + + let group_def = Bytes::from_static(br#"{"some":"group"}"#); + let array_def = Bytes::from_static(br#"{"this":"array"}"#); + let array1_path: Path = "/array1".try_into().unwrap(); let node_id = NodeId::random(); let nodes = vec![ NodeSnapshot { path: Path::root(), id: node_id, - user_attributes: None, node_data: NodeData::Group, + user_data: group_def.clone(), }, NodeSnapshot { path: array1_path.clone(), id: array_id.clone(), - user_attributes: Some(UserAttributesSnapshot::Inline( - UserAttributes::try_new(br#"{"foo":1}"#).unwrap(), - )), - node_data: NodeData::Array(zarr_meta1.clone(), vec![manifest_ref]), + node_data: NodeData::Array { + shape: shape.clone(), + dimension_names: dimension_names.clone(), + manifests: vec![manifest_ref.clone()], + }, + user_data: array_def.clone(), }, ]; - let initial = Snapshot::initial(); + let initial = Snapshot::initial().unwrap(); let manifests = vec![ManifestFileInfo::new(manifest.as_ref(), manifest_size)]; let snapshot = Arc::new(Snapshot::from_iter( - initial.id().clone(), + None, + Some(initial.id().clone()), "message".to_string(), None, manifests, - vec![], - nodes.iter().cloned(), - )); + nodes.iter().cloned().map(Ok::), + )?); asset_manager.write_snapshot(Arc::clone(&snapshot)).await?; update_branch( storage.as_ref(), @@ -1791,11 +2017,14 @@ mod tests { "main", snapshot.id().clone(), None, - true, ) .await?; - Repository::store_config(storage.as_ref(), &RepositoryConfig::default(), None) - .await?; + Repository::store_config( + storage.as_ref(), + &RepositoryConfig::default(), + &storage::VersionInfo::for_creation(), + ) + .await?; let repo = Repository::open(None, storage, HashMap::new()).await?; let mut ds = repo.writable_session("main").await?; @@ -1805,61 +2034,75 @@ mod tests { assert_eq!(nodes.get(1).unwrap(), &node); let group_name = "/tbd-group".to_string(); - ds.add_group(group_name.clone().try_into().unwrap()).await?; + ds.add_group( + group_name.clone().try_into().unwrap(), + Bytes::copy_from_slice(b"somedef"), + ) + .await?; ds.delete_group(group_name.clone().try_into().unwrap()).await?; // deleting non-existing is no-op assert!(ds.delete_group(group_name.clone().try_into().unwrap()).await.is_ok()); assert!(ds.get_node(&group_name.try_into().unwrap()).await.is_err()); // add a new array and retrieve its node - ds.add_group("/group".try_into().unwrap()).await?; - - let zarr_meta2 = ZarrArrayMetadata { - shape: vec![3], - data_type: DataType::Int32, - chunk_shape: ChunkShape(vec![NonZeroU64::new(2).unwrap()]), - chunk_key_encoding: ChunkKeyEncoding::Slash, - fill_value: FillValue::Int32(0), - codecs: vec![Codec { name: "mycodec".to_string(), configuration: None }], - storage_transformers: Some(vec![StorageTransformer { - name: "mytransformer".to_string(), - configuration: None, - }]), - dimension_names: Some(vec![Some("t".to_string())]), - }; + ds.add_group("/group".try_into().unwrap(), Bytes::copy_from_slice(b"somedef2")) + .await?; + + let shape2 = ArrayShape::new(vec![(2, 2)]).unwrap(); + let dimension_names2 = Some(vec!["t".into()]); + + let array_def2 = Bytes::from_static(br#"{"this":"other array"}"#); let new_array_path: Path = "/group/array2".to_string().try_into().unwrap(); - ds.add_array(new_array_path.clone(), zarr_meta2.clone()).await?; + ds.add_array( + new_array_path.clone(), + shape2.clone(), + dimension_names2.clone(), + array_def2.clone(), + ) + .await?; ds.delete_array(new_array_path.clone()).await?; // Delete a non-existent array is no-op assert!(ds.delete_array(new_array_path.clone()).await.is_ok()); assert!(ds.get_node(&new_array_path.clone()).await.is_err()); - ds.add_array(new_array_path.clone(), zarr_meta2.clone()).await?; + ds.add_array( + new_array_path.clone(), + shape2.clone(), + dimension_names2.clone(), + array_def2.clone(), + ) + .await?; let node = ds.get_node(&new_array_path).await; assert!(matches!( node.ok(), - Some(NodeSnapshot {path, user_attributes, node_data,..}) - if path== new_array_path.clone() && user_attributes.is_none() && node_data == NodeData::Array(zarr_meta2.clone(), vec![]) + Some(NodeSnapshot {path,node_data, user_data, .. }) + if path== new_array_path.clone() && + user_data == array_def2 && + node_data == NodeData::Array{shape:shape2, dimension_names:dimension_names2, manifests: vec![]} )); - // set user attributes for the new array and retrieve them - ds.set_user_attributes( - new_array_path.clone(), - Some(UserAttributes::try_new(br#"{"n":42}"#).unwrap()), + // update the array definition + let shape3 = ArrayShape::new(vec![(4, 3)]).unwrap(); + let dimension_names3 = Some(vec!["tt".into()]); + + let array_def3 = Bytes::from_static(br#"{"this":"yet other array"}"#); + ds.update_array( + &new_array_path.clone(), + shape3.clone(), + dimension_names3.clone(), + array_def3.clone(), ) .await?; let node = ds.get_node(&new_array_path).await; assert!(matches!( node.ok(), - Some(NodeSnapshot {path, user_attributes, node_data, ..}) + Some(NodeSnapshot {path,node_data, user_data, .. }) if path == "/group/array2".try_into().unwrap() && - user_attributes == Some(UserAttributesSnapshot::Inline( - UserAttributes::try_new(br#"{"n":42}"#).unwrap() - )) && - node_data == NodeData::Array(zarr_meta2.clone(), vec![]) + user_data == array_def3 && + node_data == NodeData::Array { shape:shape3, dimension_names: dimension_names3, manifests: vec![] } )); let payload = ds.get_chunk_writer()(Bytes::copy_from_slice(b"foo")).await?; @@ -1873,30 +2116,21 @@ mod tests { let non_chunk = ds.get_chunk_ref(&new_array_path, &ChunkIndices(vec![1])).await?; assert_eq!(non_chunk, None); - // update old array use attributes and check them - ds.set_user_attributes( - array1_path.clone(), - Some(UserAttributes::try_new(br#"{"updated": true}"#).unwrap()), + // update old array zarr metadata and check it + let shape3 = ArrayShape::new(vec![(8, 3)]).unwrap(); + let dimension_names3 = Some(vec!["tt".into()]); + + let array_def3 = Bytes::from_static(br#"{"this":"more arrays"}"#); + ds.update_array( + &array1_path.clone(), + shape3.clone(), + dimension_names3.clone(), + array_def3.clone(), ) .await?; - let node = ds.get_node(&array1_path).await.unwrap(); - assert_eq!( - node.user_attributes, - Some(UserAttributesSnapshot::Inline( - UserAttributes::try_new(br#"{"updated": true}"#).unwrap() - )) - ); - - // update old array zarr metadata and check it - let new_zarr_meta1 = ZarrArrayMetadata { shape: vec![2, 2, 3], ..zarr_meta1 }; - ds.update_array(array1_path.clone(), new_zarr_meta1).await?; let node = ds.get_node(&array1_path).await; - if let Ok(NodeSnapshot { - node_data: NodeData::Array(ZarrArrayMetadata { shape, .. }, _), - .. - }) = &node - { - assert_eq!(shape, &vec![2, 2, 3]); + if let Ok(NodeSnapshot { node_data: NodeData::Array { shape, .. }, .. }) = &node { + assert_eq!(shape, &shape3); } else { panic!("Failed to update zarr metadata"); } @@ -1918,19 +2152,12 @@ mod tests { ) .await?; assert_eq!(chunk, Some(data)); - - let path: Path = "/group/array2".try_into().unwrap(); - let node = ds.get_node(&path).await; - assert!(ds.change_set.has_updated_attributes(&node.as_ref().unwrap().id)); - assert!(ds.delete_array(path.clone()).await.is_ok()); - assert!(!ds.change_set.has_updated_attributes(&node?.id)); - Ok(()) } #[tokio::test(flavor = "multi_thread")] async fn test_repository_with_updates_and_writes() -> Result<(), Box> { - let backend: Arc = new_in_memory_storage()?; + let backend: Arc = new_in_memory_storage().await?; let logging = Arc::new(LoggingStorage::new(Arc::clone(&backend))); let logging_c: Arc = logging.clone(); @@ -1940,59 +2167,66 @@ mod tests { let mut ds = repository.writable_session("main").await?; + let initial_snapshot = repository.lookup_branch("main").await?; + + let diff = ds.status().await?; + assert!(diff.is_empty()); + + let user_data = Bytes::copy_from_slice(b"foo"); // add a new array and retrieve its node - ds.add_group(Path::root()).await?; - let snapshot_id = + ds.add_group(Path::root(), user_data.clone()).await?; + let diff = ds.status().await?; + assert!(!diff.is_empty()); + assert_eq!(diff.new_groups, [Path::root()].into()); + + let first_commit = ds.commit("commit", Some(SnapshotProperties::default())).await?; // We need a new session after the commit let mut ds = repository.writable_session("main").await?; //let node_id3 = NodeId::random(); - assert_eq!(snapshot_id, ds.snapshot_id); + assert_eq!(first_commit, ds.snapshot_id); assert!(matches!( ds.get_node(&Path::root()).await.ok(), - Some(NodeSnapshot { path, user_attributes, node_data, .. }) - if path == Path::root() && user_attributes.is_none() && node_data == NodeData::Group + Some(NodeSnapshot { path, user_data: actual_user_data, node_data, .. }) + if path == Path::root() && user_data == actual_user_data && node_data == NodeData::Group )); - ds.add_group("/group".try_into().unwrap()).await?; + let user_data2 = Bytes::copy_from_slice(b"bar"); + ds.add_group("/group".try_into().unwrap(), user_data2.clone()).await?; let _snapshot_id = ds.commit("commit", Some(SnapshotProperties::default())).await?; let mut ds = repository.writable_session("main").await?; assert!(matches!( ds.get_node(&Path::root()).await.ok(), - Some(NodeSnapshot { path, user_attributes, node_data, .. }) - if path == Path::root() && user_attributes.is_none() && node_data == NodeData::Group + Some(NodeSnapshot { path, user_data: actual_user_data, node_data, .. }) + if path == Path::root() && user_data==actual_user_data && node_data == NodeData::Group )); assert!(matches!( ds.get_node(&"/group".try_into().unwrap()).await.ok(), - Some(NodeSnapshot { path, user_attributes, node_data, .. }) - if path == "/group".try_into().unwrap() && user_attributes.is_none() && node_data == NodeData::Group + Some(NodeSnapshot { path, user_data:actual_user_data, node_data, .. }) + if path == "/group".try_into().unwrap() && user_data2 == actual_user_data && node_data == NodeData::Group )); - let zarr_meta = ZarrArrayMetadata { - shape: vec![1, 1, 2], - data_type: DataType::Float16, - chunk_shape: ChunkShape(vec![ - NonZeroU64::new(1).unwrap(), - NonZeroU64::new(1).unwrap(), - NonZeroU64::new(1).unwrap(), - ]), - chunk_key_encoding: ChunkKeyEncoding::Slash, - fill_value: FillValue::Float16(f32::NEG_INFINITY), - codecs: vec![Codec { name: "mycodec".to_string(), configuration: None }], - storage_transformers: Some(vec![StorageTransformer { - name: "mytransformer".to_string(), - configuration: None, - }]), - dimension_names: Some(vec![Some("t".to_string())]), - }; + let shape = ArrayShape::new([(1, 1), (1, 1), (2, 1)]).unwrap(); + let dimension_names = Some(vec!["x".into(), "y".into(), "z".into()]); + let array_user_data = Bytes::copy_from_slice(b"array"); let new_array_path: Path = "/group/array1".try_into().unwrap(); - ds.add_array(new_array_path.clone(), zarr_meta.clone()).await?; + ds.add_array( + new_array_path.clone(), + shape.clone(), + dimension_names.clone(), + array_user_data.clone(), + ) + .await?; + + let diff = ds.status().await?; + assert!(!diff.is_empty()); + assert_eq!(diff.new_arrays, [new_array_path.clone()].into()); // wo commit to test the case of a chunkless array let _snapshot_id = @@ -2001,7 +2235,13 @@ mod tests { let mut ds = repository.writable_session("main").await?; let new_new_array_path: Path = "/group/array2".try_into().unwrap(); - ds.add_array(new_new_array_path.clone(), zarr_meta.clone()).await?; + ds.add_array( + new_new_array_path.clone(), + shape.clone(), + dimension_names.clone(), + array_user_data.clone(), + ) + .await?; assert!(ds.has_uncommitted_changes()); let changes = ds.discard_changes(); @@ -2016,28 +2256,39 @@ mod tests { ) .await?; + let diff = ds.status().await?; + assert!(!diff.is_empty()); + assert_eq!( + diff.updated_chunks, + [(new_array_path.clone(), [ChunkIndices(vec![0, 0, 0])].into())].into() + ); + let _snapshot_id = ds.commit("commit", Some(SnapshotProperties::default())).await?; let mut ds = repository.writable_session("main").await?; assert!(matches!( ds.get_node(&Path::root()).await.ok(), - Some(NodeSnapshot { path, user_attributes, node_data, .. }) - if path == Path::root() && user_attributes.is_none() && node_data == NodeData::Group + Some(NodeSnapshot { path, user_data: actual_user_data, node_data, .. }) + if path == Path::root() && user_data == actual_user_data && node_data == NodeData::Group )); assert!(matches!( ds.get_node(&"/group".try_into().unwrap()).await.ok(), - Some(NodeSnapshot { path, user_attributes, node_data, .. }) - if path == "/group".try_into().unwrap() && user_attributes.is_none() && node_data == NodeData::Group + Some(NodeSnapshot { path, user_data: actual_user_data, node_data, .. }) + if path == "/group".try_into().unwrap() && user_data2 == actual_user_data && node_data == NodeData::Group )); assert!(matches!( ds.get_node(&new_array_path).await.ok(), Some(NodeSnapshot { path, - user_attributes: None, - node_data: NodeData::Array(meta, manifests), + user_data: actual_user_data, + node_data: NodeData::Array{ shape: actual_shape, dimension_names: actual_dim, manifests }, .. - }) if path == new_array_path && meta == zarr_meta.clone() && manifests.len() == 1 + }) if path == new_array_path + && actual_user_data == array_user_data + && actual_shape == shape + && actual_dim == dimension_names + && manifests.len() == 1 )); assert_eq!( ds.get_chunk_ref(&new_array_path, &ChunkIndices(vec![0, 0, 0])).await?, @@ -2067,10 +2318,10 @@ mod tests { let snap = repository.asset_manager().fetch_snapshot(&previous_snapshot_id).await?; match &snap.get_node(&new_array_path)?.node_data { - NodeData::Array(_, manifests) => { + NodeData::Array { manifests, .. } => { assert_eq!( manifests.first().unwrap().extents, - ChunkIndices(vec![0, 0, 0])..ChunkIndices(vec![1, 1, 2]) + ManifestExtents::new(&[0, 0, 0], &[1, 1, 2]) ); } NodeData::Group => { @@ -2091,14 +2342,15 @@ mod tests { ds.set_chunk_ref(new_array_path.clone(), ChunkIndices(vec![0, 0, 1]), None) .await?; - let new_meta = ZarrArrayMetadata { shape: vec![1, 1, 1], ..zarr_meta }; + let new_shape = ArrayShape::new([(1, 1), (1, 1), (1, 1)]).unwrap(); + let new_dimension_names = Some(vec!["X".into(), "X".into(), "Z".into()]); + let new_user_data = Bytes::copy_from_slice(b"new data"); // we change zarr metadata - ds.update_array(new_array_path.clone(), new_meta.clone()).await?; - - // we change user attributes metadata - ds.set_user_attributes( - new_array_path.clone(), - Some(UserAttributes::try_new(br#"{"foo":42}"#).unwrap()), + ds.update_array( + &new_array_path.clone(), + new_shape.clone(), + new_dimension_names.clone(), + new_user_data.clone(), ) .await?; @@ -2106,10 +2358,10 @@ mod tests { let snap = repository.asset_manager().fetch_snapshot(&snapshot_id).await?; match &snap.get_node(&new_array_path)?.node_data { - NodeData::Array(_, manifests) => { + NodeData::Array { manifests, .. } => { assert_eq!( manifests.first().unwrap().extents, - ChunkIndices(vec![0, 0, 0])..ChunkIndices(vec![1, 1, 1]) + ManifestExtents::new(&[0, 0, 0], &[1, 1, 1]) ); } NodeData::Group => { @@ -2133,12 +2385,14 @@ mod tests { ds.get_node(&new_array_path).await.ok(), Some(NodeSnapshot { path, - user_attributes: Some(atts), - node_data: NodeData::Array(meta, manifests), + user_data: actual_user_data, + node_data: NodeData::Array{shape: actual_shape, dimension_names: actual_dims, manifests}, .. - }) if path == new_array_path && meta == new_meta.clone() && + }) if path == new_array_path && + actual_user_data == new_user_data && manifests.len() == 1 && - atts == UserAttributesSnapshot::Inline(UserAttributes::try_new(br#"{"foo":42}"#).unwrap()) + actual_shape == new_shape && + actual_dims == new_dimension_names )); // since we wrote every asset we should only have one fetch for the initial snapshot @@ -2160,6 +2414,59 @@ mod tests { Some(ChunkPayload::Inline("new chunk".into())) ); + let diff = repository + .diff( + &VersionInfo::SnapshotId(initial_snapshot), + &VersionInfo::BranchTipRef("main".to_string()), + ) + .await?; + + assert!(diff.deleted_groups.is_empty()); + assert!(diff.deleted_arrays.is_empty()); + assert_eq!( + &diff.new_groups, + &["/".try_into().unwrap(), "/group".try_into().unwrap()].into() + ); + assert_eq!( + &diff.new_arrays, + &[new_array_path.clone()].into() // we never committed array2 + ); + assert_eq!( + &diff.updated_chunks, + &[( + new_array_path.clone(), + [ChunkIndices(vec![0, 0, 0]), ChunkIndices(vec![0, 0, 1])].into() + )] + .into() + ); + assert_eq!(&diff.updated_arrays, &[new_array_path.clone()].into()); + assert_eq!(&diff.updated_groups, &[].into()); + + let diff = repository + .diff( + &VersionInfo::SnapshotId(first_commit), + &VersionInfo::BranchTipRef("main".to_string()), + ) + .await?; + + // Diff should not include the changes in `from` + assert!(diff.deleted_groups.is_empty()); + assert!(diff.deleted_arrays.is_empty()); + assert_eq!(&diff.new_groups, &["/group".try_into().unwrap()].into()); + assert_eq!( + &diff.new_arrays, + &[new_array_path.clone()].into() // we never committed array2 + ); + assert_eq!( + &diff.updated_chunks, + &[( + new_array_path.clone(), + [ChunkIndices(vec![0, 0, 0]), ChunkIndices(vec![0, 0, 1])].into() + )] + .into() + ); + assert_eq!(&diff.updated_arrays, &[new_array_path.clone()].into()); + assert_eq!(&diff.updated_groups, &[].into()); Ok(()) } @@ -2167,8 +2474,8 @@ mod tests { async fn test_basic_delete_and_flush() -> Result<(), Box> { let repository = create_memory_store_repository().await; let mut ds = repository.writable_session("main").await?; - ds.add_group(Path::root()).await?; - ds.add_group("/1".try_into().unwrap()).await?; + ds.add_group(Path::root(), Bytes::copy_from_slice(b"")).await?; + ds.add_group("/1".try_into().unwrap(), Bytes::copy_from_slice(b"")).await?; ds.delete_group("/1".try_into().unwrap()).await?; assert_eq!(ds.list_nodes().await?.count(), 1); ds.commit("commit", None).await?; @@ -2186,8 +2493,8 @@ mod tests { async fn test_basic_delete_after_flush() -> Result<(), Box> { let repository = create_memory_store_repository().await; let mut ds = repository.writable_session("main").await?; - ds.add_group(Path::root()).await?; - ds.add_group("/1".try_into().unwrap()).await?; + ds.add_group(Path::root(), Bytes::copy_from_slice(b"")).await?; + ds.add_group("/1".try_into().unwrap(), Bytes::copy_from_slice(b"")).await?; ds.commit("commit", None).await?; let mut ds = repository.writable_session("main").await?; @@ -2202,7 +2509,7 @@ mod tests { async fn test_commit_after_deleting_old_node() -> Result<(), Box> { let repository = create_memory_store_repository().await; let mut ds = repository.writable_session("main").await?; - ds.add_group(Path::root()).await?; + ds.add_group(Path::root(), Bytes::copy_from_slice(b"")).await?; ds.commit("commit", None).await?; let mut ds = repository.writable_session("main").await?; @@ -2218,15 +2525,16 @@ mod tests { #[tokio::test] async fn test_delete_children() -> Result<(), Box> { + let def = Bytes::copy_from_slice(b""); let repository = create_memory_store_repository().await; let mut ds = repository.writable_session("main").await?; - ds.add_group(Path::root()).await?; + ds.add_group(Path::root(), def.clone()).await?; ds.commit("initialize", None).await?; let mut ds = repository.writable_session("main").await?; - ds.add_group("/a".try_into().unwrap()).await?; - ds.add_group("/b".try_into().unwrap()).await?; - ds.add_group("/b/bb".try_into().unwrap()).await?; + ds.add_group("/a".try_into().unwrap(), def.clone()).await?; + ds.add_group("/b".try_into().unwrap(), def.clone()).await?; + ds.add_group("/b/bb".try_into().unwrap(), def.clone()).await?; ds.delete_group("/b".try_into().unwrap()).await?; assert!(ds.get_group(&"/b".try_into().unwrap()).await.is_err()); @@ -2247,10 +2555,11 @@ mod tests { async fn test_delete_children_of_old_nodes() -> Result<(), Box> { let repository = create_memory_store_repository().await; let mut ds = repository.writable_session("main").await?; - ds.add_group(Path::root()).await?; - ds.add_group("/a".try_into().unwrap()).await?; - ds.add_group("/b".try_into().unwrap()).await?; - ds.add_group("/b/bb".try_into().unwrap()).await?; + let def = Bytes::copy_from_slice(b""); + ds.add_group(Path::root(), def.clone()).await?; + ds.add_group("/a".try_into().unwrap(), def.clone()).await?; + ds.add_group("/b".try_into().unwrap(), def.clone()).await?; + ds.add_group("/b/bb".try_into().unwrap(), def.clone()).await?; ds.commit("commit", None).await?; let mut ds = repository.writable_session("main").await?; @@ -2262,32 +2571,25 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_all_chunks_iterator() -> Result<(), Box> { - let storage: Arc = new_in_memory_storage()?; + let storage: Arc = new_in_memory_storage().await?; let repo = Repository::create(None, storage, HashMap::new()).await?; let mut ds = repo.writable_session("main").await?; + let def = Bytes::copy_from_slice(b""); // add a new array and retrieve its node - ds.add_group(Path::root()).await?; - let zarr_meta = ZarrArrayMetadata { - shape: vec![4, 2, 4], - data_type: DataType::Int32, - chunk_shape: ChunkShape(vec![ - NonZeroU64::new(2).unwrap(), - NonZeroU64::new(1).unwrap(), - NonZeroU64::new(2).unwrap(), - ]), - chunk_key_encoding: ChunkKeyEncoding::Slash, - fill_value: FillValue::Int32(0), - codecs: vec![Codec { name: "mycodec".to_string(), configuration: None }], - storage_transformers: Some(vec![StorageTransformer { - name: "mytransformer".to_string(), - configuration: None, - }]), - dimension_names: Some(vec![Some("t".to_string())]), - }; + ds.add_group(Path::root(), def.clone()).await?; + + let shape = ArrayShape::new(vec![(4, 2), (2, 1), (4, 2)]).unwrap(); + let dimension_names = Some(vec!["t".into()]); let new_array_path: Path = "/array".try_into().unwrap(); - ds.add_array(new_array_path.clone(), zarr_meta.clone()).await?; + ds.add_array( + new_array_path.clone(), + shape.clone(), + dimension_names.clone(), + def.clone(), + ) + .await?; // we 3 chunks ds.set_chunk_ref( new_array_path.clone(), @@ -2338,7 +2640,7 @@ mod tests { #[tokio::test] async fn test_manifests_shrink() -> Result<(), Box> { - let in_mem_storage = Arc::new(ObjectStorage::new_in_memory()?); + let in_mem_storage = Arc::new(ObjectStorage::new_in_memory().await?); let storage: Arc = in_mem_storage.clone(); let repo = Repository::create(None, Arc::clone(&storage), HashMap::new()).await?; @@ -2361,29 +2663,19 @@ mod tests { ); let mut ds = repo.writable_session("main").await?; - ds.add_group(Path::root()).await?; - let zarr_meta = ZarrArrayMetadata { - shape: vec![5, 5], - data_type: DataType::Float16, - chunk_shape: ChunkShape(vec![ - NonZeroU64::new(2).unwrap(), - NonZeroU64::new(2).unwrap(), - ]), - chunk_key_encoding: ChunkKeyEncoding::Slash, - fill_value: FillValue::Float16(f32::NEG_INFINITY), - codecs: vec![Codec { name: "mycodec".to_string(), configuration: None }], - storage_transformers: Some(vec![StorageTransformer { - name: "mytransformer".to_string(), - configuration: None, - }]), - dimension_names: Some(vec![Some("t".to_string())]), - }; + let def = Bytes::copy_from_slice(b""); + + let shape = ArrayShape::new(vec![(5, 2), (5, 1)]).unwrap(); + let dimension_names = Some(vec!["t".into()]); + ds.add_group(Path::root(), def.clone()).await?; let a1path: Path = "/array1".try_into()?; let a2path: Path = "/array2".try_into()?; - ds.add_array(a1path.clone(), zarr_meta.clone()).await?; - ds.add_array(a2path.clone(), zarr_meta.clone()).await?; + ds.add_array(a1path.clone(), shape.clone(), dimension_names.clone(), def.clone()) + .await?; + ds.add_array(a2path.clone(), shape.clone(), dimension_names.clone(), def.clone()) + .await?; let _ = ds.commit("first commit", None).await?; @@ -2445,7 +2737,7 @@ mod tests { ); let manifest_id = match ds.get_array(&a1path).await?.node_data { - NodeData::Array(_, manifests) => { + NodeData::Array { manifests, .. } => { manifests.first().as_ref().unwrap().object_id.clone() } NodeData::Group => panic!("must be an array"), @@ -2473,7 +2765,7 @@ mod tests { ); let manifest_id = match ds.get_array(&a1path).await?.node_data { - NodeData::Array(_, manifests) => { + NodeData::Array { manifests, .. } => { manifests.first().as_ref().unwrap().object_id.clone() } NodeData::Group => panic!("must be an array"), @@ -2512,7 +2804,7 @@ mod tests { ); let manifest_id = match ds.get_array(&a1path).await?.node_data { - NodeData::Array(_, manifests) => { + NodeData::Array { manifests, .. } => { manifests.first().as_ref().unwrap().object_id.clone() } NodeData::Group => panic!("must be an array"), @@ -2528,7 +2820,7 @@ mod tests { let _snap_id = ds.commit("chunk deleted", None).await?; let manifests = match ds.get_array(&a1path).await?.node_data { - NodeData::Array(_, manifests) => manifests, + NodeData::Array { manifests, .. } => manifests, NodeData::Group => panic!("must be an array"), }; assert!(manifests.is_empty()); @@ -2564,54 +2856,46 @@ mod tests { let storage_settings = storage.default_settings(); let mut ds = repo.writable_session("main").await?; + let def = Bytes::copy_from_slice(b""); + // add a new array and retrieve its node - ds.add_group(Path::root()).await?; + ds.add_group(Path::root(), def.clone()).await?; let new_snapshot_id = ds.commit("first commit", None).await?; assert_eq!( new_snapshot_id, - fetch_ref(storage.as_ref(), &storage_settings, "main").await?.1.snapshot + fetch_branch_tip(storage.as_ref(), &storage_settings, "main").await?.snapshot ); assert_eq!(&new_snapshot_id, ds.snapshot_id()); repo.create_tag("v1", &new_snapshot_id).await?; - let (ref_name, ref_data) = - fetch_ref(storage.as_ref(), &storage_settings, "v1").await?; - assert_eq!(ref_name, Ref::Tag("v1".to_string())); + let ref_data = fetch_tag(storage.as_ref(), &storage_settings, "v1").await?; assert_eq!(new_snapshot_id, ref_data.snapshot); assert!(matches!( ds.get_node(&Path::root()).await.ok(), - Some(NodeSnapshot { path, user_attributes, node_data, ..}) - if path == Path::root() && user_attributes.is_none() && node_data == NodeData::Group + Some(NodeSnapshot { node_data, path, ..}) + if path == Path::root() && node_data == NodeData::Group )); let mut ds = repo.writable_session("main").await?; assert!(matches!( ds.get_node(&Path::root()).await.ok(), - Some(NodeSnapshot { path, user_attributes, node_data, ..}) - if path == Path::root() && user_attributes.is_none() && node_data == NodeData::Group + Some(NodeSnapshot { path, node_data, ..}) + if path == Path::root() && node_data == NodeData::Group )); - let zarr_meta = ZarrArrayMetadata { - shape: vec![1, 1, 2], - data_type: DataType::Int32, - chunk_shape: ChunkShape(vec![ - NonZeroU64::new(2).unwrap(), - NonZeroU64::new(2).unwrap(), - NonZeroU64::new(2).unwrap(), - ]), - chunk_key_encoding: ChunkKeyEncoding::Slash, - fill_value: FillValue::Int32(0), - codecs: vec![Codec { name: "mycodec".to_string(), configuration: None }], - storage_transformers: Some(vec![StorageTransformer { - name: "mytransformer".to_string(), - configuration: None, - }]), - dimension_names: Some(vec![Some("t".to_string())]), - }; + + let shape = ArrayShape::new(vec![(1, 1), (2, 1), (4, 2)]).unwrap(); + let dimension_names = Some(vec!["t".into()]); let new_array_path: Path = "/array1".try_into().unwrap(); - ds.add_array(new_array_path.clone(), zarr_meta.clone()).await?; + ds.add_array( + new_array_path.clone(), + shape.clone(), + dimension_names.clone(), + def.clone(), + ) + .await?; ds.set_chunk_ref( new_array_path.clone(), ChunkIndices(vec![0, 0, 0]), @@ -2619,9 +2903,9 @@ mod tests { ) .await?; let new_snapshot_id = ds.commit("second commit", None).await?; - let (ref_name, ref_data) = - fetch_ref(storage.as_ref(), &storage_settings, Ref::DEFAULT_BRANCH).await?; - assert_eq!(ref_name, Ref::Branch("main".to_string())); + let ref_data = + fetch_branch_tip(storage.as_ref(), &storage_settings, Ref::DEFAULT_BRANCH) + .await?; assert_eq!(new_snapshot_id, ref_data.snapshot); let parents = repo @@ -2647,8 +2931,9 @@ mod tests { let mut ds1 = repository.writable_session("main").await?; let mut ds2 = repository.writable_session("main").await?; - ds1.add_group("/a".try_into().unwrap()).await?; - ds2.add_group("/b".try_into().unwrap()).await?; + let def = Bytes::copy_from_slice(b""); + ds1.add_group("/a".try_into().unwrap(), def.clone()).await?; + ds2.add_group("/b".try_into().unwrap(), def.clone()).await?; let barrier = Arc::new(Barrier::new(2)); let barrier_c = Arc::clone(&barrier); @@ -2671,10 +2956,16 @@ mod tests { let ok = match (&res1, &res2) { ( Ok(new_snap), - Err(SessionError::Conflict { expected_parent: _, actual_parent }), + Err(SessionError { + kind: SessionErrorKind::Conflict { expected_parent: _, actual_parent }, + .. + }), ) if Some(new_snap) == actual_parent.as_ref() => true, ( - Err(SessionError::Conflict { expected_parent: _, actual_parent }), + Err(SessionError { + kind: SessionErrorKind::Conflict { expected_parent: _, actual_parent }, + .. + }), Ok(new_snap), ) if Some(new_snap) == actual_parent.as_ref() => true, _ => false, @@ -2699,29 +2990,17 @@ mod tests { #[tokio::test] async fn test_setting_w_invalid_coords() -> Result<(), Box> { - let in_mem_storage = new_in_memory_storage()?; + let in_mem_storage = new_in_memory_storage().await?; let storage: Arc = in_mem_storage.clone(); let repo = Repository::create(None, Arc::clone(&storage), HashMap::new()).await?; let mut ds = repo.writable_session("main").await?; - ds.add_group(Path::root()).await?; - let zarr_meta = ZarrArrayMetadata { - shape: vec![5, 5], - data_type: DataType::Float16, - chunk_shape: ChunkShape(vec![ - NonZeroU64::new(2).unwrap(), - NonZeroU64::new(2).unwrap(), - ]), - chunk_key_encoding: ChunkKeyEncoding::Slash, - fill_value: FillValue::Float16(f32::NEG_INFINITY), - codecs: vec![Codec { name: "mycodec".to_string(), configuration: None }], - storage_transformers: None, - dimension_names: None, - }; + let shape = ArrayShape::new(vec![(5, 2), (5, 2)]).unwrap(); + ds.add_group(Path::root(), Bytes::new()).await?; let apath: Path = "/array1".try_into()?; - ds.add_array(apath.clone(), zarr_meta.clone()).await?; + ds.add_array(apath.clone(), shape, None, Bytes::new()).await?; ds.commit("first commit", None).await?; @@ -2756,7 +3035,10 @@ mod tests { .await; match bad_result { - Err(SessionError::InvalidIndex { coords, path }) => { + Err(SessionError { + kind: SessionErrorKind::InvalidIndex { coords, path }, + .. + }) => { assert_eq!(coords, ChunkIndices(vec![3, 0])); assert_eq!(path, apath); } @@ -2773,8 +3055,14 @@ mod tests { let repository = create_memory_store_repository().await; let mut ds = repository.writable_session("main").await?; - ds.add_group("/foo/bar".try_into().unwrap()).await?; - ds.add_array("/foo/bar/some-array".try_into().unwrap(), basic_meta()).await?; + ds.add_group("/foo/bar".try_into().unwrap(), Bytes::new()).await?; + ds.add_array( + "/foo/bar/some-array".try_into().unwrap(), + basic_shape(), + None, + Bytes::new(), + ) + .await?; ds.commit("create directory", None).await?; Ok(repository) @@ -2788,22 +3076,20 @@ mod tests { Ok((ds, ds2)) } - fn basic_meta() -> ZarrArrayMetadata { - ZarrArrayMetadata { - shape: vec![5], - data_type: DataType::Int32, - chunk_shape: ChunkShape(vec![NonZeroU64::new(1).unwrap()]), - chunk_key_encoding: ChunkKeyEncoding::Slash, - fill_value: FillValue::Int32(0), - codecs: vec![], - storage_transformers: None, - dimension_names: None, - } + fn basic_shape() -> ArrayShape { + ArrayShape::new(vec![(5, 1)]).unwrap() + } + + fn user_data() -> Bytes { + Bytes::new() } fn assert_has_conflict(conflict: &Conflict, rebase_result: SessionResult<()>) { match rebase_result { - Err(SessionError::RebaseFailed { conflicts, .. }) => { + Err(SessionError { + kind: SessionErrorKind::RebaseFailed { conflicts, .. }, + .. + }) => { assert!(conflicts.contains(conflict)); } other => panic!("test failed, expected conflict, got {:?}", other), @@ -2820,10 +3106,10 @@ mod tests { let (mut ds1, mut ds2) = get_sessions_for_conflict().await?; let conflict_path: Path = "/foo/bar/conflict".try_into().unwrap(); - ds1.add_group(conflict_path.clone()).await?; + ds1.add_group(conflict_path.clone(), user_data()).await?; ds1.commit("create group", None).await?; - ds2.add_array(conflict_path.clone(), basic_meta()).await?; + ds2.add_array(conflict_path.clone(), basic_shape(), None, user_data()).await?; ds2.commit("create array", None).await.unwrap_err(); assert_has_conflict( &Conflict::NewNodeConflictsWithExistingNode(conflict_path), @@ -2842,11 +3128,11 @@ mod tests { let (mut ds1, mut ds2) = get_sessions_for_conflict().await?; let conflict_path: Path = "/foo/bar/conflict".try_into().unwrap(); - ds1.add_array(conflict_path.clone(), basic_meta()).await?; + ds1.add_array(conflict_path.clone(), basic_shape(), None, user_data()).await?; ds1.commit("create array", None).await?; let inner_path: Path = "/foo/bar/conflict/inner".try_into().unwrap(); - ds2.add_array(inner_path.clone(), basic_meta()).await?; + ds2.add_array(inner_path.clone(), basic_shape(), None, user_data()).await?; ds2.commit("create inner array", None).await.unwrap_err(); assert_has_conflict( &Conflict::NewNodeInInvalidGroup(conflict_path), @@ -2865,10 +3151,10 @@ mod tests { let (mut ds1, mut ds2) = get_sessions_for_conflict().await?; let path: Path = "/foo/bar/some-array".try_into().unwrap(); - ds1.update_array(path.clone(), basic_meta()).await?; + ds1.update_array(&path.clone(), basic_shape(), None, user_data()).await?; ds1.commit("update array", None).await?; - ds2.update_array(path.clone(), basic_meta()).await?; + ds2.update_array(&path.clone(), basic_shape(), None, user_data()).await?; ds2.commit("update array again", None).await.unwrap_err(); assert_has_conflict( &Conflict::ZarrMetadataDoubleUpdate(path), @@ -2890,7 +3176,7 @@ mod tests { ds1.delete_array(path.clone()).await?; ds1.commit("delete array", None).await?; - ds2.update_array(path.clone(), basic_meta()).await?; + ds2.update_array(&path.clone(), basic_shape(), None, user_data()).await?; ds2.commit("update array again", None).await.unwrap_err(); assert_has_conflict( &Conflict::ZarrMetadataUpdateOfDeletedArray(path), @@ -2899,63 +3185,6 @@ mod tests { Ok(()) } - #[tokio::test()] - /// Test conflict detection - /// - /// This session: uptade user attributes - /// Previous commit: update user attributes - async fn test_conflict_detection_double_user_atts_edit() -> Result<(), Box> - { - let (mut ds1, mut ds2) = get_sessions_for_conflict().await?; - - let path: Path = "/foo/bar/some-array".try_into().unwrap(); - ds1.set_user_attributes( - path.clone(), - Some(UserAttributes::try_new(br#"{"foo":"bar"}"#).unwrap()), - ) - .await?; - ds1.commit("update array", None).await?; - - ds2.set_user_attributes( - path.clone(), - Some(UserAttributes::try_new(br#"{"foo":"bar"}"#).unwrap()), - ) - .await?; - ds2.commit("update array user atts", None).await.unwrap_err(); - let node_id = ds2.get_array(&path).await?.id; - assert_has_conflict( - &Conflict::UserAttributesDoubleUpdate { path, node_id }, - ds2.rebase(&ConflictDetector).await, - ); - Ok(()) - } - - #[tokio::test()] - /// Test conflict detection - /// - /// This session: uptade user attributes - /// Previous commit: delete same array - async fn test_conflict_detection_user_atts_edit_of_deleted( - ) -> Result<(), Box> { - let (mut ds1, mut ds2) = get_sessions_for_conflict().await?; - - let path: Path = "/foo/bar/some-array".try_into().unwrap(); - ds1.delete_array(path.clone()).await?; - ds1.commit("delete array", None).await?; - - ds2.set_user_attributes( - path.clone(), - Some(UserAttributes::try_new(br#"{"foo":"bar"}"#).unwrap()), - ) - .await?; - ds2.commit("update array user atts", None).await.unwrap_err(); - assert_has_conflict( - &Conflict::UserAttributesUpdateOfDeletedNode(path), - ds2.rebase(&ConflictDetector).await, - ); - Ok(()) - } - #[tokio::test()] /// Test conflict detection /// @@ -2966,7 +3195,7 @@ mod tests { let (mut ds1, mut ds2) = get_sessions_for_conflict().await?; let path: Path = "/foo/bar/some-array".try_into().unwrap(); - ds1.update_array(path.clone(), basic_meta()).await?; + ds1.update_array(&path.clone(), basic_shape(), None, user_data()).await?; ds1.commit("update array", None).await?; let node = ds2.get_node(&path).await.unwrap(); @@ -2979,33 +3208,6 @@ mod tests { Ok(()) } - #[tokio::test()] - /// Test conflict detection - /// - /// This session: delete array - /// Previous commit: update same array user attributes - async fn test_conflict_detection_delete_when_array_user_atts_updated( - ) -> Result<(), Box> { - let (mut ds1, mut ds2) = get_sessions_for_conflict().await?; - - let path: Path = "/foo/bar/some-array".try_into().unwrap(); - ds1.set_user_attributes( - path.clone(), - Some(UserAttributes::try_new(br#"{"foo":"bar"}"#).unwrap()), - ) - .await?; - ds1.commit("update user attributes", None).await?; - - let node = ds2.get_node(&path).await.unwrap(); - ds2.delete_array(path.clone()).await?; - ds2.commit("delete array", None).await.unwrap_err(); - assert_has_conflict( - &Conflict::DeleteOfUpdatedArray { path, node_id: node.id }, - ds2.rebase(&ConflictDetector).await, - ); - Ok(()) - } - #[tokio::test()] /// Test conflict detection /// @@ -3039,16 +3241,12 @@ mod tests { /// /// This session: delete group /// Previous commit: update same group user attributes - async fn test_conflict_detection_delete_when_group_user_atts_updated( + async fn test_conflict_detection_delete_when_group_user_data_updated( ) -> Result<(), Box> { let (mut ds1, mut ds2) = get_sessions_for_conflict().await?; let path: Path = "/foo/bar".try_into().unwrap(); - ds1.set_user_attributes( - path.clone(), - Some(UserAttributes::try_new(br#"{"foo":"bar"}"#).unwrap()), - ) - .await?; + ds1.update_group(&path, Bytes::new()).await?; ds1.commit("update user attributes", None).await?; let node = ds2.get_node(&path).await.unwrap(); @@ -3067,20 +3265,10 @@ mod tests { let mut ds = repo.writable_session("main").await?; - ds.add_group("/".try_into().unwrap()).await?; - let zarr_meta = ZarrArrayMetadata { - shape: vec![5], - data_type: DataType::Int32, - chunk_shape: ChunkShape(vec![NonZeroU64::new(1).unwrap()]), - chunk_key_encoding: ChunkKeyEncoding::Slash, - fill_value: FillValue::Int32(0), - codecs: vec![], - storage_transformers: None, - dimension_names: None, - }; + ds.add_group("/".try_into().unwrap(), user_data()).await?; let new_array_path: Path = "/array".try_into().unwrap(); - ds.add_array(new_array_path.clone(), zarr_meta.clone()).await?; + ds.add_array(new_array_path.clone(), basic_shape(), None, user_data()).await?; ds.commit("create array", None).await?; // one writer sets chunks @@ -3111,7 +3299,7 @@ mod tests { .await?; // verify we cannot commit - if let Err(SessionError::Conflict { .. }) = + if let Err(SessionError { kind: SessionErrorKind::Conflict { .. }, .. }) = ds2.commit("write one chunk with repo2", None).await { // detect conflicts using rebase @@ -3119,7 +3307,7 @@ mod tests { // assert the conflict is double chunk update assert!(matches!( result, - Err(SessionError::RebaseFailed { snapshot, conflicts, }) + Err(SessionError{kind: SessionErrorKind::RebaseFailed { snapshot, conflicts, },..}) if snapshot == conflicting_snap && conflicts.len() == 1 && matches!(conflicts[0], Conflict::ChunkDoubleUpdate { ref path, ref chunk_coordinates, .. } @@ -3139,20 +3327,9 @@ mod tests { let mut ds = repo.writable_session("main").await?; - ds.add_group("/".try_into().unwrap()).await?; - let zarr_meta = ZarrArrayMetadata { - shape: vec![5], - data_type: DataType::Int32, - chunk_shape: ChunkShape(vec![NonZeroU64::new(1).unwrap()]), - chunk_key_encoding: ChunkKeyEncoding::Slash, - fill_value: FillValue::Int32(0), - codecs: vec![], - storage_transformers: None, - dimension_names: None, - }; - + ds.add_group("/".try_into().unwrap(), user_data()).await?; let new_array_path: Path = "/array".try_into().unwrap(); - ds.add_array(new_array_path.clone(), zarr_meta.clone()).await?; + ds.add_array(new_array_path.clone(), basic_shape(), None, user_data()).await?; let _array_created_snap = ds.commit("create array", None).await?; let mut ds1 = repo.writable_session("main").await?; @@ -3172,7 +3349,7 @@ mod tests { .await?; let new_array_2_path: Path = "/array_2".try_into().unwrap(); - ds1.add_array(new_array_2_path.clone(), zarr_meta.clone()).await?; + ds1.add_array(new_array_2_path.clone(), basic_shape(), None, user_data()).await?; ds1.set_chunk_ref( new_array_2_path.clone(), ChunkIndices(vec![0]), @@ -3190,7 +3367,7 @@ mod tests { Some(ChunkPayload::Inline("hello2".into())), ) .await?; - if let Err(SessionError::Conflict { .. }) = + if let Err(SessionError { kind: SessionErrorKind::Conflict { .. }, .. }) = ds2.commit("write one chunk with repo2", None).await { let solver = BasicConflictSolver::default(); @@ -3236,220 +3413,128 @@ mod tests { "main", conflicting_snap.clone(), Some(¤t_snap), - false, ) .await?; - - // TODO: We can't create writable sessions from arbitrary snapshots anymore so not sure what to do about this? - // let's try to create a new commit, that conflicts with the previous one and writes - // to the same chunk, recovering with "Fail" policy (so it shouldn't recover) - // let mut repo2 = - // Repository::update(Arc::clone(&storage), array_created_snap.clone()).build(); - // repo2 - // .set_chunk_ref( - // new_array_path.clone(), - // ChunkIndices(vec![1]), - // Some(ChunkPayload::Inline("overridden".into())), - // ) - // .await?; - - // if let Err(SessionError::Conflict { .. }) = - // repo2.commit("main", "write one chunk with repo2", None).await - // { - // let solver = BasicConflictSolver { - // on_chunk_conflict: VersionSelection::Fail, - // ..BasicConflictSolver::default() - // }; - - // let res = repo2.rebase(&solver, "main").await; - // assert!(matches!( - // res, - // Err(SessionError::RebaseFailed { snapshot, conflicts, }) - // if snapshot == conflicting_snap && - // conflicts.len() == 1 && - // matches!(conflicts[0], Conflict::ChunkDoubleUpdate { ref path, ref chunk_coordinates, .. } - // if path == &new_array_path && chunk_coordinates == &[ChunkIndices(vec![1])].into()) - // )); - // } else { - // panic!("Bad test, it should conflict") - // } - - // // reset the branch to what repo1 wrote - // let current_snap = fetch_branch_tip(storage.as_ref(), "main").await?.snapshot; - // update_branch( - // storage.as_ref(), - // "main", - // conflicting_snap.clone(), - // Some(¤t_snap), - // false, - // ) - // .await?; - - // // let's try to create a new commit, that conflicts with the previous one and writes - // // to the same chunk, recovering with "UseOurs" policy - // let mut repo2 = - // Repository::update(Arc::clone(&storage), array_created_snap.clone()).build(); - // repo2 - // .set_chunk_ref( - // new_array_path.clone(), - // ChunkIndices(vec![1]), - // Some(ChunkPayload::Inline("overridden".into())), - // ) - // .await?; - // if let Err(SessionError::Conflict { .. }) = - // repo2.commit("main", "write one chunk with repo2", None).await - // { - // let solver = BasicConflictSolver { - // on_chunk_conflict: VersionSelection::UseOurs, - // ..Default::default() - // }; - - // repo2.rebase(&solver, "main").await?; - // repo2.commit("main", "after conflict", None).await?; - // let data = - // repo2.get_chunk_ref(&new_array_path, &ChunkIndices(vec![1])).await?; - // assert_eq!(data, Some(ChunkPayload::Inline("overridden".into()))); - // let commits = repo2.ancestry().await?.try_collect::>().await?; - // assert_eq!(commits[0].message, "after conflict"); - // assert_eq!(commits[1].message, "write two chunks with repo 1"); - // } else { - // panic!("Bad test, it should conflict") - // } - - // // reset the branch to what repo1 wrote - // let current_snap = fetch_branch_tip(storage.as_ref(), "main").await?.snapshot; - // update_branch( - // storage.as_ref(), - // "main", - // conflicting_snap.clone(), - // Some(¤t_snap), - // false, - // ) - // .await?; - - // // let's try to create a new commit, that conflicts with the previous one and writes - // // to the same chunk, recovering with "UseTheirs" policy - // let mut repo2 = - // Repository::update(Arc::clone(&storage), array_created_snap.clone()).build(); - // repo2 - // .set_chunk_ref( - // new_array_path.clone(), - // ChunkIndices(vec![1]), - // Some(ChunkPayload::Inline("overridden".into())), - // ) - // .await?; - // if let Err(SessionError::Conflict { .. }) = - // repo2.commit("main", "write one chunk with repo2", None).await - // { - // let solver = BasicConflictSolver { - // on_chunk_conflict: VersionSelection::UseTheirs, - // ..Default::default() - // }; - - // repo2.rebase(&solver, "main").await?; - // repo2.commit("main", "after conflict", None).await?; - // let data = - // repo2.get_chunk_ref(&new_array_path, &ChunkIndices(vec![1])).await?; - // assert_eq!(data, Some(ChunkPayload::Inline("hello1".into()))); - // let commits = repo2.ancestry().await?.try_collect::>().await?; - // assert_eq!(commits[0].message, "after conflict"); - // assert_eq!(commits[1].message, "write two chunks with repo 1"); - // } else { - // panic!("Bad test, it should conflict") - // } - Ok(()) } - #[tokio::test] - /// Test conflict resolution with rebase - /// - /// Two sessions write user attributes to the same array - /// We attempt to recover using [`VersionSelection::UseOurs`] policy - async fn test_conflict_resolution_double_user_atts_edit_with_ours( - ) -> Result<(), Box> { - let (mut ds1, mut ds2) = get_sessions_for_conflict().await?; - - let path: Path = "/foo/bar/some-array".try_into().unwrap(); - ds1.set_user_attributes( - path.clone(), - Some(UserAttributes::try_new(br#"{"repo":1}"#).unwrap()), - ) - .await?; - ds1.commit("update array", None).await?; - - ds2.set_user_attributes( - path.clone(), - Some(UserAttributes::try_new(br#"{"repo":2}"#).unwrap()), - ) - .await?; - ds2.commit("update array user atts", None).await.unwrap_err(); - - let solver = BasicConflictSolver { - on_user_attributes_conflict: VersionSelection::UseOurs, - ..Default::default() - }; - - ds2.rebase(&solver).await?; - ds2.commit("after conflict", None).await?; - - let atts = ds2.get_node(&path).await.unwrap().user_attributes.unwrap(); - assert_eq!( - atts, - UserAttributesSnapshot::Inline( - UserAttributes::try_new(br#"{"repo":2}"#).unwrap() - ) - ); - Ok(()) - } - - #[tokio::test] - /// Test conflict resolution with rebase - /// - /// Two sessions write user attributes to the same array - /// We attempt to recover using [`VersionSelection::UseTheirs`] policy - async fn test_conflict_resolution_double_user_atts_edit_with_theirs( - ) -> Result<(), Box> { - let (mut ds1, mut ds2) = get_sessions_for_conflict().await?; - - let path: Path = "/foo/bar/some-array".try_into().unwrap(); - ds1.set_user_attributes( - path.clone(), - Some(UserAttributes::try_new(br#"{"repo":1}"#).unwrap()), - ) - .await?; - ds1.commit("update array", None).await?; - - // we made one extra random change to the repo, because we'll undo the user attributes - // update and we cannot commit an empty change - ds2.add_group("/baz".try_into().unwrap()).await?; - - ds2.set_user_attributes( - path.clone(), - Some(UserAttributes::try_new(br#"{"repo":2}"#).unwrap()), - ) - .await?; - ds2.commit("update array user atts", None).await.unwrap_err(); - - let solver = BasicConflictSolver { - on_user_attributes_conflict: VersionSelection::UseTheirs, - ..Default::default() - }; - - ds2.rebase(&solver).await?; - ds2.commit("after conflict", None).await?; - - let atts = ds2.get_node(&path).await.unwrap().user_attributes.unwrap(); - assert_eq!( - atts, - UserAttributesSnapshot::Inline( - UserAttributes::try_new(br#"{"repo":1}"#).unwrap() - ) - ); - - ds2.get_node(&"/baz".try_into().unwrap()).await?; - Ok(()) - } + // TODO: We can't create writable sessions from arbitrary snapshots anymore so not sure what to do about this? + // let's try to create a new commit, that conflicts with the previous one and writes + // to the same chunk, recovering with "Fail" policy (so it shouldn't recover) + // let mut repo2 = + // Repository::update(Arc::clone(&storage), array_created_snap.clone()).build(); + // repo2 + // .set_chunk_ref( + // new_array_path.clone(), + // ChunkIndices(vec![1]), + // Some(ChunkPayload::Inline("overridden".into())), + // ) + // .await?; + + // if let Err(SessionError::Conflict { .. }) = + // repo2.commit("main", "write one chunk with repo2", None).await + // { + // let solver = BasicConflictSolver { + // on_chunk_conflict: VersionSelection::Fail, + // ..BasicConflictSolver::default() + // }; + + // let res = repo2.rebase(&solver, "main").await; + // assert!(matches!( + // res, + // Err(SessionError::RebaseFailed { snapshot, conflicts, }) + // if snapshot == conflicting_snap && + // conflicts.len() == 1 && + // matches!(conflicts[0], Conflict::ChunkDoubleUpdate { ref path, ref chunk_coordinates, .. } + // if path == &new_array_path && chunk_coordinates == &[ChunkIndices(vec![1])].into()) + // )); + // } else { + // panic!("Bad test, it should conflict") + // } + + // // reset the branch to what repo1 wrote + // let current_snap = fetch_branch_tip(storage.as_ref(), "main").await?.snapshot; + // update_branch( + // storage.as_ref(), + // "main", + // conflicting_snap.clone(), + // Some(¤t_snap), + // false, + // ) + // .await?; + + // // let's try to create a new commit, that conflicts with the previous one and writes + // // to the same chunk, recovering with "UseOurs" policy + // let mut repo2 = + // Repository::update(Arc::clone(&storage), array_created_snap.clone()).build(); + // repo2 + // .set_chunk_ref( + // new_array_path.clone(), + // ChunkIndices(vec![1]), + // Some(ChunkPayload::Inline("overridden".into())), + // ) + // .await?; + // if let Err(SessionError::Conflict { .. }) = + // repo2.commit("main", "write one chunk with repo2", None).await + // { + // let solver = BasicConflictSolver { + // on_chunk_conflict: VersionSelection::UseOurs, + // ..Default::default() + // }; + + // repo2.rebase(&solver, "main").await?; + // repo2.commit("main", "after conflict", None).await?; + // let data = + // repo2.get_chunk_ref(&new_array_path, &ChunkIndices(vec![1])).await?; + // assert_eq!(data, Some(ChunkPayload::Inline("overridden".into()))); + // let commits = repo2.ancestry().await?.try_collect::>().await?; + // assert_eq!(commits[0].message, "after conflict"); + // assert_eq!(commits[1].message, "write two chunks with repo 1"); + // } else { + // panic!("Bad test, it should conflict") + // } + + // // reset the branch to what repo1 wrote + // let current_snap = fetch_branch_tip(storage.as_ref(), "main").await?.snapshot; + // update_branch( + // storage.as_ref(), + // "main", + // conflicting_snap.clone(), + // Some(¤t_snap), + // false, + // ) + // .await?; + + // // let's try to create a new commit, that conflicts with the previous one and writes + // // to the same chunk, recovering with "UseTheirs" policy + // let mut repo2 = + // Repository::update(Arc::clone(&storage), array_created_snap.clone()).build(); + // repo2 + // .set_chunk_ref( + // new_array_path.clone(), + // ChunkIndices(vec![1]), + // Some(ChunkPayload::Inline("overridden".into())), + // ) + // .await?; + // if let Err(SessionError::Conflict { .. }) = + // repo2.commit("main", "write one chunk with repo2", None).await + // { + // let solver = BasicConflictSolver { + // on_chunk_conflict: VersionSelection::UseTheirs, + // ..Default::default() + // }; + + // repo2.rebase(&solver, "main").await?; + // repo2.commit("main", "after conflict", None).await?; + // let data = + // repo2.get_chunk_ref(&new_array_path, &ChunkIndices(vec![1])).await?; + // assert_eq!(data, Some(ChunkPayload::Inline("hello1".into()))); + // let commits = repo2.ancestry().await?.try_collect::>().await?; + // assert_eq!(commits[0].message, "after conflict"); + // assert_eq!(commits[1].message, "write two chunks with repo 1"); + // } else { + // panic!("Bad test, it should conflict") + // } #[tokio::test] /// Test conflict resolution with rebase @@ -3462,7 +3547,7 @@ mod tests { let (mut ds1, mut ds2) = get_sessions_for_conflict().await?; let path: Path = "/foo/bar/some-array".try_into().unwrap(); - ds1.update_array(path.clone(), basic_meta()).await?; + ds1.update_array(&path, basic_shape(), None, user_data()).await?; ds1.commit("update array", None).await?; ds2.delete_array(path.clone()).await?; @@ -3473,7 +3558,7 @@ mod tests { assert!(matches!( ds2.get_node(&path).await, - Err(SessionError::NodeNotFound { .. }) + Err(SessionError { kind: SessionErrorKind::NodeNotFound { .. }, .. }) )); Ok(()) @@ -3541,12 +3626,13 @@ mod tests { let mut ds2 = repo.writable_session("main").await?; let path: Path = "/foo/bar/some-array".try_into().unwrap(); - ds1.set_user_attributes( + ds1.set_chunk_ref( path.clone(), - Some(UserAttributes::try_new(br#"{"repo":1}"#).unwrap()), + ChunkIndices(vec![1]), + Some(ChunkPayload::Inline("repo 1".into())), ) .await?; - let non_conflicting_snap = ds1.commit("update user atts", None).await?; + let non_conflicting_snap = ds1.commit("updated non-conflict chunk", None).await?; let mut ds1 = repo.writable_session("main").await?; ds1.set_chunk_ref( @@ -3577,7 +3663,7 @@ mod tests { assert!(matches!( err, - SessionError::RebaseFailed { snapshot, conflicts} + SessionError{kind: SessionErrorKind::RebaseFailed { snapshot, conflicts},..} if snapshot == conflicting_snap && conflicts.len() == 1 && matches!(conflicts[0], Conflict::ChunkDoubleUpdate { ref path, ref chunk_coordinates, .. } @@ -3595,6 +3681,7 @@ mod tests { mod state_machine_test { use crate::format::snapshot::NodeData; use crate::format::Path; + use bytes::Bytes; use futures::Future; use proptest::prelude::*; use proptest::sample; @@ -3609,16 +3696,17 @@ mod tests { use proptest::test_runner::Config; use super::create_memory_store_repository; + use super::ArrayShape; + use super::DimensionName; use super::Session; - use super::ZarrArrayMetadata; - use super::{node_paths, zarr_array_metadata}; + use super::{node_paths, shapes_and_dims}; #[derive(Clone, Debug)] enum RepositoryTransition { - AddArray(Path, ZarrArrayMetadata), - UpdateArray(Path, ZarrArrayMetadata), + AddArray(Path, ArrayShape, Option>, Bytes), + UpdateArray(Path, ArrayShape, Option>, Bytes), DeleteArray(Option), - AddGroup(Path), + AddGroup(Path, Bytes), DeleteGroup(Option), } @@ -3627,8 +3715,8 @@ mod tests { #[derive(Clone, Default, Debug)] struct RepositoryModel { - arrays: HashMap, - groups: Vec, + arrays: HashMap>, Bytes)>, + groups: HashMap, } impl ReferenceStateMachine for RepositoryStateMachine { @@ -3659,7 +3747,7 @@ mod tests { let delete_groups = { if !state.groups.is_empty() { - sample::select(state.groups.clone()) + sample::select(state.groups.keys().cloned().collect::>()) .prop_map(|p| RepositoryTransition::DeleteGroup(Some(p))) .boxed() } else { @@ -3668,12 +3756,38 @@ mod tests { }; prop_oneof![ - (node_paths(), zarr_array_metadata()) - .prop_map(|(a, b)| RepositoryTransition::AddArray(a, b)), - (node_paths(), zarr_array_metadata()) - .prop_map(|(a, b)| RepositoryTransition::UpdateArray(a, b)), + ( + node_paths(), + shapes_and_dims(None), + proptest::collection::vec(any::(), 0..=100) + ) + .prop_map(|(a, shape, user_data)| { + RepositoryTransition::AddArray( + a, + shape.shape, + shape.dimension_names, + Bytes::copy_from_slice(user_data.as_slice()), + ) + }), + ( + node_paths(), + shapes_and_dims(None), + proptest::collection::vec(any::(), 0..=100) + ) + .prop_map(|(a, shape, user_data)| { + RepositoryTransition::UpdateArray( + a, + shape.shape, + shape.dimension_names, + Bytes::copy_from_slice(user_data.as_slice()), + ) + }), delete_arrays, - node_paths().prop_map(RepositoryTransition::AddGroup), + (node_paths(), proptest::collection::vec(any::(), 0..=100)) + .prop_map(|(p, ud)| RepositoryTransition::AddGroup( + p, + Bytes::copy_from_slice(ud.as_slice()) + )), delete_groups, ] .boxed() @@ -3685,14 +3799,20 @@ mod tests { ) -> Self::State { match transition { // Array ops - RepositoryTransition::AddArray(path, metadata) => { - let res = state.arrays.insert(path.clone(), metadata.clone()); + RepositoryTransition::AddArray(path, shape, dims, ud) => { + let res = state.arrays.insert( + path.clone(), + (shape.clone(), dims.clone(), ud.clone()), + ); assert!(res.is_none()); } - RepositoryTransition::UpdateArray(path, metadata) => { + RepositoryTransition::UpdateArray(path, shape, dims, ud) => { state .arrays - .insert(path.clone(), metadata.clone()) + .insert( + path.clone(), + (shape.clone(), dims.clone(), ud.clone()), + ) .expect("(postcondition) insertion failed"); } RepositoryTransition::DeleteArray(path) => { @@ -3704,17 +3824,13 @@ mod tests { } // Group ops - RepositoryTransition::AddGroup(path) => { - state.groups.push(path.clone()); + RepositoryTransition::AddGroup(path, ud) => { + state.groups.insert(path.clone(), ud.clone()); // TODO: postcondition } RepositoryTransition::DeleteGroup(Some(path)) => { - let index = - state.groups.iter().position(|x| x == path).expect( - "Attempting to delete a non-existent path: {path}", - ); - state.groups.swap_remove(index); - state.groups.retain(|group| !group.starts_with(path)); + state.groups.remove(path); + state.groups.retain(|group, _| !group.starts_with(path)); state.arrays.retain(|array, _| !array.starts_with(path)); } _ => panic!(), @@ -3724,15 +3840,17 @@ mod tests { fn preconditions(state: &Self::State, transition: &Self::Transition) -> bool { match transition { - RepositoryTransition::AddArray(path, _) => { - !state.arrays.contains_key(path) && !state.groups.contains(path) + RepositoryTransition::AddArray(path, _, _, _) => { + !state.arrays.contains_key(path) + && !state.groups.contains_key(path) } - RepositoryTransition::UpdateArray(path, _) => { + RepositoryTransition::UpdateArray(path, _, _, _) => { state.arrays.contains_key(path) } RepositoryTransition::DeleteArray(path) => path.is_some(), - RepositoryTransition::AddGroup(path) => { - !state.arrays.contains_key(path) && !state.groups.contains(path) + RepositoryTransition::AddGroup(path, _) => { + !state.arrays.contains_key(path) + && !state.groups.contains_key(path) } RepositoryTransition::DeleteGroup(p) => p.is_some(), } @@ -3781,17 +3899,17 @@ mod tests { let runtime = &state.runtime; let repository = &mut state.session; match transition { - RepositoryTransition::AddArray(path, metadata) => { - runtime.unwrap(repository.add_array(path, metadata)) + RepositoryTransition::AddArray(path, shape, dims, ud) => { + runtime.unwrap(repository.add_array(path, shape, dims, ud)) } - RepositoryTransition::UpdateArray(path, metadata) => { - runtime.unwrap(repository.update_array(path, metadata)) + RepositoryTransition::UpdateArray(path, shape, dims, ud) => { + runtime.unwrap(repository.update_array(&path, shape, dims, ud)) } RepositoryTransition::DeleteArray(Some(path)) => { runtime.unwrap(repository.delete_array(path)) } - RepositoryTransition::AddGroup(path) => { - runtime.unwrap(repository.add_group(path)) + RepositoryTransition::AddGroup(path, ud) => { + runtime.unwrap(repository.add_group(path, ud)) } RepositoryTransition::DeleteGroup(Some(path)) => { runtime.unwrap(repository.delete_group(path)) @@ -3806,23 +3924,28 @@ mod tests { ref_state: &::State, ) { let runtime = &state.runtime; - for (path, metadata) in ref_state.arrays.iter() { + for (path, (shape, dims, ud)) in ref_state.arrays.iter() { let node = runtime.unwrap(state.session.get_array(path)); let actual_metadata = match node.node_data { - NodeData::Array(metadata, _) => Ok(metadata), + NodeData::Array { shape, dimension_names, .. } => { + Ok((shape, dimension_names)) + } _ => Err("foo"), } .unwrap(); - assert_eq!(metadata, &actual_metadata); + assert_eq!(shape, &actual_metadata.0); + assert_eq!(dims, &actual_metadata.1); + assert_eq!(ud, &node.user_data); } - for path in ref_state.groups.iter() { + for (path, ud) in ref_state.groups.iter() { let node = runtime.unwrap(state.session.get_group(path)); match node.node_data { NodeData::Group => Ok(()), _ => Err("foo"), } .unwrap(); + assert_eq!(&node.user_data, ud) } } } diff --git a/icechunk/src/storage/logging.rs b/icechunk/src/storage/logging.rs index 8997f7e6..00673098 100644 --- a/icechunk/src/storage/logging.rs +++ b/icechunk/src/storage/logging.rs @@ -1,4 +1,5 @@ use std::{ + fmt, ops::Range, sync::{Arc, Mutex}, }; @@ -10,7 +11,10 @@ use futures::stream::BoxStream; use serde::{Deserialize, Serialize}; use tokio::io::AsyncRead; -use super::{ETag, ListInfo, Reader, Settings, Storage, StorageError, StorageResult}; +use super::{ + FetchConfigResult, GetRefResult, ListInfo, Reader, Settings, Storage, StorageError, + StorageResult, UpdateConfigResult, VersionInfo, WriteRefResult, +}; use crate::{ format::{ChunkId, ChunkOffset, ManifestId, SnapshotId}, private, @@ -34,6 +38,12 @@ impl LoggingStorage { } } +impl fmt::Display for LoggingStorage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "LoggingStorage(backend={})", self.backend) + } +} + impl private::Sealed for LoggingStorage {} #[async_trait] @@ -46,16 +56,16 @@ impl Storage for LoggingStorage { async fn fetch_config( &self, settings: &Settings, - ) -> StorageResult> { + ) -> StorageResult { self.backend.fetch_config(settings).await } async fn update_config( &self, settings: &Settings, config: Bytes, - etag: Option<&str>, - ) -> StorageResult { - self.backend.update_config(settings, config, etag).await + previous_version: &VersionInfo, + ) -> StorageResult { + self.backend.update_config(settings, config, previous_version).await } async fn fetch_snapshot( @@ -159,7 +169,11 @@ impl Storage for LoggingStorage { self.backend.write_chunk(settings, id, bytes).await } - async fn get_ref(&self, settings: &Settings, ref_key: &str) -> StorageResult { + async fn get_ref( + &self, + settings: &Settings, + ref_key: &str, + ) -> StorageResult { self.backend.get_ref(settings, ref_key).await } @@ -171,18 +185,10 @@ impl Storage for LoggingStorage { &self, settings: &Settings, ref_key: &str, - overwrite_refs: bool, bytes: Bytes, - ) -> StorageResult<()> { - self.backend.write_ref(settings, ref_key, overwrite_refs, bytes).await - } - - async fn ref_versions( - &self, - settings: &Settings, - ref_name: &str, - ) -> StorageResult>> { - self.backend.ref_versions(settings, ref_name).await + previous_version: &VersionInfo, + ) -> StorageResult { + self.backend.write_ref(settings, ref_key, bytes, previous_version).await } async fn list_objects<'a>( @@ -210,10 +216,6 @@ impl Storage for LoggingStorage { self.backend.get_snapshot_last_modified(settings, snapshot).await } - async fn root_is_clean(&self) -> StorageResult { - self.backend.root_is_clean().await - } - async fn get_object_range_buf( &self, key: &str, diff --git a/icechunk/src/storage/mod.rs b/icechunk/src/storage/mod.rs index 67cbb6e6..5701b271 100644 --- a/icechunk/src/storage/mod.rs +++ b/icechunk/src/storage/mod.rs @@ -1,7 +1,4 @@ -use ::object_store::{ - azure::AzureConfigKey, - gcp::{GoogleCloudStorageBuilder, GoogleConfigKey}, -}; +use ::object_store::{azure::AzureConfigKey, gcp::GoogleConfigKey}; use aws_sdk_s3::{ config::http::HttpResponse, error::SdkError, @@ -19,7 +16,6 @@ use futures::{ Stream, StreamExt, TryStreamExt, }; use itertools::Itertools; -use object_store::ObjectStorageConfig; use s3::S3Storage; use serde::{Deserialize, Serialize}; use std::{ @@ -49,17 +45,15 @@ pub mod s3; pub use object_store::ObjectStorage; use crate::{ - config::{ - AzureCredentials, AzureStaticCredentials, GcsCredentials, GcsStaticCredentials, - S3Credentials, S3Options, - }, + config::{AzureCredentials, GcsCredentials, S3Credentials, S3Options}, + error::ICError, format::{ChunkId, ChunkOffset, ManifestId, SnapshotId}, private, }; #[derive(Debug, Error)] -pub enum StorageError { - #[error("error contacting object store {0}")] +pub enum StorageErrorKind { + #[error("object store error {0}")] ObjectStore(#[from] ::object_store::Error), #[error("bad object store prefix {0:?}")] BadPrefix(OsString), @@ -75,18 +69,25 @@ pub enum StorageError { S3DeleteObjectError(#[from] SdkError), #[error("error streaming bytes from object store {0}")] S3StreamError(#[from] ByteStreamError), - #[error("cannot overwrite ref: {0}")] - RefAlreadyExists(String), - #[error("ref not found: {0}")] - RefNotFound(String), - #[error("the etag does not match")] - ConfigUpdateConflict, #[error("I/O error: {0}")] IOError(#[from] std::io::Error), #[error("unknown storage error: {0}")] Other(String), } +pub type StorageError = ICError; + +// it would be great to define this impl in error.rs, but it conflicts with the blanket +// `impl From for T` +impl From for StorageError +where + E: Into, +{ + fn from(value: E) -> Self { + Self::new(value.into()) + } +} + pub type StorageResult = Result; #[derive(Debug)] @@ -103,7 +104,38 @@ const REF_PREFIX: &str = "refs"; const TRANSACTION_PREFIX: &str = "transactions/"; const CONFIG_PATH: &str = "config.yaml"; -pub type ETag = String; +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone, Hash, PartialOrd, Ord)] +pub struct ETag(pub String); +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone, Default)] +pub struct Generation(pub String); + +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)] +pub struct VersionInfo { + pub etag: Option, + pub generation: Option, +} + +impl VersionInfo { + pub fn for_creation() -> Self { + Self { etag: None, generation: None } + } + + pub fn from_etag_only(etag: String) -> Self { + Self { etag: Some(ETag(etag)), generation: None } + } + + pub fn is_create(&self) -> bool { + self.etag.is_none() && self.generation.is_none() + } + + pub fn etag(&self) -> Option<&String> { + self.etag.as_ref().map(|e| &e.0) + } + + pub fn generation(&self) -> Option<&String> { + self.generation.as_ref().map(|e| &e.0) + } +} #[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone, Default)] pub struct ConcurrencySettings { @@ -142,6 +174,9 @@ impl ConcurrencySettings { #[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone, Default)] pub struct Settings { pub concurrency: Option, + pub unsafe_use_conditional_update: Option, + pub unsafe_use_conditional_create: Option, + pub unsafe_use_metadata: Option, } static DEFAULT_CONCURRENCY: OnceLock = OnceLock::new(); @@ -153,6 +188,18 @@ impl Settings { .unwrap_or_else(|| DEFAULT_CONCURRENCY.get_or_init(Default::default)) } + pub fn unsafe_use_conditional_create(&self) -> bool { + self.unsafe_use_conditional_create.unwrap_or(true) + } + + pub fn unsafe_use_conditional_update(&self) -> bool { + self.unsafe_use_conditional_update.unwrap_or(true) + } + + pub fn unsafe_use_metadata(&self) -> bool { + self.unsafe_use_metadata.unwrap_or(true) + } + pub fn merge(&self, other: Self) -> Self { Self { concurrency: match (&self.concurrency, other.concurrency) { @@ -161,6 +208,33 @@ impl Settings { (Some(c), None) => Some(c.clone()), (Some(mine), Some(theirs)) => Some(mine.merge(theirs)), }, + unsafe_use_conditional_create: match ( + &self.unsafe_use_conditional_create, + other.unsafe_use_conditional_create, + ) { + (None, None) => None, + (None, Some(c)) => Some(c), + (Some(c), None) => Some(*c), + (Some(_), Some(theirs)) => Some(theirs), + }, + unsafe_use_conditional_update: match ( + &self.unsafe_use_conditional_update, + other.unsafe_use_conditional_update, + ) { + (None, None) => None, + (None, Some(c)) => Some(c), + (Some(c), None) => Some(*c), + (Some(_), Some(theirs)) => Some(theirs), + }, + unsafe_use_metadata: match ( + &self.unsafe_use_metadata, + other.unsafe_use_metadata, + ) { + (None, None) => None, + (None, Some(c)) => Some(c), + (Some(c), None) => Some(*c), + (Some(_), Some(theirs)) => Some(theirs), + }, } } } @@ -176,7 +250,9 @@ impl Reader { Reader::Asynchronous(mut read) => { // add some extra space to the buffer to optimize conversion to bytes let mut buffer = Vec::with_capacity(expected_size + 16); - tokio::io::copy(&mut read, &mut buffer).await?; + tokio::io::copy(&mut read, &mut buffer) + .await + .map_err(StorageErrorKind::IOError)?; Ok(buffer.into()) } Reader::Synchronous(mut buf) => Ok(buf.copy_to_bytes(buf.remaining())), @@ -192,26 +268,48 @@ impl Reader { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FetchConfigResult { + Found { bytes: Bytes, version: VersionInfo }, + NotFound, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UpdateConfigResult { + Updated { new_version: VersionInfo }, + NotOnLatestVersion, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GetRefResult { + Found { bytes: Bytes, version: VersionInfo }, + NotFound, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WriteRefResult { + Written, + WontOverwrite, +} + /// Fetch and write the parquet files that represent the repository in object store /// /// Different implementation can cache the files differently, or not at all. /// Implementations are free to assume files are never overwritten. #[async_trait] #[typetag::serde(tag = "type")] -pub trait Storage: fmt::Debug + private::Sealed + Sync + Send { +pub trait Storage: fmt::Debug + fmt::Display + private::Sealed + Sync + Send { fn default_settings(&self) -> Settings { Default::default() } - async fn fetch_config( - &self, - settings: &Settings, - ) -> StorageResult>; + async fn fetch_config(&self, settings: &Settings) + -> StorageResult; async fn update_config( &self, settings: &Settings, config: Bytes, - etag: Option<&str>, - ) -> StorageResult; + previous_version: &VersionInfo, + ) -> StorageResult; async fn fetch_snapshot( &self, settings: &Settings, @@ -273,20 +371,19 @@ pub trait Storage: fmt::Debug + private::Sealed + Sync + Send { bytes: Bytes, ) -> StorageResult<()>; - async fn get_ref(&self, settings: &Settings, ref_key: &str) -> StorageResult; - async fn ref_names(&self, settings: &Settings) -> StorageResult>; - async fn ref_versions( + async fn get_ref( &self, settings: &Settings, - ref_name: &str, - ) -> StorageResult>>; + ref_key: &str, + ) -> StorageResult; + async fn ref_names(&self, settings: &Settings) -> StorageResult>; async fn write_ref( &self, settings: &Settings, ref_key: &str, - overwrite_refs: bool, bytes: Bytes, - ) -> StorageResult<()>; + previous_version: &VersionInfo, + ) -> StorageResult; async fn list_objects<'a>( &'a self, @@ -308,7 +405,13 @@ pub trait Storage: fmt::Debug + private::Sealed + Sync + Send { snapshot: &SnapshotId, ) -> StorageResult>; - async fn root_is_clean(&self) -> StorageResult; + async fn root_is_clean(&self) -> StorageResult { + match self.list_objects(&Settings::default(), "").await?.next().await { + None => Ok(true), + Some(Ok(_)) => Ok(false), + Some(Err(err)) => Err(err), + } + } async fn list_chunks( &self, @@ -546,127 +649,67 @@ pub fn new_tigris_storage( Ok(Arc::new(st)) } -pub fn new_in_memory_storage() -> StorageResult> { - let st = ObjectStorage::new_in_memory()?; +pub async fn new_in_memory_storage() -> StorageResult> { + let st = ObjectStorage::new_in_memory().await?; Ok(Arc::new(st)) } -pub fn new_local_filesystem_storage(path: &Path) -> StorageResult> { - let st = ObjectStorage::new_local_filesystem(path)?; +pub async fn new_local_filesystem_storage( + path: &Path, +) -> StorageResult> { + let st = ObjectStorage::new_local_filesystem(path).await?; Ok(Arc::new(st)) } -pub fn new_azure_blob_storage( +pub async fn new_s3_object_store_storage( + config: S3Options, + bucket: String, + prefix: Option, + credentials: Option, +) -> StorageResult> { + let storage = + ObjectStorage::new_s3(bucket, prefix, credentials, Some(config)).await?; + Ok(Arc::new(storage)) +} + +pub async fn new_azure_blob_storage( + account: String, container: String, - prefix: String, + prefix: Option, credentials: Option, config: Option>, ) -> StorageResult> { - let url = format!("azure://{}/{}", container, prefix); - let mut options = config.unwrap_or_default().into_iter().collect::>(); - // Either the account name should be provided or user_emulator should be set to true to use the default account - if !options.iter().any(|(k, _)| k == AzureConfigKey::AccountName.as_ref()) { - options - .push((AzureConfigKey::UseEmulator.as_ref().to_string(), "true".to_string())); - } - - match credentials { - Some(AzureCredentials::Static(AzureStaticCredentials::AccessKey(key))) => { - options.push((AzureConfigKey::AccessKey.as_ref().to_string(), key)); - } - Some(AzureCredentials::Static(AzureStaticCredentials::SASToken(token))) => { - options.push((AzureConfigKey::SasKey.as_ref().to_string(), token)); - } - Some(AzureCredentials::Static(AzureStaticCredentials::BearerToken(token))) => { - options.push((AzureConfigKey::Token.as_ref().to_string(), token)); - } - None | Some(AzureCredentials::FromEnv) => { - let builder = ::object_store::azure::MicrosoftAzureBuilder::from_env(); - - for key in &[ - AzureConfigKey::AccessKey, - AzureConfigKey::SasKey, - AzureConfigKey::Token, - ] { - if let Some(value) = builder.get_config_value(key) { - options.push((key.as_ref().to_string(), value)); - } - } - } - }; - - let config = ObjectStorageConfig { - url, - prefix: "".to_string(), // it's embedded in the url - options, - }; - - Ok(Arc::new(ObjectStorage::from_config(config)?)) + let config = config + .unwrap_or_default() + .into_iter() + .filter_map(|(key, value)| key.parse::().map(|k| (k, value)).ok()) + .collect(); + let storage = + ObjectStorage::new_azure(account, container, prefix, credentials, Some(config)) + .await?; + Ok(Arc::new(storage)) } -pub fn new_gcs_storage( +pub async fn new_gcs_storage( bucket: String, prefix: Option, credentials: Option, config: Option>, ) -> StorageResult> { - let url = format!( - "gs://{}{}", - bucket, - prefix.map(|p| format!("/{}", p)).unwrap_or("".to_string()) - ); - let mut options = config.unwrap_or_default().into_iter().collect::>(); - - match credentials { - Some(GcsCredentials::Static(GcsStaticCredentials::ServiceAccount(path))) => { - options.push(( - GoogleConfigKey::ServiceAccount.as_ref().to_string(), - path.into_os_string().into_string().map_err(|_| { - StorageError::Other("invalid service account path".to_string()) - })?, - )); - } - Some(GcsCredentials::Static(GcsStaticCredentials::ServiceAccountKey(key))) => { - options.push((GoogleConfigKey::ServiceAccountKey.as_ref().to_string(), key)); - } - Some(GcsCredentials::Static(GcsStaticCredentials::ApplicationCredentials( - path, - ))) => { - options.push(( - GoogleConfigKey::ApplicationCredentials.as_ref().to_string(), - path.into_os_string().into_string().map_err(|_| { - StorageError::Other( - "invalid application credentials path".to_string(), - ) - })?, - )); - } - None | Some(GcsCredentials::FromEnv) => { - let builder = GoogleCloudStorageBuilder::from_env(); - - for key in &[ - GoogleConfigKey::ServiceAccount, - GoogleConfigKey::ServiceAccountKey, - GoogleConfigKey::ApplicationCredentials, - ] { - if let Some(value) = builder.get_config_value(key) { - options.push((key.as_ref().to_string(), value)); - } - } - } - }; - - let config = ObjectStorageConfig { - url, - prefix: "".to_string(), // it's embedded in the url - options, - }; - - Ok(Arc::new(ObjectStorage::from_config(config)?)) + let config = config + .unwrap_or_default() + .into_iter() + .filter_map(|(key, value)| { + key.parse::().map(|k| (k, value)).ok() + }) + .collect(); + let storage = + ObjectStorage::new_gcs(bucket, prefix, credentials, Some(config)).await?; + Ok(Arc::new(storage)) } #[cfg(test)] -#[allow(clippy::unwrap_used)] +#[allow(clippy::unwrap_used, clippy::panic)] mod tests { use std::{collections::HashSet, fs::File, io::Write, path::PathBuf}; diff --git a/icechunk/src/storage/object_store.rs b/icechunk/src/storage/object_store.rs index 75705565..8591425b 100644 --- a/icechunk/src/storage/object_store.rs +++ b/icechunk/src/storage/object_store.rs @@ -1,4 +1,8 @@ use crate::{ + config::{ + AzureCredentials, AzureStaticCredentials, GcsBearerCredential, GcsCredentials, + GcsCredentialsFetcher, GcsStaticCredentials, S3Credentials, S3Options, + }, format::{ChunkId, ChunkOffset, FileTypeTag, ManifestId, ObjectId, SnapshotId}, private, }; @@ -10,115 +14,149 @@ use futures::{ StreamExt, TryStreamExt, }; use object_store::{ - local::LocalFileSystem, parse_url_opts, path::Path as ObjectPath, Attribute, - AttributeValue, Attributes, GetOptions, ObjectMeta, ObjectStore, PutMode, PutOptions, - PutPayload, UpdateVersion, + aws::AmazonS3Builder, + azure::{AzureConfigKey, MicrosoftAzureBuilder}, + gcp::{GcpCredential, GoogleCloudStorageBuilder, GoogleConfigKey}, + local::LocalFileSystem, + memory::InMemory, + path::Path as ObjectPath, + Attribute, AttributeValue, Attributes, CredentialProvider, GetOptions, ObjectMeta, + ObjectStore, PutMode, PutOptions, PutPayload, StaticCredentialProvider, + UpdateVersion, }; use serde::{Deserialize, Serialize}; use std::{ + collections::HashMap, + fmt::{self, Debug, Display}, fs::create_dir_all, future::ready, num::{NonZeroU16, NonZeroU64}, ops::Range, - path::Path as StdPath, + path::{Path as StdPath, PathBuf}, sync::{ atomic::{AtomicUsize, Ordering}, Arc, }, }; -use tokio::io::AsyncRead; +use tokio::{ + io::AsyncRead, + sync::{Mutex, OnceCell}, +}; use tokio_util::compat::FuturesAsyncReadCompatExt; -use url::Url; +use tracing::instrument; use super::{ - ConcurrencySettings, ETag, ListInfo, Reader, Settings, Storage, StorageError, - StorageResult, CHUNK_PREFIX, CONFIG_PATH, MANIFEST_PREFIX, REF_PREFIX, - SNAPSHOT_PREFIX, TRANSACTION_PREFIX, + ConcurrencySettings, ETag, FetchConfigResult, Generation, GetRefResult, ListInfo, + Reader, Settings, Storage, StorageError, StorageErrorKind, StorageResult, + UpdateConfigResult, VersionInfo, WriteRefResult, CHUNK_PREFIX, CONFIG_PATH, + MANIFEST_PREFIX, REF_PREFIX, SNAPSHOT_PREFIX, TRANSACTION_PREFIX, }; -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] -pub struct ObjectStorageConfig { - pub url: String, - pub prefix: String, - pub options: Vec<(String, String)>, -} - -#[derive(Debug, Serialize)] -#[serde(transparent)] +#[derive(Debug, Serialize, Deserialize)] pub struct ObjectStorage { - config: ObjectStorageConfig, + backend: Arc, #[serde(skip)] - store: Arc, + /// We need to use OnceCell to allow async initialization, because serde + /// does not support async cfunction calls from deserialization. This gives + /// us a way to lazily initialize the client. + client: OnceCell>, } impl ObjectStorage { /// Create an in memory Storage implementation /// /// This implementation should not be used in production code. - pub fn new_in_memory() -> Result { - let url = "memory:/".to_string(); - let config = ObjectStorageConfig { url, prefix: "".to_string(), options: vec![] }; - Self::from_config(config) + pub async fn new_in_memory() -> Result { + let backend = Arc::new(InMemoryObjectStoreBackend); + let client = backend.mk_object_store().await?; + let storage = ObjectStorage { backend, client: OnceCell::new_with(Some(client)) }; + Ok(storage) } /// Create an local filesystem Storage implementation /// /// This implementation should not be used in production code. - pub fn new_local_filesystem(prefix: &StdPath) -> Result { - create_dir_all(prefix).map_err(|e| StorageError::Other(e.to_string()))?; - + pub async fn new_local_filesystem( + prefix: &StdPath, + ) -> Result { let prefix = std::fs::canonicalize(prefix) .map_err(|e| StorageError::Other(e.to_string()))?; - let prefix = - prefix.into_os_string().into_string().map_err(StorageError::BadPrefix)?; - dbg!(&prefix); - let url = format!("file:///{prefix}"); - dbg!(&url); - let config = ObjectStorageConfig { url, prefix, options: vec![] }; - Self::from_config(config) - } - - /// Create an ObjectStore client from a URL and provided options - pub fn from_config( - config: ObjectStorageConfig, + let backend = + Arc::new(LocalFileSystemObjectStoreBackend { path: prefix.to_path_buf() }); + let client = backend.mk_object_store().await?; + let storage = ObjectStorage { backend, client: OnceCell::new_with(Some(client)) }; + Ok(storage) + } + + pub async fn new_s3( + bucket: String, + prefix: Option, + credentials: Option, + config: Option, ) -> Result { - let url: Url = Url::parse(config.url.as_str()) - .map_err(|e| StorageError::Other(e.to_string()))?; - if url.scheme() == "file" { - let path = url.path(); - let store = Arc::new(LocalFileSystem::new_with_prefix(path)?); - return Ok(ObjectStorage { - store, - config: ObjectStorageConfig { - url: url.to_string(), - prefix: "".to_string(), - options: config.options, - }, - }); - } + let backend = + Arc::new(S3ObjectStoreBackend { bucket, prefix, credentials, config }); + let client = backend.mk_object_store().await?; + let storage = ObjectStorage { backend, client: OnceCell::new_with(Some(client)) }; - let (store, path) = parse_url_opts(&url, config.options.clone()) - .map_err(|e| StorageError::Other(e.to_string()))?; - let store: Arc = Arc::from(store); - Ok(ObjectStorage { - store, - config: ObjectStorageConfig { - url: url.to_string(), - prefix: path.to_string(), - options: config.options, - }, - }) + Ok(storage) + } + + pub async fn new_azure( + account: String, + container: String, + prefix: Option, + credentials: Option, + config: Option>, + ) -> Result { + let backend = Arc::new(AzureObjectStoreBackend { + account, + container, + prefix, + credentials, + config, + }); + let client = backend.mk_object_store().await?; + let storage = ObjectStorage { backend, client: OnceCell::new_with(Some(client)) }; + + Ok(storage) + } + + pub async fn new_gcs( + bucket: String, + prefix: Option, + credentials: Option, + config: Option>, + ) -> Result { + let backend = + Arc::new(GcsObjectStoreBackend { bucket, prefix, credentials, config }); + let client = backend.mk_object_store().await?; + let storage = ObjectStorage { backend, client: OnceCell::new_with(Some(client)) }; + + Ok(storage) + } + + /// Get the client, initializing it if it hasn't been initialized yet. This is necessary because the + /// client is not serializeable and must be initialized after deserialization. Under normal construction + /// the original client is returned immediately. + #[instrument(skip(self))] + async fn get_client(&self) -> &Arc { + self.client + .get_or_init(|| async { + // TODO: handle error better? + #[allow(clippy::expect_used)] + self.backend + .mk_object_store() + .await + .expect("failed to create object store") + }) + .await } /// We need this because object_store's local file implementation doesn't sort refs. Since this /// implementation is used only for tests, it's OK to sort in memory. pub fn artificially_sort_refs_in_mem(&self) -> bool { - self.config.url.starts_with("file") - } - - /// We need this because object_store's local file implementation doesn't support metadata. - pub fn supports_metadata(&self) -> bool { - !self.config.url.starts_with("file") + self.backend.artificially_sort_refs_in_mem() } /// Return all keys in the store @@ -126,7 +164,8 @@ impl ObjectStorage { /// Intended for testing and debugging purposes only. pub async fn all_keys(&self) -> StorageResult> { Ok(self - .store + .get_client() + .await .list(None) .map_ok(|obj| obj.location.to_string()) .try_collect() @@ -134,7 +173,7 @@ impl ObjectStorage { } fn get_path_str(&self, file_prefix: &str, id: &str) -> ObjectPath { - let path = format!("{}/{}/{}", self.config.prefix, file_prefix, id); + let path = format!("{}/{}/{}", self.backend.prefix(), file_prefix, id); ObjectPath::from(path) } @@ -173,29 +212,7 @@ impl ObjectStorage { fn ref_key(&self, ref_key: &str) -> ObjectPath { // ObjectPath knows how to deal with empty path parts: bar//foo - ObjectPath::from(format!( - "{}/{}/{}", - self.config.prefix.as_str(), - REF_PREFIX, - ref_key - )) - } - - async fn do_ref_versions(&self, ref_name: &str) -> BoxStream> { - let prefix = self.ref_key(ref_name); - self.store - .list(Some(prefix.clone()).as_ref()) - .map_err(|e| e.into()) - .and_then(move |meta| { - ready( - self.drop_prefix(&prefix, &meta.location) - .map(|path| path.to_string()) - .ok_or(StorageError::Other( - "Bug in ref prefix logic".to_string(), - )), - ) - }) - .boxed() + ObjectPath::from(format!("{}/{}/{}", self.backend.prefix(), REF_PREFIX, ref_key)) } async fn delete_batch( @@ -204,7 +221,7 @@ impl ObjectStorage { batch: Vec, ) -> StorageResult { let keys = batch.iter().map(|id| Ok(self.get_path_str(prefix, id))); - let results = self.store.delete_stream(stream::iter(keys).boxed()); + let results = self.get_client().await.delete_stream(stream::iter(keys).boxed()); // FIXME: flag errors instead of skipping them Ok(results.filter(|res| ready(res.is_ok())).count().await) } @@ -215,7 +232,8 @@ impl ObjectStorage { path: &ObjectPath, ) -> StorageResult { Ok(self - .store + .get_client() + .await .get(path) .await? .into_stream() @@ -224,8 +242,12 @@ impl ObjectStorage { .compat()) } - fn metadata_to_attributes(&self, metadata: Vec<(String, String)>) -> Attributes { - if self.supports_metadata() { + fn metadata_to_attributes( + &self, + settings: &Settings, + metadata: Vec<(String, String)>, + ) -> Attributes { + if settings.unsafe_use_metadata() { Attributes::from_iter(metadata.into_iter().map(|(key, val)| { ( Attribute::Metadata(std::borrow::Cow::Owned(key)), @@ -236,78 +258,81 @@ impl ObjectStorage { Attributes::new() } } -} -impl private::Sealed for ObjectStorage {} + fn get_ref_name(&self, prefix: &ObjectPath, meta: &ObjectMeta) -> Option { + let relative_key = self.drop_prefix(prefix, &meta.location)?; + let parent = relative_key.parts().next()?; + Some(parent.as_ref().to_string()) + } -impl<'de> serde::Deserialize<'de> for ObjectStorage { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let config = ObjectStorageConfig::deserialize(deserializer)?; - ObjectStorage::from_config(config).map_err(serde::de::Error::custom) + fn get_put_mode( + &self, + settings: &Settings, + previous_version: &VersionInfo, + ) -> PutMode { + match ( + previous_version.is_create(), + settings.unsafe_use_conditional_create(), + settings.unsafe_use_conditional_update(), + ) { + (true, true, _) => PutMode::Create, + (true, false, _) => PutMode::Overwrite, + + (false, _, true) => PutMode::Update(UpdateVersion { + e_tag: previous_version.etag().cloned(), + version: previous_version.generation().cloned(), + }), + (false, _, false) => PutMode::Overwrite, + } } } +impl fmt::Display for ObjectStorage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "ObjectStorage(backend={})", self.backend) + } +} + +impl private::Sealed for ObjectStorage {} + #[async_trait] #[typetag::serde] impl Storage for ObjectStorage { + #[instrument(skip(self))] fn default_settings(&self) -> Settings { - let base = Settings::default(); - let url = Url::parse(self.config.url.as_str()); - let scheme = url.as_ref().map(|url| url.scheme()).unwrap_or("s3"); - match scheme { - "file" => Settings { - concurrency: Some(ConcurrencySettings { - max_concurrent_requests_for_object: Some( - NonZeroU16::new(5).unwrap_or(NonZeroU16::MIN), - ), - ideal_concurrent_request_size: Some( - NonZeroU64::new(4 * 1024).unwrap_or(NonZeroU64::MIN), - ), - }), - }, - "memory" => Settings { - concurrency: Some(ConcurrencySettings { - // we do != 1 because we use this store for tests - max_concurrent_requests_for_object: Some( - NonZeroU16::new(5).unwrap_or(NonZeroU16::MIN), - ), - ideal_concurrent_request_size: Some( - NonZeroU64::new(1).unwrap_or(NonZeroU64::MIN), - ), - }), - }, - - _ => base, - } + self.backend.default_settings() } + #[instrument(skip(self, _settings))] async fn fetch_config( &self, _settings: &Settings, - ) -> StorageResult> { + ) -> StorageResult { let path = self.get_config_path(); - let response = self.store.get(&path).await; + let response = self.get_client().await.get(&path).await; match response { - Ok(result) => match result.meta.e_tag.clone() { - Some(etag) => Ok(Some((result.bytes().await?, etag))), - None => Err(StorageError::Other("No ETag found for config".to_string())), - }, - Err(object_store::Error::NotFound { .. }) => Ok(None), + Ok(result) => { + let version = VersionInfo { + etag: result.meta.e_tag.as_ref().cloned().map(ETag), + generation: result.meta.version.as_ref().cloned().map(Generation), + }; + + Ok(FetchConfigResult::Found { bytes: result.bytes().await?, version }) + } + Err(object_store::Error::NotFound { .. }) => Ok(FetchConfigResult::NotFound), Err(err) => Err(err.into()), } } + #[instrument(skip(self, settings, config))] async fn update_config( &self, - _settings: &Settings, + settings: &Settings, config: Bytes, - etag: Option<&str>, - ) -> StorageResult { + previous_version: &VersionInfo, + ) -> StorageResult { let path = self.get_config_path(); - let attributes = if self.supports_metadata() { + let attributes = if settings.unsafe_use_metadata() { Attributes::from_iter(vec![( Attribute::ContentType, AttributeValue::from("application/yaml"), @@ -316,31 +341,26 @@ impl Storage for ObjectStorage { Attributes::new() }; - let mode = if let Some(etag) = etag { - PutMode::Update(UpdateVersion { - e_tag: Some(etag.to_string()), - version: None, - }) - } else { - PutMode::Create - }; + let mode = self.get_put_mode(settings, previous_version); let options = PutOptions { mode, attributes, ..PutOptions::default() }; - let res = self.store.put_opts(&path, config.into(), options).await; + let res = self.get_client().await.put_opts(&path, config.into(), options).await; match res { Ok(res) => { - let etag = res.e_tag.ok_or(StorageError::Other( - "Config object should have an etag".to_string(), - ))?; - Ok(etag) + let new_version = VersionInfo { + etag: res.e_tag.map(ETag), + generation: res.version.map(Generation), + }; + Ok(UpdateConfigResult::Updated { new_version }) } Err(object_store::Error::Precondition { .. }) => { - Err(StorageError::ConfigUpdateConflict) + Ok(UpdateConfigResult::NotOnLatestVersion) } Err(err) => Err(err.into()), } } + #[instrument(skip(self, settings))] async fn fetch_snapshot( &self, settings: &Settings, @@ -350,6 +370,7 @@ impl Storage for ObjectStorage { Ok(Box::new(self.get_object_reader(settings, &path).await?)) } + #[instrument(skip(self, settings))] async fn fetch_manifest_known_size( &self, settings: &Settings, @@ -360,6 +381,7 @@ impl Storage for ObjectStorage { self.get_object_concurrently(settings, path.as_ref(), &(0..size)).await } + #[instrument(skip(self, settings))] async fn fetch_manifest_unknown_size( &self, settings: &Settings, @@ -369,6 +391,7 @@ impl Storage for ObjectStorage { Ok(Box::new(self.get_object_reader(settings, &path).await?)) } + #[instrument(skip(self, settings))] async fn fetch_transaction_log( &self, settings: &Settings, @@ -378,51 +401,55 @@ impl Storage for ObjectStorage { Ok(Box::new(self.get_object_reader(settings, &path).await?)) } + #[instrument(skip(self, settings, metadata, bytes))] async fn write_snapshot( &self, - _settings: &Settings, + settings: &Settings, id: SnapshotId, metadata: Vec<(String, String)>, bytes: Bytes, ) -> StorageResult<()> { let path = self.get_snapshot_path(&id); - let attributes = self.metadata_to_attributes(metadata); + let attributes = self.metadata_to_attributes(settings, metadata); let options = PutOptions { attributes, ..PutOptions::default() }; // FIXME: use multipart - self.store.put_opts(&path, bytes.into(), options).await?; + self.get_client().await.put_opts(&path, bytes.into(), options).await?; Ok(()) } + #[instrument(skip(self, settings, metadata, bytes))] async fn write_manifest( &self, - _settings: &Settings, + settings: &Settings, id: ManifestId, metadata: Vec<(String, String)>, bytes: Bytes, ) -> StorageResult<()> { let path = self.get_manifest_path(&id); - let attributes = self.metadata_to_attributes(metadata); + let attributes = self.metadata_to_attributes(settings, metadata); let options = PutOptions { attributes, ..PutOptions::default() }; // FIXME: use multipart - self.store.put_opts(&path, bytes.into(), options).await?; + self.get_client().await.put_opts(&path, bytes.into(), options).await?; Ok(()) } + #[instrument(skip(self, settings, metadata, bytes))] async fn write_transaction_log( &self, - _settings: &Settings, + settings: &Settings, id: SnapshotId, metadata: Vec<(String, String)>, bytes: Bytes, ) -> StorageResult<()> { let path = self.get_transaction_path(&id); - let attributes = self.metadata_to_attributes(metadata); + let attributes = self.metadata_to_attributes(settings, metadata); let options = PutOptions { attributes, ..PutOptions::default() }; // FIXME: use multipart - self.store.put_opts(&path, bytes.into(), options).await?; + self.get_client().await.put_opts(&path, bytes.into(), options).await?; Ok(()) } + #[instrument(skip(self, settings))] async fn fetch_chunk( &self, settings: &Settings, @@ -436,6 +463,7 @@ impl Storage for ObjectStorage { .await } + #[instrument(skip(self, _settings))] async fn write_chunk( &self, _settings: &Settings, @@ -443,7 +471,7 @@ impl Storage for ObjectStorage { bytes: bytes::Bytes, ) -> Result<(), StorageError> { let path = self.get_chunk_path(&id); - let upload = self.store.put_multipart(&path).await?; + let upload = self.get_client().await.put_multipart(&path).await?; // TODO: new_with_chunk_size? let mut write = object_store::WriteMultipart::new(upload); write.write(&bytes); @@ -451,86 +479,77 @@ impl Storage for ObjectStorage { Ok(()) } - async fn get_ref(&self, _settings: &Settings, ref_key: &str) -> StorageResult { + #[instrument(skip(self, _settings))] + async fn get_ref( + &self, + _settings: &Settings, + ref_key: &str, + ) -> StorageResult { let key = self.ref_key(ref_key); - match self.store.get(&key).await { - Ok(res) => Ok(res.bytes().await?), - Err(object_store::Error::NotFound { .. }) => { - Err(StorageError::RefNotFound(key.to_string())) + match self.get_client().await.get(&key).await { + Ok(res) => { + let etag = res.meta.e_tag.clone().map(ETag); + let generation = res.meta.version.clone().map(Generation); + Ok(GetRefResult::Found { + bytes: res.bytes().await?, + version: VersionInfo { etag, generation }, + }) } + Err(object_store::Error::NotFound { .. }) => Ok(GetRefResult::NotFound), Err(err) => Err(err.into()), } } + #[instrument(skip(self, _settings))] async fn ref_names(&self, _settings: &Settings) -> StorageResult> { - // FIXME: i don't think object_store's implementation of list_with_delimiter is any good - // we need to test if it even works beyond 1k refs let prefix = self.ref_key(""); Ok(self - .store - .list_with_delimiter(Some(prefix.clone()).as_ref()) - .await? - .common_prefixes - .iter() - .filter_map(|path| { - self.drop_prefix(&prefix, path).map(|path| path.to_string()) - }) - .collect()) - } - - async fn ref_versions( - &self, - _settings: &Settings, - ref_name: &str, - ) -> StorageResult>> { - let res = self.do_ref_versions(ref_name).await; - if self.artificially_sort_refs_in_mem() { - #[allow(clippy::expect_used)] - // This branch is used for local tests, not in production. We don't expect the size of - // these streams to be large, so we can collect in memory and fail early if there is an - // error - let mut all = - res.try_collect::>().await.expect("Error fetching ref versions"); - all.sort(); - Ok(futures::stream::iter(all.into_iter().map(Ok)).boxed()) - } else { - Ok(res) - } + .get_client() + .await + .list(Some(prefix.clone()).as_ref()) + .try_filter_map(|meta| ready(Ok(self.get_ref_name(&prefix, &meta)))) + .try_collect() + .await?) } + #[instrument(skip(self, settings, bytes))] async fn write_ref( &self, - _settings: &Settings, + settings: &Settings, ref_key: &str, - overwrite_refs: bool, bytes: Bytes, - ) -> StorageResult<()> { + previous_version: &VersionInfo, + ) -> StorageResult { let key = self.ref_key(ref_key); - let mode = if overwrite_refs { PutMode::Overwrite } else { PutMode::Create }; + let mode = self.get_put_mode(settings, previous_version); let opts = PutOptions { mode, ..PutOptions::default() }; - self.store + match self + .get_client() + .await .put_opts(&key, PutPayload::from_bytes(bytes), opts) .await - .map_err(|e| match e { - object_store::Error::AlreadyExists { path, .. } => { - StorageError::RefAlreadyExists(path) - } - _ => e.into(), - }) - .map(|_| ()) + { + Ok(_) => Ok(WriteRefResult::Written), + Err(object_store::Error::Precondition { .. }) + | Err(object_store::Error::AlreadyExists { .. }) => { + Ok(WriteRefResult::WontOverwrite) + } + Err(err) => Err(err.into()), + } } + #[instrument(skip(self, _settings))] async fn list_objects<'a>( &'a self, _settings: &Settings, prefix: &str, ) -> StorageResult>>> { - let prefix = - ObjectPath::from(format!("{}/{}", self.config.prefix.as_str(), prefix)); + let prefix = ObjectPath::from(format!("{}/{}", self.backend.prefix(), prefix)); let stream = self - .store + .get_client() + .await .list(Some(&prefix)) // TODO: we should signal error instead of filtering .try_filter_map(|object| ready(Ok(object_to_list_info(&object)))) @@ -538,6 +557,7 @@ impl Storage for ObjectStorage { Ok(stream.boxed()) } + #[instrument(skip(self, _settings, ids))] async fn delete_objects( &self, _settings: &Settings, @@ -559,25 +579,18 @@ impl Storage for ObjectStorage { Ok(deleted.into_inner()) } + #[instrument(skip(self, _settings))] async fn get_snapshot_last_modified( &self, _settings: &Settings, snapshot: &SnapshotId, ) -> StorageResult> { let path = self.get_snapshot_path(snapshot); - let res = self.store.head(&path).await?; + let res = self.get_client().await.head(&path).await?; Ok(res.last_modified) } - async fn root_is_clean(&self) -> StorageResult { - Ok(self - .store - .list(Some(&ObjectPath::from(self.config.prefix.clone()))) - .next() - .await - .is_none()) - } - + #[instrument(skip(self))] async fn get_object_range_buf( &self, key: &str, @@ -587,9 +600,10 @@ impl Storage for ObjectStorage { let usize_range = range.start as usize..range.end as usize; let range = Some(usize_range.into()); let opts = GetOptions { range, ..Default::default() }; - Ok(Box::new(self.store.get_opts(&path, opts).await?.bytes().await?)) + Ok(Box::new(self.get_client().await.get_opts(&path, opts).await?.bytes().await?)) } + #[instrument(skip(self))] async fn get_object_range_read( &self, key: &str, @@ -600,7 +614,8 @@ impl Storage for ObjectStorage { let range = Some(usize_range.into()); let opts = GetOptions { range, ..Default::default() }; let res: Box = Box::new( - self.store + self.get_client() + .await .get_opts(&path, opts) .await? .into_stream() @@ -612,6 +627,389 @@ impl Storage for ObjectStorage { } } +#[async_trait] +#[typetag::serde(tag = "object_store_provider_type")] +pub trait ObjectStoreBackend: Debug + Display + Sync + Send { + async fn mk_object_store(&self) -> Result, StorageError>; + + /// The prefix for the object store. + fn prefix(&self) -> String; + + /// We need this because object_store's local file implementation doesn't sort refs. Since this + /// implementation is used only for tests, it's OK to sort in memory. + fn artificially_sort_refs_in_mem(&self) -> bool { + false + } + + fn default_settings(&self) -> Settings; +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct InMemoryObjectStoreBackend; + +impl fmt::Display for InMemoryObjectStoreBackend { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "InMemoryObjectStoreBackend") + } +} + +#[async_trait] +#[typetag::serde(name = "in_memory_object_store_provider")] +impl ObjectStoreBackend for InMemoryObjectStoreBackend { + async fn mk_object_store(&self) -> Result, StorageError> { + Ok(Arc::new(InMemory::new())) + } + + fn prefix(&self) -> String { + "".to_string() + } + + fn default_settings(&self) -> Settings { + Settings { + concurrency: Some(ConcurrencySettings { + // we do != 1 because we use this store for tests + max_concurrent_requests_for_object: Some( + NonZeroU16::new(5).unwrap_or(NonZeroU16::MIN), + ), + ideal_concurrent_request_size: Some( + NonZeroU64::new(1).unwrap_or(NonZeroU64::MIN), + ), + }), + ..Default::default() + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct LocalFileSystemObjectStoreBackend { + path: PathBuf, +} + +impl fmt::Display for LocalFileSystemObjectStoreBackend { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "LocalFileSystemObjectStoreBackend(path={})", self.path.display()) + } +} + +#[async_trait] +#[typetag::serde(name = "local_file_system_object_store_provider")] +impl ObjectStoreBackend for LocalFileSystemObjectStoreBackend { + async fn mk_object_store(&self) -> Result, StorageError> { + _ = create_dir_all(&self.path) + .map_err(|e| StorageErrorKind::Other(e.to_string()))?; + + let path = std::fs::canonicalize(&self.path) + .map_err(|e| StorageErrorKind::Other(e.to_string()))?; + Ok(Arc::new( + LocalFileSystem::new_with_prefix(path) + .map_err(|e| StorageErrorKind::Other(e.to_string()))?, + )) + } + + fn prefix(&self) -> String { + "".to_string() + } + + fn artificially_sort_refs_in_mem(&self) -> bool { + true + } + + fn default_settings(&self) -> Settings { + Settings { + concurrency: Some(ConcurrencySettings { + max_concurrent_requests_for_object: Some( + NonZeroU16::new(5).unwrap_or(NonZeroU16::MIN), + ), + ideal_concurrent_request_size: Some( + NonZeroU64::new(4 * 1024).unwrap_or(NonZeroU64::MIN), + ), + }), + unsafe_use_conditional_update: Some(false), + unsafe_use_metadata: Some(false), + ..Default::default() + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct S3ObjectStoreBackend { + bucket: String, + prefix: Option, + credentials: Option, + config: Option, +} + +impl fmt::Display for S3ObjectStoreBackend { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "S3ObjectStoreBackend(bucket={}, prefix={}, config={})", + self.bucket, + self.prefix.as_deref().unwrap_or(""), + self.config.as_ref().map(|c| c.to_string()).unwrap_or("None".to_string()) + ) + } +} + +#[async_trait] +#[typetag::serde(name = "s3_object_store_provider")] +impl ObjectStoreBackend for S3ObjectStoreBackend { + async fn mk_object_store(&self) -> Result, StorageError> { + let builder = AmazonS3Builder::new(); + + let builder = match self.credentials.as_ref() { + Some(S3Credentials::Static(credentials)) => { + let builder = builder + .with_access_key_id(credentials.access_key_id.clone()) + .with_secret_access_key(credentials.secret_access_key.clone()); + + let builder = + if let Some(session_token) = credentials.session_token.as_ref() { + builder.with_token(session_token.clone()) + } else { + builder + }; + + builder + } + Some(S3Credentials::Anonymous) => builder.with_skip_signature(true), + // TODO: Support refreshable credentials + _ => AmazonS3Builder::from_env(), + }; + + let builder = if let Some(config) = self.config.as_ref() { + let builder = if let Some(region) = config.region.as_ref() { + builder.with_region(region.to_string()) + } else { + builder + }; + + let builder = if let Some(endpoint) = config.endpoint_url.as_ref() { + builder.with_endpoint(endpoint.to_string()) + } else { + builder + }; + + builder + .with_skip_signature(config.anonymous) + .with_allow_http(config.allow_http) + } else { + builder + }; + + // Defaults + let builder = builder + .with_bucket_name(&self.bucket) + .with_conditional_put(object_store::aws::S3ConditionalPut::ETagMatch); + + let store = + builder.build().map_err(|e| StorageErrorKind::Other(e.to_string()))?; + Ok(Arc::new(store)) + } + + fn prefix(&self) -> String { + self.prefix.clone().unwrap_or("".to_string()) + } + + fn default_settings(&self) -> Settings { + Default::default() + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct AzureObjectStoreBackend { + account: String, + container: String, + prefix: Option, + credentials: Option, + config: Option>, +} + +impl fmt::Display for AzureObjectStoreBackend { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "AzureObjectStoreBackend(account={}, container={}, prefix={})", + self.account, + self.container, + self.prefix.as_deref().unwrap_or("") + ) + } +} + +#[async_trait] +#[typetag::serde(name = "azure_object_store_provider")] +impl ObjectStoreBackend for AzureObjectStoreBackend { + async fn mk_object_store(&self) -> Result, StorageError> { + let builder = MicrosoftAzureBuilder::new(); + + let builder = match self.credentials.as_ref() { + Some(AzureCredentials::Static(AzureStaticCredentials::AccessKey(key))) => { + builder.with_access_key(key) + } + Some(AzureCredentials::Static(AzureStaticCredentials::SASToken(token))) => { + builder.with_config(AzureConfigKey::SasKey, token) + } + Some(AzureCredentials::Static(AzureStaticCredentials::BearerToken( + token, + ))) => builder.with_bearer_token_authorization(token), + None | Some(AzureCredentials::FromEnv) => MicrosoftAzureBuilder::from_env(), + }; + + // Either the account name should be provided or user_emulator should be set to true to use the default account + let builder = + builder.with_account(&self.account).with_container_name(&self.container); + + let builder = self + .config + .as_ref() + .unwrap_or(&HashMap::new()) + .iter() + .fold(builder, |builder, (key, value)| builder.with_config(*key, value)); + + let store = + builder.build().map_err(|e| StorageErrorKind::Other(e.to_string()))?; + Ok(Arc::new(store)) + } + + fn prefix(&self) -> String { + self.prefix.clone().unwrap_or("".to_string()) + } + + fn default_settings(&self) -> Settings { + Default::default() + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GcsObjectStoreBackend { + bucket: String, + prefix: Option, + credentials: Option, + config: Option>, +} + +impl fmt::Display for GcsObjectStoreBackend { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "GcsObjectStoreBackend(bucket={}, prefix={})", + self.bucket, + self.prefix.as_deref().unwrap_or("") + ) + } +} + +#[async_trait] +#[typetag::serde(name = "gcs_object_store_provider")] +impl ObjectStoreBackend for GcsObjectStoreBackend { + async fn mk_object_store(&self) -> Result, StorageError> { + let builder = GoogleCloudStorageBuilder::new(); + + let builder = match self.credentials.as_ref() { + Some(GcsCredentials::Static(GcsStaticCredentials::ServiceAccount(path))) => { + let path = path.clone().into_os_string().into_string().map_err(|_| { + StorageErrorKind::Other("invalid service account path".to_string()) + })?; + builder.with_service_account_path(path) + } + Some(GcsCredentials::Static(GcsStaticCredentials::ServiceAccountKey( + key, + ))) => builder.with_service_account_key(key), + Some(GcsCredentials::Static( + GcsStaticCredentials::ApplicationCredentials(path), + )) => { + let path = path.clone().into_os_string().into_string().map_err(|_| { + StorageErrorKind::Other( + "invalid application credentials path".to_string(), + ) + })?; + builder.with_application_credentials(path) + } + Some(GcsCredentials::Static(GcsStaticCredentials::BearerToken(token))) => { + let provider = StaticCredentialProvider::new(GcpCredential::from(token)); + builder.with_credentials(Arc::new(provider)) + } + Some(GcsCredentials::Refreshable(fetcher)) => { + let credential_provider = + GcsRefreshableCredentialProvider::new(Arc::clone(fetcher)); + builder.with_credentials(Arc::new(credential_provider)) + } + None | Some(GcsCredentials::FromEnv) => GoogleCloudStorageBuilder::from_env(), + }; + + let builder = builder.with_bucket_name(&self.bucket); + + // Add options + let builder = self + .config + .as_ref() + .unwrap_or(&HashMap::new()) + .iter() + .fold(builder, |builder, (key, value)| builder.with_config(*key, value)); + + let store = + builder.build().map_err(|e| StorageErrorKind::Other(e.to_string()))?; + Ok(Arc::new(store)) + } + + fn prefix(&self) -> String { + self.prefix.clone().unwrap_or("".to_string()) + } + + fn default_settings(&self) -> Settings { + Default::default() + } +} + +#[derive(Debug)] +pub struct GcsRefreshableCredentialProvider { + last_credential: Arc>>, + refresher: Arc, +} + +impl GcsRefreshableCredentialProvider { + pub fn new(refresher: Arc) -> Self { + Self { last_credential: Arc::new(Mutex::new(None)), refresher } + } + + pub async fn get_or_update_credentials( + &self, + ) -> Result { + let mut last_credential = self.last_credential.lock().await; + + // If we have a credential and it hasn't expired, return it + if let Some(creds) = last_credential.as_ref() { + if let Some(expires_after) = creds.expires_after { + if expires_after > Utc::now() { + return Ok(creds.clone()); + } + } + } + + // Otherwise, refresh the credential and cache it + let creds = self + .refresher + .get() + .await + .map_err(|e| StorageErrorKind::Other(e.to_string()))?; + *last_credential = Some(creds.clone()); + Ok(creds) + } +} + +#[async_trait] +impl CredentialProvider for GcsRefreshableCredentialProvider { + type Credential = GcpCredential; + + async fn get_credential(&self) -> object_store::Result> { + let creds = self.get_or_update_credentials().await.map_err(|e| { + object_store::Error::Generic { store: "gcp", source: Box::new(e) } + })?; + Ok(Arc::new(GcpCredential::from(&creds))) + } +} + fn object_to_list_info(object: &ObjectMeta) -> Option> { let created_at = object.last_modified; let id = object.location.filename()?.to_string(); @@ -627,23 +1025,18 @@ mod tests { use super::ObjectStorage; - #[test] - fn test_serialize_object_store() { + #[tokio::test] + async fn test_serialize_object_store() { let tmp_dir = TempDir::new().unwrap(); - let store = ObjectStorage::new_local_filesystem(tmp_dir.path()).unwrap(); + let store = ObjectStorage::new_local_filesystem(tmp_dir.path()).await.unwrap(); let serialized = serde_json::to_string(&store).unwrap(); + let deserialized: ObjectStorage = serde_json::from_str(&serialized).unwrap(); assert_eq!( - serialized, - format!( - r#"{{"url":"file://{}","prefix":"","options":[]}}"#, - std::fs::canonicalize(tmp_dir.path()).unwrap().to_str().unwrap() - ) + store.backend.as_ref().prefix(), + deserialized.backend.as_ref().prefix() ); - - let deserialized: ObjectStorage = serde_json::from_str(&serialized).unwrap(); - assert_eq!(store.config, deserialized.config); } struct TestLocalPath(String); @@ -660,23 +1053,23 @@ mod tests { } } - #[test] - fn test_canonicalize_path() { + #[tokio::test] + async fn test_canonicalize_path() { // Absolute path let tmp_dir = TempDir::new().unwrap(); - let store = ObjectStorage::new_local_filesystem(tmp_dir.path()); + let store = ObjectStorage::new_local_filesystem(tmp_dir.path()).await; assert!(store.is_ok()); // Relative path let rel_path = "relative/path"; let store = - ObjectStorage::new_local_filesystem(PathBuf::from(&rel_path).as_path()); + ObjectStorage::new_local_filesystem(PathBuf::from(&rel_path).as_path()).await; assert!(store.is_ok()); // Relative with leading ./ let rel_path = TestLocalPath("./other/path".to_string()); let store = - ObjectStorage::new_local_filesystem(PathBuf::from(&rel_path).as_path()); + ObjectStorage::new_local_filesystem(PathBuf::from(&rel_path).as_path()).await; assert!(store.is_ok()); } } diff --git a/icechunk/src/storage/s3.rs b/icechunk/src/storage/s3.rs index 5d4b5c22..a40b657b 100644 --- a/icechunk/src/storage/s3.rs +++ b/icechunk/src/storage/s3.rs @@ -1,4 +1,5 @@ use std::{ + fmt, future::ready, ops::Range, path::{Path, PathBuf}, @@ -9,11 +10,10 @@ use std::{ }; use crate::{ - config::{CredentialsFetcher, S3Credentials, S3Options}, + config::{S3Credentials, S3CredentialsFetcher, S3Options}, format::{ChunkId, ChunkOffset, FileTypeTag, ManifestId, ObjectId, SnapshotId}, private, Storage, StorageError, }; -use async_stream::try_stream; use async_trait::async_trait; use aws_config::{ meta::region::RegionProviderChain, retry::ProvideErrorKind, AppName, BehaviorVersion, @@ -30,16 +30,19 @@ use aws_sdk_s3::{ use aws_smithy_types_convert::{date_time::DateTimeExt, stream::PaginationStreamExt}; use bytes::{Buf, Bytes}; use chrono::{DateTime, Utc}; +use err_into::ErrorInto as _; use futures::{ stream::{self, BoxStream}, StreamExt, TryStreamExt, }; use serde::{Deserialize, Serialize}; use tokio::{io::AsyncRead, sync::OnceCell}; +use tracing::instrument; use super::{ - ETag, ListInfo, Reader, Settings, StorageResult, CHUNK_PREFIX, CONFIG_PATH, - MANIFEST_PREFIX, REF_PREFIX, SNAPSHOT_PREFIX, TRANSACTION_PREFIX, + FetchConfigResult, GetRefResult, ListInfo, Reader, Settings, StorageErrorKind, + StorageResult, UpdateConfigResult, VersionInfo, WriteRefResult, CHUNK_PREFIX, + CONFIG_PATH, MANIFEST_PREFIX, REF_PREFIX, SNAPSHOT_PREFIX, TRANSACTION_PREFIX, }; #[derive(Debug, Serialize, Deserialize)] @@ -57,6 +60,17 @@ pub struct S3Storage { client: OnceCell>, } +impl fmt::Display for S3Storage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "S3Storage(bucket={}, prefix={}, config={})", + self.bucket, self.prefix, self.config, + ) + } +} + +#[instrument(skip(credentials))] pub async fn mk_client(config: &S3Options, credentials: S3Credentials) -> Client { let region = config .region @@ -125,6 +139,7 @@ impl S3Storage { /// Get the client, initializing it if it hasn't been initialized yet. This is necessary because the /// client is not serializeable and must be initialized after deserialization. Under normal construction /// the original client is returned immediately. + #[instrument(skip(self))] async fn get_client(&self) -> &Arc { self.client .get_or_init(|| async { @@ -135,7 +150,10 @@ impl S3Storage { fn get_path_str(&self, file_prefix: &str, id: &str) -> StorageResult { let path = PathBuf::from_iter([self.prefix.as_str(), file_prefix, id]); - path.into_os_string().into_string().map_err(StorageError::BadPrefix) + path.into_os_string() + .into_string() + .map_err(StorageErrorKind::BadPrefix) + .err_into() } fn get_path( @@ -169,7 +187,10 @@ impl S3Storage { fn ref_key(&self, ref_key: &str) -> StorageResult { let path = PathBuf::from_iter([self.prefix.as_str(), REF_PREFIX, ref_key]); - path.into_os_string().into_string().map_err(StorageError::BadPrefix) + path.into_os_string() + .into_string() + .map_err(StorageErrorKind::BadPrefix) + .err_into() } async fn get_object_reader( @@ -186,6 +207,7 @@ impl S3Storage { I: IntoIterator, impl Into)>, >( &self, + settings: &Settings, key: &str, content_type: Option>, metadata: I, @@ -194,14 +216,17 @@ impl S3Storage { let mut b = self.get_client().await.put_object().bucket(self.bucket.clone()).key(key); - if let Some(ct) = content_type { - b = b.content_type(ct) - }; - - for (k, v) in metadata { - b = b.metadata(k, v); + if settings.unsafe_use_metadata() { + if let Some(ct) = content_type { + b = b.content_type(ct) + }; } + if settings.unsafe_use_metadata() { + for (k, v) in metadata { + b = b.metadata(k, v); + } + } b.body(bytes.into()).send().await?; Ok(()) } @@ -224,7 +249,7 @@ impl S3Storage { let delete = Delete::builder() .set_objects(Some(keys)) .build() - .map_err(|e| StorageError::Other(e.to_string()))?; + .map_err(|e| StorageErrorKind::Other(e.to_string()))?; let res = self .get_client() @@ -237,6 +262,14 @@ impl S3Storage { Ok(res.deleted().len()) } + + fn get_ref_name<'a>(&self, key: Option<&'a str>) -> Option<&'a str> { + let key = key?; + let prefix = self.ref_key("").ok()?; + let relative_key = key.strip_prefix(&prefix)?; + let ref_name = relative_key.split('/').next()?; + Some(ref_name) + } } pub fn range_to_header(range: &Range) -> String { @@ -248,10 +281,11 @@ impl private::Sealed for S3Storage {} #[async_trait] #[typetag::serde] impl Storage for S3Storage { + #[instrument(skip(self, _settings))] async fn fetch_config( &self, _settings: &Settings, - ) -> StorageResult> { + ) -> StorageResult { let key = self.get_config_path()?; let res = self .get_client() @@ -264,22 +298,26 @@ impl Storage for S3Storage { match res { Ok(output) => match output.e_tag { - Some(etag) => Ok(Some((output.body.collect().await?.into_bytes(), etag))), - None => Err(StorageError::Other("No ETag found for config".to_string())), + Some(etag) => Ok(FetchConfigResult::Found { + bytes: output.body.collect().await?.into_bytes(), + version: VersionInfo::from_etag_only(etag), + }), + None => Ok(FetchConfigResult::NotFound), }, Err(sdk_err) => match sdk_err.as_service_error() { - Some(e) if e.is_no_such_key() => Ok(None), + Some(e) if e.is_no_such_key() => Ok(FetchConfigResult::NotFound), _ => Err(sdk_err.into()), }, } } + #[instrument(skip(self, settings, config))] async fn update_config( &self, - _settings: &Settings, + settings: &Settings, config: Bytes, - etag: Option<&str>, - ) -> StorageResult { + previous_version: &VersionInfo, + ) -> StorageResult { let key = self.get_config_path()?; let mut req = self .get_client() @@ -287,28 +325,39 @@ impl Storage for S3Storage { .put_object() .bucket(self.bucket.clone()) .key(key) - .content_type("application/yaml") .body(config.into()); - if let Some(etag) = etag { - req = req.if_match(etag) - } else { - req = req.if_none_match("*") + if settings.unsafe_use_metadata() { + req = req.content_type("application/yaml") + } + + match ( + previous_version.etag(), + settings.unsafe_use_conditional_create(), + settings.unsafe_use_conditional_update(), + ) { + (None, true, _) => req = req.if_none_match("*"), + (Some(etag), _, true) => req = req.if_match(etag), + (_, _, _) => {} } let res = req.send().await; match res { Ok(out) => { - let etag = out.e_tag().ok_or(StorageError::Other( - "Config object should have an etag".to_string(), - ))?; - Ok(etag.to_string()) + let new_etag = out + .e_tag() + .ok_or(StorageErrorKind::Other( + "Config object should have an etag".to_string(), + ))? + .to_string(); + let new_version = VersionInfo::from_etag_only(new_etag); + Ok(UpdateConfigResult::Updated { new_version }) } // minio returns this Err(SdkError::ServiceError(err)) => { if err.err().meta().code() == Some("PreconditionFailed") { - Err(StorageError::ConfigUpdateConflict) + Ok(UpdateConfigResult::NotOnLatestVersion) } else { Err(StorageError::from(SdkError::::ServiceError(err))) } @@ -318,7 +367,7 @@ impl Storage for S3Storage { let status = err.raw().status().as_u16(); // see https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html#API_PutObject_RequestSyntax if status == 409 || status == 412 { - Err(StorageError::ConfigUpdateConflict) + Ok(UpdateConfigResult::NotOnLatestVersion) } else { Err(StorageError::from(SdkError::::ResponseError( err, @@ -329,6 +378,7 @@ impl Storage for S3Storage { } } + #[instrument(skip(self, settings))] async fn fetch_snapshot( &self, settings: &Settings, @@ -338,6 +388,7 @@ impl Storage for S3Storage { self.get_object_reader(settings, key.as_str()).await } + #[instrument(skip(self, settings))] async fn fetch_manifest_known_size( &self, settings: &Settings, @@ -348,6 +399,7 @@ impl Storage for S3Storage { self.get_object_concurrently(settings, key.as_str(), &(0..size)).await } + #[instrument(skip(self, settings))] async fn fetch_manifest_unknown_size( &self, settings: &Settings, @@ -357,6 +409,7 @@ impl Storage for S3Storage { self.get_object_reader(settings, key.as_str()).await } + #[instrument(skip(self, settings))] async fn fetch_transaction_log( &self, settings: &Settings, @@ -366,6 +419,7 @@ impl Storage for S3Storage { self.get_object_reader(settings, key.as_str()).await } + #[instrument(skip(self, settings))] async fn fetch_chunk( &self, settings: &Settings, @@ -379,52 +433,75 @@ impl Storage for S3Storage { .await } + #[instrument(skip(self, settings, metadata, bytes))] async fn write_snapshot( &self, - _settings: &Settings, + settings: &Settings, id: SnapshotId, metadata: Vec<(String, String)>, bytes: Bytes, ) -> StorageResult<()> { let key = self.get_snapshot_path(&id)?; - self.put_object(key.as_str(), None::, metadata, bytes).await + self.put_object(settings, key.as_str(), None::, metadata, bytes).await } + #[instrument(skip(self, settings, metadata, bytes))] async fn write_manifest( &self, - _settings: &Settings, + settings: &Settings, id: ManifestId, metadata: Vec<(String, String)>, bytes: Bytes, ) -> StorageResult<()> { let key = self.get_manifest_path(&id)?; - self.put_object(key.as_str(), None::, metadata.into_iter(), bytes).await + self.put_object( + settings, + key.as_str(), + None::, + metadata.into_iter(), + bytes, + ) + .await } + #[instrument(skip(self, settings, metadata, bytes))] async fn write_transaction_log( &self, - _settings: &Settings, + settings: &Settings, id: SnapshotId, metadata: Vec<(String, String)>, bytes: Bytes, ) -> StorageResult<()> { let key = self.get_transaction_path(&id)?; - self.put_object(key.as_str(), None::, metadata.into_iter(), bytes).await + self.put_object( + settings, + key.as_str(), + None::, + metadata.into_iter(), + bytes, + ) + .await } + #[instrument(skip(self, settings, bytes))] async fn write_chunk( &self, - _settings: &Settings, + settings: &Settings, id: ChunkId, bytes: bytes::Bytes, ) -> Result<(), StorageError> { let key = self.get_chunk_path(&id)?; //FIXME: use multipart upload let metadata: [(String, String); 0] = []; - self.put_object(key.as_str(), None::, metadata, bytes).await + self.put_object(settings, key.as_str(), None::, metadata, bytes).await } - async fn get_ref(&self, _settings: &Settings, ref_key: &str) -> StorageResult { + #[instrument(skip(self, _settings))] + async fn get_ref( + &self, + _settings: &Settings, + ref_key: &str, + ) -> StorageResult { let key = self.ref_key(ref_key)?; let res = self .get_client() @@ -436,19 +513,27 @@ impl Storage for S3Storage { .await; match res { - Ok(res) => Ok(res.body.collect().await?.into_bytes()), + Ok(res) => { + let bytes = res.body.collect().await?.into_bytes(); + if let Some(version) = res.e_tag.map(VersionInfo::from_etag_only) { + Ok(GetRefResult::Found { bytes, version }) + } else { + Ok(GetRefResult::NotFound) + } + } Err(err) if err .as_service_error() .map(|e| e.is_no_such_key()) .unwrap_or(false) => { - Err(StorageError::RefNotFound(key.to_string())) + Ok(GetRefResult::NotFound) } Err(err) => Err(err.into()), } } + #[instrument(skip(self, _settings))] async fn ref_names(&self, _settings: &Settings) -> StorageResult> { let prefix = self.ref_key("")?; let mut paginator = self @@ -457,21 +542,16 @@ impl Storage for S3Storage { .list_objects_v2() .bucket(self.bucket.clone()) .prefix(prefix.clone()) - .delimiter("/") .into_paginator() .send(); let mut res = Vec::new(); while let Some(page) = paginator.try_next().await? { - for common_prefix in page.common_prefixes() { - if let Some(key) = common_prefix - .prefix() - .as_ref() - .and_then(|key| key.strip_prefix(prefix.as_str())) - .and_then(|key| key.strip_suffix('/')) - { - res.push(key.to_string()); + for obj in page.contents.unwrap_or_else(Vec::new) { + let name = self.get_ref_name(obj.key()); + if let Some(name) = name { + res.push(name.to_string()); } } } @@ -479,41 +559,14 @@ impl Storage for S3Storage { Ok(res) } - async fn ref_versions( - &self, - _settings: &Settings, - ref_name: &str, - ) -> StorageResult>> { - let prefix = self.ref_key(ref_name)?; - let mut paginator = self - .get_client() - .await - .list_objects_v2() - .bucket(self.bucket.clone()) - .prefix(prefix.clone()) - .into_paginator() - .send(); - - let prefix = prefix + "/"; - let stream = try_stream! { - while let Some(page) = paginator.try_next().await? { - for object in page.contents() { - if let Some(key) = object.key.as_ref().and_then(|key| key.strip_prefix(prefix.as_str())) { - yield key.to_string() - } - } - } - }; - Ok(stream.boxed()) - } - + #[instrument(skip(self, settings, bytes))] async fn write_ref( &self, - _settings: &Settings, + settings: &Settings, ref_key: &str, - overwrite_refs: bool, bytes: Bytes, - ) -> StorageResult<()> { + previous_version: &VersionInfo, + ) -> StorageResult { let key = self.ref_key(ref_key)?; let mut builder = self .get_client() @@ -522,20 +575,30 @@ impl Storage for S3Storage { .bucket(self.bucket.clone()) .key(key.clone()); - if !overwrite_refs { - builder = builder.if_none_match("*") + match ( + previous_version.etag(), + settings.unsafe_use_conditional_create(), + settings.unsafe_use_conditional_update(), + ) { + (None, true, _) => { + builder = builder.if_none_match("*"); + } + (Some(etag), _, true) => { + builder = builder.if_match(etag); + } + (_, _, _) => {} } let res = builder.body(bytes.into()).send().await; match res { - Ok(_) => Ok(()), + Ok(_) => Ok(WriteRefResult::Written), Err(err) => { let code = err.as_service_error().and_then(|e| e.code()).unwrap_or(""); if code.contains("PreconditionFailed") || code.contains("ConditionalRequestConflict") { - Err(StorageError::RefAlreadyExists(key)) + Ok(WriteRefResult::WontOverwrite) } else { Err(err.into()) } @@ -543,6 +606,7 @@ impl Storage for S3Storage { } } + #[instrument(skip(self, _settings))] async fn list_objects<'a>( &'a self, _settings: &Settings, @@ -551,7 +615,7 @@ impl Storage for S3Storage { let prefix = PathBuf::from_iter([self.prefix.as_str(), prefix]) .into_os_string() .into_string() - .map_err(StorageError::BadPrefix)?; + .map_err(StorageErrorKind::BadPrefix)?; let stream = self .get_client() .await @@ -571,6 +635,7 @@ impl Storage for S3Storage { Ok(stream.boxed()) } + #[instrument(skip(self, _settings, ids))] async fn delete_objects( &self, _settings: &Settings, @@ -592,6 +657,7 @@ impl Storage for S3Storage { Ok(deleted.into_inner()) } + #[instrument(skip(self, _settings))] async fn get_snapshot_last_modified( &self, _settings: &Settings, @@ -607,29 +673,17 @@ impl Storage for S3Storage { .send() .await?; - let res = res.last_modified.ok_or(StorageError::Other( + let res = res.last_modified.ok_or(StorageErrorKind::Other( "Object has no last_modified field".to_string(), ))?; - let res = res - .to_chrono_utc() - .map_err(|_| StorageError::Other("Invalid metadata timestamp".to_string()))?; + let res = res.to_chrono_utc().map_err(|_| { + StorageErrorKind::Other("Invalid metadata timestamp".to_string()) + })?; Ok(res) } - async fn root_is_clean(&self) -> StorageResult { - let res = self - .get_client() - .await - .list_objects_v2() - .bucket(self.bucket.clone()) - .prefix(self.prefix.clone()) - .max_keys(1) - .send() - .await?; - Ok(res.contents.map(|v| v.is_empty()).unwrap_or(true)) - } - + #[instrument(skip(self))] async fn get_object_range_buf( &self, key: &str, @@ -645,6 +699,7 @@ impl Storage for S3Storage { Ok(Box::new(b.send().await?.body.collect().await?)) } + #[instrument(skip(self))] async fn get_object_range_read( &self, key: &str, @@ -665,7 +720,7 @@ fn object_to_list_info(object: &Object) -> Option> { } #[derive(Debug)] -struct ProvideRefreshableCredentials(Arc); +struct ProvideRefreshableCredentials(Arc); impl ProvideCredentials for ProvideRefreshableCredentials { fn provide_credentials<'a>( @@ -737,7 +792,7 @@ mod tests { assert_eq!( serialized, - r#"{"config":{"region":"us-west-2","endpoint_url":"http://localhost:9000","anonymous":false,"allow_http":true},"credentials":{"type":"static","access_key_id":"access_key_id","secret_access_key":"secret_access_key","session_token":"session_token","expires_after":null},"bucket":"bucket","prefix":"prefix"}"# + r#"{"config":{"region":"us-west-2","endpoint_url":"http://localhost:9000","anonymous":false,"allow_http":true},"credentials":{"s3_credential_type":"static","access_key_id":"access_key_id","secret_access_key":"secret_access_key","session_token":"session_token","expires_after":null},"bucket":"bucket","prefix":"prefix"}"# ); let deserialized: S3Storage = serde_json::from_str(&serialized).unwrap(); diff --git a/icechunk/src/store.rs b/icechunk/src/store.rs index 90b013e1..27bb4871 100644 --- a/icechunk/src/store.rs +++ b/icechunk/src/store.rs @@ -2,7 +2,6 @@ use std::{ collections::HashSet, fmt::Display, iter, - num::NonZeroU64, ops::{Deref, DerefMut}, sync::{ atomic::{AtomicUsize, Ordering}, @@ -18,20 +17,18 @@ use serde::{de, Deserialize, Serialize}; use serde_with::{serde_as, TryFromInto}; use thiserror::Error; use tokio::sync::RwLock; +use tracing::instrument; use crate::{ + error::ICError, format::{ manifest::{ChunkPayload, VirtualChunkRef}, - snapshot::{NodeData, NodeSnapshot, UserAttributesSnapshot, ZarrArrayMetadata}, - ByteRange, ChunkIndices, ChunkOffset, IcechunkFormatError, Path, PathError, + snapshot::{ArrayShape, DimensionName, NodeData, NodeSnapshot}, + ByteRange, ChunkIndices, ChunkOffset, Path, PathError, }, - metadata::{ - ArrayShape, ChunkKeyEncoding, ChunkShape, Codec, DataType, DimensionNames, - FillValue, StorageTransformer, UserAttributes, - }, - refs::RefError, - repository::RepositoryError, - session::{get_chunk, is_prefix_match, Session, SessionError}, + refs::{RefError, RefErrorKind}, + repository::{RepositoryError, RepositoryErrorKind}, + session::{get_chunk, is_prefix_match, Session, SessionError, SessionErrorKind}, }; #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] @@ -45,7 +42,7 @@ pub type StoreResult = Result; #[derive(Debug, Clone, PartialEq, Eq, Error)] #[non_exhaustive] pub enum KeyNotFoundError { - #[error("chunk cannot be find for key `{key}`")] + #[error("chunk cannot be find for key `{key}` (path={path}, coords={coords:?})")] ChunkNotFound { key: String, path: Path, coords: ChunkIndices }, #[error("node not found at `{path}`")] NodeNotFound { path: Path }, @@ -55,32 +52,33 @@ pub enum KeyNotFoundError { #[derive(Debug, Error)] #[non_exhaustive] -pub enum StoreError { +pub enum StoreErrorKind { + #[error(transparent)] + SessionError(SessionErrorKind), + #[error(transparent)] + RepositoryError(RepositoryErrorKind), + #[error(transparent)] + RefError(RefErrorKind), + #[error("invalid zarr key format `{key}`")] InvalidKey { key: String }, #[error("this operation is not allowed: {0}")] NotAllowed(String), - #[error("object not found: `{0}`")] + #[error(transparent)] NotFound(#[from] KeyNotFoundError), - #[error("unsuccessful session operation: `{0}`")] - SessionError(#[from] SessionError), - #[error("unsuccessful repository operation: `{0}`")] - RepositoryError(#[from] RepositoryError), #[error("error merging stores: `{0}`")] MergeError(String), - #[error("unsuccessful ref operation: `{0}`")] - RefError(#[from] RefError), #[error("cannot commit when no snapshot is present")] NoSnapshot, #[error("could not create path from prefix")] PathError(#[from] PathError), #[error("all commits must be made on a branch")] NotOnBranch, - #[error("bad metadata: `{0}`")] + #[error("bad metadata")] BadMetadata(#[from] serde_json::Error), - #[error("deserialization error: `{0}`")] + #[error("deserialization error")] DeserializationError(#[from] rmp_serde::decode::Error), - #[error("serialization error: `{0}`")] + #[error("serialization error")] SerializationError(#[from] rmp_serde::encode::Error), #[error("store method `{0}` is not implemented by Icechunk")] Unimplemented(&'static str), @@ -98,10 +96,49 @@ pub enum StoreError { "invalid chunk location, no matching virtual chunk container: `{chunk_location}`" )] InvalidVirtualChunkContainer { chunk_location: String }, - #[error("unknown store error: `{0}`")] + #[error("{0}")] + Other(String), + #[error("unknown store error")] Unknown(Box), } +pub type StoreError = ICError; + +// it would be great to define this impl in error.rs, but it conflicts with the blanket +// `impl From for T` +impl From for StoreError +where + E: Into, +{ + fn from(value: E) -> Self { + Self::new(value.into()) + } +} + +impl From for StoreError { + fn from(value: RepositoryError) -> Self { + Self::with_context(StoreErrorKind::RepositoryError(value.kind), value.context) + } +} + +impl From for StoreError { + fn from(value: RefError) -> Self { + Self::with_context(StoreErrorKind::RefError(value.kind), value.context) + } +} + +impl From for StoreError { + fn from(value: SessionError) -> Self { + Self::with_context(StoreErrorKind::SessionError(value.kind), value.context) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SetVirtualRefsResult { + Done, + FailedRefs(Vec), +} + #[derive(Clone)] pub struct Store { session: Arc>, @@ -121,12 +158,14 @@ impl Store { Self { session, get_partial_values_concurrency } } + #[instrument(skip(bytes))] pub fn from_bytes(bytes: Bytes) -> StoreResult { let session: Session = rmp_serde::from_slice(&bytes).map_err(StoreError::from)?; let conc = session.config().get_partial_values_concurrency(); Ok(Self::from_session_and_config(Arc::new(RwLock::new(session)), conc)) } + #[instrument(skip(self))] pub async fn as_bytes(&self) -> StoreResult { let session = self.session.write().await; let bytes = rmp_serde::to_vec(session.deref()).map_err(StoreError::from)?; @@ -137,19 +176,23 @@ impl Store { Arc::clone(&self.session) } + #[instrument(skip(self))] pub async fn read_only(&self) -> bool { self.session.read().await.read_only() } + #[instrument(skip(self))] pub async fn is_empty(&self, prefix: &str) -> StoreResult { Ok(self.list_dir(prefix).await?.next().await.is_none()) } + #[instrument(skip(self))] pub async fn clear(&self) -> StoreResult<()> { let mut repo = self.session.write().await; Ok(repo.clear().await?) } + #[instrument(skip(self))] pub async fn get(&self, key: &str, byte_range: &ByteRange) -> StoreResult { let repo = self.session.read().await; get_key(key, byte_range, &repo).await @@ -164,6 +207,7 @@ impl Store { /// /// Currently this function is using concurrency but not parallelism. To limit the number of /// concurrent tasks use the Store config value `get_partial_values_concurrency`. + #[instrument(skip(self, key_ranges))] pub async fn get_partial_values( &self, key_ranges: impl IntoIterator, @@ -207,31 +251,35 @@ impl Store { .await; let results = Arc::into_inner(results) - .ok_or(StoreError::PartialValuesPanic)? + .ok_or(StoreErrorKind::PartialValuesPanic)? .into_inner() - .map_err(|_| StoreError::PartialValuesPanic)?; + .map_err(|_| StoreErrorKind::PartialValuesPanic)?; debug_assert!(results.len() == num_keys.into_inner()); let res: Option> = results.into_iter().collect(); - res.ok_or(StoreError::PartialValuesPanic) + res.ok_or(StoreErrorKind::PartialValuesPanic.into()) } + #[instrument(skip(self))] pub async fn exists(&self, key: &str) -> StoreResult { let guard = self.session.read().await; exists(key, guard.deref()).await } + #[instrument(skip(self))] pub fn supports_writes(&self) -> StoreResult { Ok(true) } + #[instrument(skip(self))] pub fn supports_deletes(&self) -> StoreResult { Ok(true) } + #[instrument(skip(self, value))] pub async fn set(&self, key: &str, value: Bytes) -> StoreResult<()> { if self.read_only().await { - return Err(StoreError::ReadOnly); + return Err(StoreErrorKind::ReadOnly.into()); } self.set_with_optional_locking(key, value, None).await @@ -245,23 +293,23 @@ impl Store { ) -> StoreResult<()> { if let Some(session) = locked_session.as_ref() { if session.read_only() { - return Err(StoreError::ReadOnly); + return Err(StoreErrorKind::ReadOnly.into()); } } else if self.read_only().await { - return Err(StoreError::ReadOnly); + return Err(StoreErrorKind::ReadOnly.into()); } match Key::parse(key)? { Key::Metadata { node_path } => { if let Ok(array_meta) = serde_json::from_slice(value.as_ref()) { - self.set_array_meta(node_path, array_meta, locked_session).await + self.set_array_meta(node_path, value, array_meta, locked_session) + .await } else { - match serde_json::from_slice(value.as_ref()) { - Ok(group_meta) => { - self.set_group_meta(node_path, group_meta, locked_session) - .await + match serde_json::from_slice::(value.as_ref()) { + Ok(_) => { + self.set_group_meta(node_path, value, locked_session).await } - Err(err) => Err(StoreError::BadMetadata(err)), + Err(err) => Err(StoreErrorKind::BadMetadata(err).into()), } } } @@ -287,22 +335,25 @@ impl Store { } Ok(()) } - Key::ZarrV2(_) => Err(StoreError::Unimplemented( + Key::ZarrV2(_) => Err(StoreErrorKind::Unimplemented( "Icechunk cannot set Zarr V2 metadata keys", - )), + ) + .into()), } } - pub async fn set_if_not_exists(&self, key: &str, value: Bytes) -> StoreResult<()> { + #[instrument(skip(self, bytes))] + pub async fn set_if_not_exists(&self, key: &str, bytes: Bytes) -> StoreResult<()> { let mut guard = self.session.write().await; if exists(key, guard.deref()).await? { Ok(()) } else { - self.set_with_optional_locking(key, value, Some(guard.deref_mut())).await + self.set_with_optional_locking(key, bytes, Some(guard.deref_mut())).await } } // alternate API would take array path, and a mapping from string coord to ChunkPayload + #[instrument(skip(self))] pub async fn set_virtual_ref( &self, key: &str, @@ -310,7 +361,7 @@ impl Store { validate_container: bool, ) -> StoreResult<()> { if self.read_only().await { - return Err(StoreError::ReadOnly); + return Err(StoreErrorKind::ReadOnly.into()); } match Key::parse(key)? { @@ -319,9 +370,10 @@ impl Store { if validate_container && session.matching_container(&reference.location).is_none() { - return Err(StoreError::InvalidVirtualChunkContainer { + return Err(StoreErrorKind::InvalidVirtualChunkContainer { chunk_location: reference.location.0, - }); + } + .into()); } session .set_chunk_ref( @@ -332,27 +384,67 @@ impl Store { .await?; Ok(()) } - Key::Metadata { .. } | Key::ZarrV2(_) => Err(StoreError::NotAllowed( + Key::Metadata { .. } | Key::ZarrV2(_) => Err(StoreErrorKind::NotAllowed( format!("use .set to modify metadata for key {}", key), - )), + ) + .into()), } } + #[instrument(skip(self, references))] + pub async fn set_virtual_refs( + &self, + array_path: &Path, + validate_container: bool, + references: I, + ) -> StoreResult + where + I: IntoIterator + std::fmt::Debug, + { + if self.read_only().await { + return Err(StoreErrorKind::ReadOnly.into()); + } + + let mut session = self.session.write().await; + let mut failed = Vec::new(); + for (index, reference) in references.into_iter() { + if validate_container + && session.matching_container(&reference.location).is_none() + { + failed.push(index); + } else { + session + .set_chunk_ref( + array_path.clone(), + index, + Some(ChunkPayload::Virtual(reference)), + ) + .await?; + } + } + if failed.is_empty() { + Ok(SetVirtualRefsResult::Done) + } else { + Ok(SetVirtualRefsResult::FailedRefs(failed)) + } + } + + #[instrument(skip(self))] pub async fn delete_dir(&self, prefix: &str) -> StoreResult<()> { if self.read_only().await { - return Err(StoreError::ReadOnly); + return Err(StoreErrorKind::ReadOnly.into()); } let prefix = prefix.trim_start_matches("/").trim_end_matches("/"); // TODO: Handling preceding "/" is ugly! let path = format!("/{}", prefix) .try_into() - .map_err(|_| StoreError::BadKeyPrefix(prefix.to_owned()))?; + .map_err(|_| StoreErrorKind::BadKeyPrefix(prefix.to_owned()))?; let mut guard = self.session.write().await; let node = guard.get_node(&path).await; match node { Ok(node) => Ok(guard.deref_mut().delete_node(node).await?), - Err(SessionError::NodeNotFound { .. }) => { + Err(SessionError { kind: SessionErrorKind::NodeNotFound { .. }, .. }) => { // other cases are // 1. delete("/path/to/array/c") // 2. delete("/path/to/array/c/0/0") @@ -363,7 +455,7 @@ impl Store { let node = guard.get_closest_ancestor_node(&path).await; if let Ok(NodeSnapshot { path: node_path, - node_data: NodeData::Array(..), + node_data: NodeData::Array { .. }, .. }) = node { @@ -399,6 +491,7 @@ impl Store { } } + #[instrument(skip(self))] pub async fn delete(&self, key: &str) -> StoreResult<()> { // we need to hold the lock while we do the node search and the write // to avoid race conditions with other writers @@ -409,10 +502,14 @@ impl Store { let node = session.get_node(&node_path).await; // When there is no node at the given key, we don't consider it an error, instead we just do nothing - if let Err(SessionError::NodeNotFound { path: _, message: _ }) = node { + if let Err(SessionError { + kind: SessionErrorKind::NodeNotFound { path: _, message: _ }, + .. + }) = node + { return Ok(()); }; - Ok(session.delete_node(node.map_err(StoreError::SessionError)?).await?) + Ok(session.delete_node(node.map_err(StoreError::from)?).await?) } Key::Chunk { node_path, coords } => { Ok(session.delete_chunks(&node_path, vec![coords].into_iter()).await?) @@ -425,15 +522,16 @@ impl Store { Ok(false) } + #[instrument(skip(self, _key_start_values))] pub async fn set_partial_values( &self, _key_start_values: impl IntoIterator, ) -> StoreResult<()> { if self.read_only().await { - return Err(StoreError::ReadOnly); + return Err(StoreErrorKind::ReadOnly.into()); } - Err(StoreError::Unimplemented("set_partial_values")) + Err(StoreErrorKind::Unimplemented("set_partial_values").into()) } pub fn supports_listing(&self) -> StoreResult { @@ -446,6 +544,7 @@ impl Store { self.list_prefix("/").await } + #[instrument(skip(self))] pub async fn list_prefix( &self, prefix: &str, @@ -470,6 +569,7 @@ impl Store { Ok(res) } + #[instrument(skip(self))] pub async fn list_dir_items( &self, prefix: &str, @@ -481,7 +581,7 @@ impl Store { let path = Path::try_from(absolute_prefix)?; let session = Arc::clone(&self.session).read_owned().await; let results = match session.get_node(&path).await { - Ok(NodeSnapshot { node_data: NodeData::Array(..), .. }) => { + Ok(NodeSnapshot { node_data: NodeData::Array { .. }, .. }) => { // if this is an array we know what to yield vec![ ListDirItem::Key("zarr.json".to_string()), @@ -492,7 +592,7 @@ impl Store { ListDirItem::Prefix("c".to_string()), ] } - Ok(NodeSnapshot { node_data: NodeData::Group, .. }) => { + Ok(NodeSnapshot { node_data: NodeData::Group { .. }, .. }) => { // if the prefix is the path to a group we need to discover any nodes with the prefix as node path // listing chunks is unnecessary self.list_metadata_prefix(prefix, true) @@ -565,55 +665,72 @@ impl Store { Ok(futures::stream::iter(results.into_iter().map(Ok))) } + #[instrument(skip(self))] pub async fn getsize(&self, key: &str) -> StoreResult { - let repo = self.session.read().await; - get_key_size(key, &repo).await + let session = self.session.read().await; + get_key_size(key, &session).await + } + + #[instrument(skip(self))] + pub async fn getsize_prefix(&self, prefix: &str) -> StoreResult { + let session_guard = Arc::clone(&self.session).read_owned().await; + let session = session_guard.deref(); + + let meta = self.list_metadata_prefix(prefix, false).await?; + let chunks = self.list_chunks_prefix(prefix).await?; + meta.chain(chunks) + .try_fold(0, move |accum, key| async move { + get_key_size(key.as_str(), session).await.map(|n| n + accum) + }) + .await } async fn set_array_meta( &self, path: Path, + user_data: Bytes, array_meta: ArrayMetadata, - locked_repo: Option<&mut Session>, + locked_session: Option<&mut Session>, ) -> Result<(), StoreError> { - match locked_repo { - Some(repo) => set_array_meta(path, array_meta, repo).await, - None => self.set_array_meta_locking(path, array_meta).await, + match locked_session { + Some(session) => set_array_meta(path, user_data, array_meta, session).await, + None => self.set_array_meta_locking(path, user_data, array_meta).await, } } async fn set_array_meta_locking( &self, path: Path, + user_data: Bytes, array_meta: ArrayMetadata, ) -> Result<(), StoreError> { // we need to hold the lock while we search the array and do the update to avoid race // conditions with other writers (notice we don't take &mut self) let mut guard = self.session.write().await; - set_array_meta(path, array_meta, guard.deref_mut()).await + set_array_meta(path, user_data, array_meta, guard.deref_mut()).await } async fn set_group_meta( &self, path: Path, - group_meta: GroupMetadata, + user_data: Bytes, locked_repo: Option<&mut Session>, ) -> Result<(), StoreError> { match locked_repo { - Some(repo) => set_group_meta(path, group_meta, repo).await, - None => self.set_group_meta_locking(path, group_meta).await, + Some(repo) => set_group_meta(path, user_data, repo).await, + None => self.set_group_meta_locking(path, user_data).await, } } async fn set_group_meta_locking( &self, path: Path, - group_meta: GroupMetadata, + user_data: Bytes, ) -> Result<(), StoreError> { // we need to hold the lock while we search the array and do the update to avoid race // conditions with other writers (notice we don't take &mut self) let mut guard = self.session.write().await; - set_group_meta(path, group_meta, guard.deref_mut()).await + set_group_meta(path, user_data, guard.deref_mut()).await } async fn list_metadata_prefix<'a, 'b: 'a>( @@ -626,7 +743,7 @@ impl Store { let repository = Arc::clone(&self.session).read_owned().await; for node in repository.list_nodes().await? { // TODO: handle non-utf8? - let meta_key = Key::Metadata { node_path: node.path }.to_string(); + let meta_key = Key::Metadata { node_path: node?.path }.to_string(); if is_prefix_match(&meta_key, prefix) { if strip_prefix { yield meta_key.trim_start_matches(prefix).trim_start_matches("/").to_string(); @@ -648,7 +765,7 @@ impl Store { let repository = Arc::clone(&self.session).read_owned().await; // TODO: this is inefficient because it filters based on the prefix, instead of only // generating items that could potentially match - for await maybe_path_chunk in repository.all_chunks().await.map_err(StoreError::SessionError)? { + for await maybe_path_chunk in repository.all_chunks().await.map_err(StoreError::from)? { // FIXME: utf8 handling match maybe_path_chunk { Ok((path, chunk)) => { @@ -667,60 +784,45 @@ impl Store { async fn set_array_meta( path: Path, + user_data: Bytes, array_meta: ArrayMetadata, - repo: &mut Session, + session: &mut Session, ) -> StoreResult<()> { - // TODO: Consider deleting all this logic? - // We try hard to not overwrite existing metadata here. - // I don't think this is actually useful because Zarr's - // Array/Group API will require that the user set `overwrite=True` - // which will delete any existing array metadata. This path is only - // applicable when using the explicit `store.set` interface. - if let Ok(node) = repo.get_array(&path).await { - // Check if the user attributes are different, if they are update them - let existing_attrs = match node.user_attributes { - None => None, - Some(UserAttributesSnapshot::Inline(atts)) => Some(atts), - // FIXME: implement - Some(UserAttributesSnapshot::Ref(_)) => None, - }; - - if existing_attrs != array_meta.attributes { - repo.set_user_attributes(path.clone(), array_meta.attributes).await?; - } - - // Check if the zarr metadata is different, if it is update it - if let NodeData::Array(existing_array_metadata, _) = node.node_data { - if existing_array_metadata != array_meta.zarr_metadata { - repo.update_array(path, array_meta.zarr_metadata).await?; + let shape = array_meta + .shape() + .ok_or(StoreErrorKind::Other("Invalid chunk grid metadata".to_string()))?; + if let Ok(node) = session.get_array(&path).await { + if let NodeData::Array { .. } = node.node_data { + if node.user_data != user_data { + session + .update_array(&path, shape, array_meta.dimension_names(), user_data) + .await?; } - } else { - // This should be unreachable, but just in case... - repo.update_array(path, array_meta.zarr_metadata).await?; } - + // FIXME: don't ignore error Ok(()) } else { - repo.add_array(path.clone(), array_meta.zarr_metadata).await?; - repo.set_user_attributes(path, array_meta.attributes).await?; + session + .add_array(path.clone(), shape, array_meta.dimension_names(), user_data) + .await?; Ok(()) } } async fn set_group_meta( path: Path, - group_meta: GroupMetadata, - repo: &mut Session, + user_data: Bytes, + session: &mut Session, ) -> StoreResult<()> { - // we need to hold the lock while we search the group and do the update to avoid race - // conditions with other writers (notice we don't take &mut self) - // - if repo.get_group(&path).await.is_ok() { - repo.set_user_attributes(path, group_meta.attributes).await?; + if let Ok(node) = session.get_group(&path).await { + if let NodeData::Group = node.node_data { + if node.user_data != user_data { + session.update_group(&path, user_data).await?; + } + } Ok(()) } else { - repo.add_group(path.clone()).await?; - repo.set_user_attributes(path, group_meta.attributes).await?; + session.add_group(path.clone(), user_data).await?; Ok(()) } } @@ -729,28 +831,13 @@ async fn get_metadata( _key: &str, path: &Path, range: &ByteRange, - repo: &Session, + session: &Session, ) -> StoreResult { - let node = repo.get_node(path).await.map_err(|_| { - StoreError::NotFound(KeyNotFoundError::NodeNotFound { path: path.clone() }) + // FIXME: don't skip errors + let node = session.get_node(path).await.map_err(|_| { + StoreErrorKind::NotFound(KeyNotFoundError::NodeNotFound { path: path.clone() }) })?; - let user_attributes = match node.user_attributes { - None => None, - Some(UserAttributesSnapshot::Inline(atts)) => Some(atts), - // FIXME: implement - #[allow(clippy::unimplemented)] - Some(UserAttributesSnapshot::Ref(_)) => unimplemented!(), - }; - let full_metadata = match node.node_data { - NodeData::Group => { - Ok::(GroupMetadata::new(user_attributes).to_bytes()) - } - NodeData::Array(zarr_metadata, _) => { - Ok(ArrayMetadata::new(user_attributes, zarr_metadata).to_bytes()) - } - }?; - - Ok(range.slice(full_metadata)) + Ok(range.slice(node.user_data)) } async fn get_chunk_bytes( @@ -758,21 +845,28 @@ async fn get_chunk_bytes( path: Path, coords: ChunkIndices, byte_range: &ByteRange, - repo: &Session, + session: &Session, ) -> StoreResult { - let reader = repo.get_chunk_reader(&path, &coords, byte_range).await?; + let reader = session.get_chunk_reader(&path, &coords, byte_range).await?; // then we can fetch the bytes without holding the lock let chunk = get_chunk(reader).await?; - chunk.ok_or(StoreError::NotFound(KeyNotFoundError::ChunkNotFound { - key: key.to_string(), - path, - coords, - })) + chunk.ok_or( + StoreErrorKind::NotFound(KeyNotFoundError::ChunkNotFound { + key: key.to_string(), + path, + coords, + }) + .into(), + ) } -async fn get_metadata_size(key: &str, path: &Path, repo: &Session) -> StoreResult { - let bytes = get_metadata(key, path, &ByteRange::From(0), repo).await?; +async fn get_metadata_size( + key: &str, + path: &Path, + session: &Session, +) -> StoreResult { + let bytes = get_metadata(key, path, &ByteRange::From(0), session).await?; Ok(bytes.len() as u64) } @@ -780,9 +874,9 @@ async fn get_chunk_size( _key: &str, path: &Path, coords: &ChunkIndices, - repo: &Session, + session: &Session, ) -> StoreResult { - let chunk_ref = repo.get_chunk_ref(path, coords).await?; + let chunk_ref = session.get_chunk_ref(path, coords).await?; let size = chunk_ref .map(|payload| match payload { ChunkPayload::Inline(bytes) => bytes.len() as u64, @@ -796,46 +890,61 @@ async fn get_chunk_size( async fn get_key( key: &str, byte_range: &ByteRange, - repo: &Session, + session: &Session, ) -> StoreResult { let bytes = match Key::parse(key)? { Key::Metadata { node_path } => { - get_metadata(key, &node_path, byte_range, repo).await + get_metadata(key, &node_path, byte_range, session).await } Key::Chunk { node_path, coords } => { - get_chunk_bytes(key, node_path, coords, byte_range, repo).await + get_chunk_bytes(key, node_path, coords, byte_range, session).await } Key::ZarrV2(key) => { - Err(StoreError::NotFound(KeyNotFoundError::ZarrV2KeyNotFound { key })) + Err(StoreErrorKind::NotFound(KeyNotFoundError::ZarrV2KeyNotFound { key }) + .into()) } }?; Ok(bytes) } -async fn get_key_size(key: &str, repo: &Session) -> StoreResult { +async fn get_key_size(key: &str, session: &Session) -> StoreResult { let bytes = match Key::parse(key)? { - Key::Metadata { node_path } => get_metadata_size(key, &node_path, repo).await, + Key::Metadata { node_path } => get_metadata_size(key, &node_path, session).await, Key::Chunk { node_path, coords } => { - get_chunk_size(key, &node_path, &coords, repo).await + get_chunk_size(key, &node_path, &coords, session).await } Key::ZarrV2(key) => { - Err(StoreError::NotFound(KeyNotFoundError::ZarrV2KeyNotFound { key })) + Err(StoreErrorKind::NotFound(KeyNotFoundError::ZarrV2KeyNotFound { key }) + .into()) } }?; Ok(bytes) } -async fn exists(key: &str, repo: &Session) -> StoreResult { - match get_key(key, &ByteRange::ALL, repo).await { - Ok(_) => Ok(true), - Err(StoreError::NotFound(_)) => Ok(false), - Err(StoreError::SessionError(SessionError::NodeNotFound { - path: _, - message: _, - })) => Ok(false), - Err(other_error) => Err(other_error), +async fn exists(key: &str, session: &Session) -> StoreResult { + match Key::parse(key)? { + Key::Metadata { node_path } => match session.get_node(&node_path).await { + Ok(_) => Ok(true), + Err(SessionError { kind: SessionErrorKind::NodeNotFound { .. }, .. }) => { + Ok(false) + } + Err(err) => Err(err.into()), + }, + Key::Chunk { node_path, coords } => { + match session.get_chunk_ref(&node_path, &coords).await { + Ok(r) => Ok(r.is_some()), + Err(SessionError { + kind: SessionErrorKind::NodeNotFound { .. }, .. + }) => Ok(false), + Err(err) => Err(err.into()), + } + } + Key::ZarrV2(key) => { + Err(StoreErrorKind::NotFound(KeyNotFoundError::ZarrV2KeyNotFound { key }) + .into()) + } } } @@ -876,17 +985,17 @@ impl Key { if coords.is_empty() { Ok(Key::Chunk { node_path: format!("/{path}").try_into().map_err(|_| { - StoreError::InvalidKey { key: key.to_string() } + StoreErrorKind::InvalidKey { key: key.to_string() } })?, coords: ChunkIndices(vec![]), }) } else { - let absolute = format!("/{path}") - .try_into() - .map_err(|_| StoreError::InvalidKey { key: key.to_string() })?; + let absolute = format!("/{path}").try_into().map_err(|_| { + StoreErrorKind::InvalidKey { key: key.to_string() } + })?; coords .strip_prefix('/') - .ok_or(StoreError::InvalidKey { key: key.to_string() })? + .ok_or(StoreErrorKind::InvalidKey { key: key.to_string() })? .split('/') .map(|s| s.parse::()) .collect::, _>>() @@ -894,10 +1003,12 @@ impl Key { node_path: absolute, coords: ChunkIndices(coords), }) - .map_err(|_| StoreError::InvalidKey { key: key.to_string() }) + .map_err(|_| { + StoreErrorKind::InvalidKey { key: key.to_string() }.into() + }) } } else { - Err(StoreError::InvalidKey { key: key.to_string() }) + Err(StoreErrorKind::InvalidKey { key: key.to_string() }.into()) } } @@ -908,7 +1019,7 @@ impl Key { Ok(Key::Metadata { node_path: format!("/{path}") .try_into() - .map_err(|_| StoreError::InvalidKey { key: key.to_string() })?, + .map_err(|_| StoreErrorKind::InvalidKey { key: key.to_string() })?, }) } else { parse_chunk(key) @@ -942,152 +1053,39 @@ impl Display for Key { #[serde_as] #[derive(Debug, Serialize, Deserialize, PartialEq)] struct ArrayMetadata { - zarr_format: u8, + pub shape: Vec, + #[serde(deserialize_with = "validate_array_node_type")] node_type: String, - #[serde(skip_serializing_if = "Option::is_none")] - attributes: Option, - #[serde(flatten)] - #[serde_as(as = "TryFromInto")] - zarr_metadata: ZarrArrayMetadata, -} - -#[serde_as] -#[derive(Serialize, Deserialize)] -pub struct ZarrArrayMetadataSerialzer { - pub shape: ArrayShape, - pub data_type: DataType, #[serde_as(as = "TryFromInto")] - #[serde(rename = "chunk_grid")] - pub chunk_shape: ChunkShape, + pub chunk_grid: Vec, - #[serde_as(as = "TryFromInto")] - pub chunk_key_encoding: ChunkKeyEncoding, - pub fill_value: serde_json::Value, - pub codecs: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub storage_transformers: Option>, - // each dimension name can be null in Zarr - pub dimension_names: Option, + pub dimension_names: Option>>, } -impl TryFrom for ZarrArrayMetadata { - type Error = IcechunkFormatError; - - fn try_from(value: ZarrArrayMetadataSerialzer) -> Result { - let ZarrArrayMetadataSerialzer { - shape, - data_type, - chunk_shape, - chunk_key_encoding, - fill_value, - codecs, - storage_transformers, - dimension_names, - } = value; - { - let fill_value = FillValue::from_data_type_and_json(&data_type, &fill_value)?; - Ok(ZarrArrayMetadata { - fill_value, - shape, - data_type, - chunk_shape, - chunk_key_encoding, - codecs, - storage_transformers, - dimension_names, - }) - } +impl ArrayMetadata { + fn dimension_names(&self) -> Option> { + self.dimension_names + .as_ref() + .map(|ds| ds.iter().map(|d| d.as_ref().map(|s| s.as_str()).into()).collect()) } -} -impl From for ZarrArrayMetadataSerialzer { - fn from(value: ZarrArrayMetadata) -> Self { - let ZarrArrayMetadata { - shape, - data_type, - chunk_shape, - chunk_key_encoding, - fill_value, - codecs, - storage_transformers, - dimension_names, - } = value; - { - fn fill_value_to_json(f: FillValue) -> serde_json::Value { - match f { - FillValue::Bool(b) => b.into(), - FillValue::Int8(n) => n.into(), - FillValue::Int16(n) => n.into(), - FillValue::Int32(n) => n.into(), - FillValue::Int64(n) => n.into(), - FillValue::UInt8(n) => n.into(), - FillValue::UInt16(n) => n.into(), - FillValue::UInt32(n) => n.into(), - FillValue::UInt64(n) => n.into(), - FillValue::Float16(f) => { - if f.is_nan() { - FillValue::NAN_STR.into() - } else if f == f32::INFINITY { - FillValue::INF_STR.into() - } else if f == f32::NEG_INFINITY { - FillValue::NEG_INF_STR.into() - } else { - f.into() - } - } - FillValue::Float32(f) => { - if f.is_nan() { - FillValue::NAN_STR.into() - } else if f == f32::INFINITY { - FillValue::INF_STR.into() - } else if f == f32::NEG_INFINITY { - FillValue::NEG_INF_STR.into() - } else { - f.into() - } - } - FillValue::Float64(f) => { - if f.is_nan() { - FillValue::NAN_STR.into() - } else if f == f64::INFINITY { - FillValue::INF_STR.into() - } else if f == f64::NEG_INFINITY { - FillValue::NEG_INF_STR.into() - } else { - f.into() - } - } - FillValue::Complex64(r, i) => ([r, i].as_ref()).into(), - FillValue::Complex128(r, i) => ([r, i].as_ref()).into(), - FillValue::String(s) => s.into(), - FillValue::Bytes(b) => b.into(), - } - } - - let fill_value = fill_value_to_json(fill_value); - ZarrArrayMetadataSerialzer { - shape, - data_type, - chunk_shape, - chunk_key_encoding, - codecs, - storage_transformers, - dimension_names, - fill_value, - } + fn shape(&self) -> Option { + if self.shape.len() != self.chunk_grid.len() { + None + } else { + ArrayShape::new( + self.shape.iter().zip(self.chunk_grid.iter()).map(|(a, b)| (*a, *b)), + ) } } } #[derive(Debug, Serialize, Deserialize)] struct GroupMetadata { - zarr_format: u8, #[serde(deserialize_with = "validate_group_node_type")] node_type: String, - #[serde(skip_serializing_if = "Option::is_none")] - attributes: Option, } fn validate_group_node_type<'de, D>(d: D) -> Result @@ -1122,49 +1120,18 @@ where Ok(value) } -impl ArrayMetadata { - fn new(attributes: Option, zarr_metadata: ZarrArrayMetadata) -> Self { - Self { zarr_format: 3, node_type: "array".to_string(), attributes, zarr_metadata } - } - - fn to_bytes(&self) -> Bytes { - Bytes::from_iter( - // We can unpack because it comes from controlled datastructures that can be serialized - #[allow(clippy::expect_used)] - serde_json::to_vec(self).expect("bug in ArrayMetadata serialization"), - ) - } -} - -impl GroupMetadata { - fn new(attributes: Option) -> Self { - Self { zarr_format: 3, node_type: "group".to_string(), attributes } - } - - fn to_bytes(&self) -> Bytes { - Bytes::from_iter( - // We can unpack because it comes from controlled datastructures that can be serialized - #[allow(clippy::expect_used)] - serde_json::to_vec(self).expect("bug in GroupMetadata serialization"), - ) - } -} - #[derive(Serialize, Deserialize)] struct NameConfigSerializer { name: String, configuration: serde_json::Value, } -impl From for NameConfigSerializer { - fn from(value: ChunkShape) -> Self { +impl From> for NameConfigSerializer { + fn from(value: Vec) -> Self { let arr = serde_json::Value::Array( value - .0 .iter() - .map(|v| { - serde_json::Value::Number(serde_json::value::Number::from(v.get())) - }) + .map(|v| serde_json::Value::Number(serde_json::value::Number::from(*v))) .collect(), ); let kvs = serde_json::value::Map::from_iter(iter::once(( @@ -1178,7 +1145,7 @@ impl From for NameConfigSerializer { } } -impl TryFrom for ChunkShape { +impl TryFrom for Vec { type Error = &'static str; fn try_from(value: NameConfigSerializer) -> Result { @@ -1193,52 +1160,16 @@ impl TryFrom for ChunkShape { .ok_or("cannot parse ChunkShape")?; let shape = values .iter() - .map(|v| v.as_u64().and_then(|u64| NonZeroU64::try_from(u64).ok())) + .map(|v| v.as_u64()) .collect::>>() .ok_or("cannot parse ChunkShape")?; - Ok(ChunkShape(shape)) + Ok(shape) } _ => Err("cannot parse ChunkShape"), } } } -impl From for NameConfigSerializer { - fn from(_value: ChunkKeyEncoding) -> Self { - let kvs = serde_json::value::Map::from_iter(iter::once(( - "separator".to_string(), - serde_json::Value::String("/".to_string()), - ))); - Self { - name: "default".to_string(), - configuration: serde_json::Value::Object(kvs), - } - } -} - -impl TryFrom for ChunkKeyEncoding { - type Error = &'static str; - - fn try_from(value: NameConfigSerializer) -> Result { - //FIXME: we are hardcoding / as the separator - match value { - NameConfigSerializer { - name, - configuration: serde_json::Value::Object(kvs), - } if name == "default" => { - if let Some("/") = - kvs.get("separator").ok_or("cannot parse ChunkKeyEncoding")?.as_str() - { - Ok(ChunkKeyEncoding::Slash) - } else { - Err("cannot parse ChunkKeyEncoding") - } - } - _ => Err("cannot parse ChunkKeyEncoding"), - } - } -} - #[cfg(test)] #[allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)] mod tests { @@ -1274,7 +1205,8 @@ mod tests { } async fn create_memory_store_repository() -> Repository { - let storage = new_in_memory_storage().expect("failed to create in-memory store"); + let storage = + new_in_memory_storage().await.expect("failed to create in-memory store"); Repository::create(None, storage, HashMap::new()).await.unwrap() } @@ -1440,97 +1372,28 @@ mod tests { .is_err()); assert!(serde_json::from_str::( - r#"{"zarr_format":3,"node_type":"array","shape":[2,2,2],"data_type":"int32","chunk_grid":{"name":"regular","configuration":{"chunk_shape":[1,1,1]}},"chunk_key_encoding":{"name":"default","configuration":{"separator":"/"}},"fill_value":0,"codecs":[{"name":"mycodec","configuration":{"foo":42}}],"storage_transformers":[{"name":"mytransformer","configuration":{"bar":43}}],"dimension_names":["x","y","t"]}"# - ) - .is_ok()); + r#"{"zarr_format":3,"node_type":"array","shape":[2,2,2],"data_type":"int32","chunk_grid":{"name":"regular","configuration":{"chunk_shape":[1,1,1]}},"chunk_key_encoding":{"name":"default","configuration":{"separator":"/"}},"fill_value":0,"codecs":[{"name":"mycodec","configuration":{"foo":42}}],"storage_transformers":[{"name":"mytransformer","configuration":{"bar":43}}],"dimension_names":["x","y","t"]}"# + ) + .is_ok()); assert!(serde_json::from_str::( - r#"{"zarr_format":3,"node_type":"group","shape":[2,2,2],"data_type":"int32","chunk_grid":{"name":"regular","configuration":{"chunk_shape":[1,1,1]}},"chunk_key_encoding":{"name":"default","configuration":{"separator":"/"}},"fill_value":0,"codecs":[{"name":"mycodec","configuration":{"foo":42}}],"storage_transformers":[{"name":"mytransformer","configuration":{"bar":43}}],"dimension_names":["x","y","t"]}"# - ) - .is_err()); + r#"{"zarr_format":3,"node_type":"group","shape":[2,2,2],"data_type":"int32","chunk_grid":{"name":"regular","configuration":{"chunk_shape":[1,1,1]}},"chunk_key_encoding":{"name":"default","configuration":{"separator":"/"}},"fill_value":0,"codecs":[{"name":"mycodec","configuration":{"foo":42}}],"storage_transformers":[{"name":"mytransformer","configuration":{"bar":43}}],"dimension_names":["x","y","t"]}"# + ) + .is_err()); // deserialize with nan - assert!(matches!( - serde_json::from_str::( - r#"{"zarr_format":3,"node_type":"array","shape":[2,2,2],"data_type":"float16","chunk_grid":{"name":"regular","configuration":{"chunk_shape":[1,1,1]}},"chunk_key_encoding":{"name":"default","configuration":{"separator":"/"}},"fill_value":"NaN","codecs":[{"name":"mycodec","configuration":{"foo":42}}],"storage_transformers":[{"name":"mytransformer","configuration":{"bar":43}}],"dimension_names":["x","y","t"]}"# - ).unwrap().zarr_metadata.fill_value, - FillValue::Float16(n) if n.is_nan() - )); - assert!(matches!( - serde_json::from_str::( - r#"{"zarr_format":3,"node_type":"array","shape":[2,2,2],"data_type":"float32","chunk_grid":{"name":"regular","configuration":{"chunk_shape":[1,1,1]}},"chunk_key_encoding":{"name":"default","configuration":{"separator":"/"}},"fill_value":"NaN","codecs":[{"name":"mycodec","configuration":{"foo":42}}],"storage_transformers":[{"name":"mytransformer","configuration":{"bar":43}}],"dimension_names":["x","y","t"]}"# - ).unwrap().zarr_metadata.fill_value, - FillValue::Float32(n) if n.is_nan() - )); - assert!(matches!( - serde_json::from_str::( - r#"{"zarr_format":3,"node_type":"array","shape":[2,2,2],"data_type":"float64","chunk_grid":{"name":"regular","configuration":{"chunk_shape":[1,1,1]}},"chunk_key_encoding":{"name":"default","configuration":{"separator":"/"}},"fill_value":"NaN","codecs":[{"name":"mycodec","configuration":{"foo":42}}],"storage_transformers":[{"name":"mytransformer","configuration":{"bar":43}}],"dimension_names":["x","y","t"]}"# - ).unwrap().zarr_metadata.fill_value, - FillValue::Float64(n) if n.is_nan() - )); - - // deserialize with infinity assert_eq!( - serde_json::from_str::( - r#"{"zarr_format":3,"node_type":"array","shape":[2,2,2],"data_type":"float16","chunk_grid":{"name":"regular","configuration":{"chunk_shape":[1,1,1]}},"chunk_key_encoding":{"name":"default","configuration":{"separator":"/"}},"fill_value":"Infinity","codecs":[{"name":"mycodec","configuration":{"foo":42}}],"storage_transformers":[{"name":"mytransformer","configuration":{"bar":43}}],"dimension_names":["x","y","t"]}"# - ).unwrap().zarr_metadata.fill_value, - FillValue::Float16(f32::INFINITY) - ); - assert_eq!( - serde_json::from_str::( - r#"{"zarr_format":3,"node_type":"array","shape":[2,2,2],"data_type":"float32","chunk_grid":{"name":"regular","configuration":{"chunk_shape":[1,1,1]}},"chunk_key_encoding":{"name":"default","configuration":{"separator":"/"}},"fill_value":"Infinity","codecs":[{"name":"mycodec","configuration":{"foo":42}}],"storage_transformers":[{"name":"mytransformer","configuration":{"bar":43}}],"dimension_names":["x","y","t"]}"# - ).unwrap().zarr_metadata.fill_value, - FillValue::Float32(f32::INFINITY) - ); - assert_eq!( - serde_json::from_str::( - r#"{"zarr_format":3,"node_type":"array","shape":[2,2,2],"data_type":"float64","chunk_grid":{"name":"regular","configuration":{"chunk_shape":[1,1,1]}},"chunk_key_encoding":{"name":"default","configuration":{"separator":"/"}},"fill_value":"Infinity","codecs":[{"name":"mycodec","configuration":{"foo":42}}],"storage_transformers":[{"name":"mytransformer","configuration":{"bar":43}}],"dimension_names":["x","y","t"]}"# - ).unwrap().zarr_metadata.fill_value, - FillValue::Float64(f64::INFINITY) - ); - - // deserialize with -infinity - assert_eq!( - serde_json::from_str::( - r#"{"zarr_format":3,"node_type":"array","shape":[2,2,2],"data_type":"float16","chunk_grid":{"name":"regular","configuration":{"chunk_shape":[1,1,1]}},"chunk_key_encoding":{"name":"default","configuration":{"separator":"/"}},"fill_value":"-Infinity","codecs":[{"name":"mycodec","configuration":{"foo":42}}],"storage_transformers":[{"name":"mytransformer","configuration":{"bar":43}}],"dimension_names":["x","y","t"]}"# - ).unwrap().zarr_metadata.fill_value, - FillValue::Float16(f32::NEG_INFINITY) - ); - assert_eq!( - serde_json::from_str::( - r#"{"zarr_format":3,"node_type":"array","shape":[2,2,2],"data_type":"float32","chunk_grid":{"name":"regular","configuration":{"chunk_shape":[1,1,1]}},"chunk_key_encoding":{"name":"default","configuration":{"separator":"/"}},"fill_value":"-Infinity","codecs":[{"name":"mycodec","configuration":{"foo":42}}],"storage_transformers":[{"name":"mytransformer","configuration":{"bar":43}}],"dimension_names":["x","y","t"]}"# - ).unwrap().zarr_metadata.fill_value, - FillValue::Float32(f32::NEG_INFINITY) - ); - assert_eq!( - serde_json::from_str::( - r#"{"zarr_format":3,"node_type":"array","shape":[2,2,2],"data_type":"float64","chunk_grid":{"name":"regular","configuration":{"chunk_shape":[1,1,1]}},"chunk_key_encoding":{"name":"default","configuration":{"separator":"/"}},"fill_value":"-Infinity","codecs":[{"name":"mycodec","configuration":{"foo":42}}],"storage_transformers":[{"name":"mytransformer","configuration":{"bar":43}}],"dimension_names":["x","y","t"]}"# - ).unwrap().zarr_metadata.fill_value, - FillValue::Float64(f64::NEG_INFINITY) - ); - - // infinity roundtrip - let zarr_meta = ZarrArrayMetadata { - shape: vec![1, 1, 2], - data_type: DataType::Float16, - chunk_shape: ChunkShape(vec![NonZeroU64::new(2).unwrap()]), - chunk_key_encoding: ChunkKeyEncoding::Slash, - fill_value: FillValue::Float16(f32::NEG_INFINITY), - codecs: vec![Codec { name: "mycodec".to_string(), configuration: None }], - storage_transformers: Some(vec![StorageTransformer { - name: "mytransformer".to_string(), - configuration: None, - }]), - dimension_names: Some(vec![Some("t".to_string())]), - }; - let zarr_meta = ArrayMetadata::new(None, zarr_meta); + serde_json::from_str::( + r#"{"zarr_format":3,"node_type":"array","shape":[2,2,2],"data_type":"float16","chunk_grid":{"name":"regular","configuration":{"chunk_shape":[1,1,1]}},"chunk_key_encoding":{"name":"default","configuration":{"separator":"/"}},"fill_value":"NaN","codecs":[{"name":"mycodec","configuration":{"foo":42}}],"storage_transformers":[{"name":"mytransformer","configuration":{"bar":43}}],"dimension_names":["x","y","t"]}"# + ).unwrap().dimension_names(), + Some(vec!["x".into(), "y".into(), "t".into()]) + ); assert_eq!( - serde_json::from_str::( - serde_json::to_string(&zarr_meta).unwrap().as_str() - ) - .unwrap(), - zarr_meta, - ) + serde_json::from_str::( + r#"{"zarr_format":3,"node_type":"array","shape":[2,3,4],"data_type":"float16","chunk_grid":{"name":"regular","configuration":{"chunk_shape":[1,2,3]}},"chunk_key_encoding":{"name":"default","configuration":{"separator":"/"}},"fill_value":"NaN","codecs":[{"name":"mycodec","configuration":{"foo":42}}],"storage_transformers":[{"name":"mytransformer","configuration":{"bar":43}}],"dimension_names":["x","y","t"]}"# + ).unwrap().shape(), + ArrayShape::new(vec![(2,1), (3,2), (4,3) ]) + ); } #[tokio::test] @@ -1541,13 +1404,13 @@ mod tests { assert!(matches!( store.get("zarr.json", &ByteRange::ALL).await, - Err(StoreError::NotFound(KeyNotFoundError::NodeNotFound {path})) if path.to_string() == "/" + Err(StoreError{kind: StoreErrorKind::NotFound(KeyNotFoundError::NodeNotFound {path}),..}) if path.to_string() == "/" )); store .set( "zarr.json", - Bytes::copy_from_slice(br#"{"zarr_format":3, "node_type":"group"}"#), + Bytes::copy_from_slice(br#"{"zarr_format":3,"node_type":"group"}"#), ) .await?; assert_eq!( @@ -1555,13 +1418,13 @@ mod tests { Bytes::copy_from_slice(br#"{"zarr_format":3,"node_type":"group"}"#) ); - store.set("a/b/zarr.json", Bytes::copy_from_slice(br#"{"zarr_format":3, "node_type":"group", "attributes": {"spam":"ham", "eggs":42}}"#)).await?; + store.set("a/b/zarr.json", Bytes::copy_from_slice(br#"{"zarr_format":3,"node_type":"group","attributes":{"spam":"ham","eggs":42}}"#)).await?; assert_eq!( - store.get("a/b/zarr.json", &ByteRange::ALL).await.unwrap(), - Bytes::copy_from_slice( - br#"{"zarr_format":3,"node_type":"group","attributes":{"eggs":42,"spam":"ham"}}"# - ) - ); + store.get("a/b/zarr.json", &ByteRange::ALL).await.unwrap(), + Bytes::copy_from_slice( + br#"{"zarr_format":3,"node_type":"group","attributes":{"spam":"ham","eggs":42}}"# + ) + ); let zarr_meta = Bytes::copy_from_slice(br#"{"zarr_format":3,"node_type":"array","attributes":{"foo":42},"shape":[2,2,2],"data_type":"int32","chunk_grid":{"name":"regular","configuration":{"chunk_shape":[1,1,1]}},"chunk_key_encoding":{"name":"default","configuration":{"separator":"/"}},"fill_value":0,"codecs":[{"name":"mycodec","configuration":{"foo":42}}],"storage_transformers":[{"name":"mytransformer","configuration":{"bar":43}}],"dimension_names":["x","y","t"]}"#); store.set("a/b/array/zarr.json", zarr_meta.clone()).await?; @@ -1594,7 +1457,7 @@ mod tests { store.delete("array/zarr.json").await.unwrap(); assert!(matches!( store.get("array/zarr.json", &ByteRange::ALL).await, - Err(StoreError::NotFound(KeyNotFoundError::NodeNotFound { path })) + Err(StoreError{kind: StoreErrorKind::NotFound(KeyNotFoundError::NodeNotFound { path }),..}) if path.to_string() == "/array", )); // Deleting a non-existent key should not fail @@ -1604,7 +1467,7 @@ mod tests { store.delete("array/zarr.json").await.unwrap(); assert!(matches!( store.get("array/zarr.json", &ByteRange::ALL).await, - Err(StoreError::NotFound(KeyNotFoundError::NodeNotFound { path } )) + Err(StoreError{kind: StoreErrorKind::NotFound(KeyNotFoundError::NodeNotFound { path } ), ..}) if path.to_string() == "/array", )); store.set("array/zarr.json", Bytes::copy_from_slice(group_data)).await.unwrap(); @@ -1728,17 +1591,17 @@ mod tests { store.delete("array/c/1/1/1").await.unwrap(); assert!(matches!( store.get("array/c/0/1/0", &ByteRange::ALL).await, - Err(StoreError::NotFound(KeyNotFoundError::ChunkNotFound { key, path, coords })) + Err(StoreError{kind:StoreErrorKind::NotFound(KeyNotFoundError::ChunkNotFound { key, path, coords }),..}) if key == "array/c/0/1/0" && path.to_string() == "/array" && coords == ChunkIndices([0, 1, 0].to_vec()) )); assert!(matches!( store.delete("array/foo").await, - Err(StoreError::InvalidKey { key }) if key == "array/foo", + Err(StoreError{kind: StoreErrorKind::InvalidKey { key }, ..}) if key == "array/foo", )); assert!(matches!( store.delete("array/c/10/1/1").await, - Err(StoreError::SessionError(SessionError::InvalidIndex { coords, path })) + Err(StoreError{kind: StoreErrorKind::SessionError(SessionErrorKind::InvalidIndex { coords, path }),..}) if path.to_string() == "/array" && coords == ChunkIndices([10, 1, 1].to_vec()) )); @@ -1758,18 +1621,18 @@ mod tests { store.delete_dir("group/array").await.unwrap(); assert!(matches!( store.get("group/array/zarr.json", &ByteRange::ALL).await, - Err(StoreError::NotFound(..)) + Err(StoreError { kind: StoreErrorKind::NotFound(..), .. }) )); add_array_and_chunk(&store, "group/array").await.unwrap(); store.delete_dir("group").await.unwrap(); assert!(matches!( store.get("group/zarr.json", &ByteRange::ALL).await, - Err(StoreError::NotFound(..)) + Err(StoreError { kind: StoreErrorKind::NotFound(..), .. }) )); assert!(matches!( store.get("group/array/zarr.json", &ByteRange::ALL).await, - Err(StoreError::NotFound(..)) + Err(StoreError { kind: StoreErrorKind::NotFound(..), .. }) )); add_group(&store, "group").await.unwrap(); @@ -2451,7 +2314,8 @@ mod tests { Bytes::copy_from_slice(br#"{"zarr_format":3, "node_type":"group"}"#), ) .await; - let correct_error = matches!(result, Err(StoreError::ReadOnly)); + let correct_error = + matches!(result, Err(StoreError { kind: StoreErrorKind::ReadOnly, .. })); assert!(correct_error); } @@ -2460,6 +2324,7 @@ mod tests { let repo_dir = TempDir::new().expect("could not create temp dir"); let storage = Arc::new( ObjectStorage::new_local_filesystem(repo_dir.path()) + .await .expect("could not create storage"), ); @@ -2469,7 +2334,7 @@ mod tests { store .set( "zarr.json", - Bytes::copy_from_slice(br#"{"zarr_format":3, "node_type":"group"}"#), + Bytes::copy_from_slice(br#"{"zarr_format":3,"node_type":"group"}"#), ) .await .unwrap(); diff --git a/icechunk/src/strategies.rs b/icechunk/src/strategies.rs index 5a41ee45..cd3d6555 100644 --- a/icechunk/src/strategies.rs +++ b/icechunk/src/strategies.rs @@ -6,12 +6,8 @@ use prop::string::string_regex; use proptest::prelude::*; use proptest::{collection::vec, option, strategy::Strategy}; -use crate::format::snapshot::ZarrArrayMetadata; +use crate::format::snapshot::{ArrayShape, DimensionName}; use crate::format::{ChunkIndices, Path}; -use crate::metadata::{ - ArrayShape, ChunkKeyEncoding, ChunkShape, Codec, DimensionNames, FillValue, - StorageTransformer, -}; use crate::session::Session; use crate::storage::new_in_memory_storage; use crate::Repository; @@ -31,10 +27,10 @@ prop_compose! { // Using Just requires Repository impl Clone, which we do not want // FIXME: add storages strategy - let storage = new_in_memory_storage().expect("Cannot create in memory storage"); let runtime = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); runtime.block_on(async { + let storage = new_in_memory_storage().await.expect("Cannot create in memory storage"); Repository::create(None, storage, HashMap::new()) .await .expect("Failed to initialize repository") @@ -49,10 +45,11 @@ prop_compose! { // Using Just requires Repository impl Clone, which we do not want // FIXME: add storages strategy - let storage = new_in_memory_storage().expect("Cannot create in memory storage"); + let runtime = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); runtime.block_on(async { + let storage = new_in_memory_storage().await.expect("Cannot create in memory storage"); let repository = Repository::create(None, storage, HashMap::new()) .await .expect("Failed to initialize repository"); @@ -61,25 +58,10 @@ prop_compose! { } } -pub fn codecs() -> impl Strategy> { - prop_oneof![Just(vec![Codec { name: "mycodec".to_string(), configuration: None }]),] -} - -pub fn storage_transformers() -> impl Strategy>> { - prop_oneof![ - Just(Some(vec![StorageTransformer { - name: "mytransformer".to_string(), - configuration: None, - }])), - Just(None), - ] -} - #[derive(Debug)] pub struct ShapeDim { - shape: ArrayShape, - chunk_shape: ChunkShape, - dimension_names: Option, + pub shape: ArrayShape, + pub dimension_names: Option>, } pub fn shapes_and_dims(max_ndim: Option) -> impl Strategy { @@ -104,32 +86,37 @@ pub fn shapes_and_dims(max_ndim: Option) -> impl Strategy()), ndim))) }) .prop_map(|(shape, chunk_shape, dimension_names)| ShapeDim { - shape, - chunk_shape: ChunkShape(chunk_shape), - dimension_names, + #[allow(clippy::expect_used)] + shape: ArrayShape::new( + shape.into_iter().zip(chunk_shape.iter().map(|n| n.get())), + ) + .expect("Invalid array shape"), + dimension_names: dimension_names.map(|ds| { + ds.iter().map(|s| From::from(s.as_ref().map(|s| s.as_str()))).collect() + }), }) } -prop_compose! { - pub fn zarr_array_metadata()( - chunk_key_encoding: ChunkKeyEncoding, - fill_value: FillValue, - shape_and_dim in shapes_and_dims(None), - storage_transformers in storage_transformers(), - codecs in codecs(), - ) -> ZarrArrayMetadata { - ZarrArrayMetadata { - shape: shape_and_dim.shape, - data_type: fill_value.get_data_type(), - chunk_shape: shape_and_dim.chunk_shape, - chunk_key_encoding, - fill_value, - codecs, - storage_transformers, - dimension_names: shape_and_dim.dimension_names, - } - } -} +//prop_compose! { +// pub fn zarr_array_metadata()( +// chunk_key_encoding: ChunkKeyEncoding, +// fill_value: FillValue, +// shape_and_dim in shapes_and_dims(None), +// storage_transformers in storage_transformers(), +// codecs in codecs(), +// ) -> ZarrArrayMetadata { +// ZarrArrayMetadata { +// shape: shape_and_dim.shape, +// data_type: fill_value.get_data_type(), +// chunk_shape: shape_and_dim.chunk_shape, +// chunk_key_encoding, +// fill_value, +// codecs, +// storage_transformers, +// dimension_names: shape_and_dim.dimension_names, +// } +// } +//} prop_compose! { pub fn chunk_indices(dim: usize, values_in: Range)(v in proptest::collection::vec(values_in, dim..(dim+1))) -> ChunkIndices { diff --git a/icechunk/src/virtual_chunks.rs b/icechunk/src/virtual_chunks.rs index bd862659..465ea42b 100644 --- a/icechunk/src/virtual_chunks.rs +++ b/icechunk/src/virtual_chunks.rs @@ -12,7 +12,9 @@ use url::Url; use crate::{ config::{Credentials, S3Credentials, S3Options}, format::{ - manifest::{Checksum, SecondsSinceEpoch, VirtualReferenceError}, + manifest::{ + Checksum, SecondsSinceEpoch, VirtualReferenceError, VirtualReferenceErrorKind, + }, ChunkOffset, }, private, @@ -172,7 +174,7 @@ impl VirtualChunkResolver { chunk_location: &str, ) -> Result, VirtualReferenceError> { let cont = find_container(chunk_location, &self.containers).ok_or_else(|| { - VirtualReferenceError::NoContainerForUrl(chunk_location.to_string()) + VirtualReferenceErrorKind::NoContainerForUrl(chunk_location.to_string()) })?; // Many tasks will be fetching chunks at the same time, so it's important @@ -222,9 +224,9 @@ impl VirtualChunkResolver { ObjectStoreConfig::S3(opts) | ObjectStoreConfig::S3Compatible(opts) => { let creds = match self.credentials.get(&cont.name) { Some(Credentials::S3(creds)) => creds, - Some(_) => { - Err(VirtualReferenceError::InvalidCredentials("S3".to_string()))? - } + Some(_) => Err(VirtualReferenceErrorKind::InvalidCredentials( + "S3".to_string(), + ))?, None => { if opts.anonymous { &S3Credentials::Anonymous @@ -238,9 +240,9 @@ impl VirtualChunkResolver { ObjectStoreConfig::Tigris(opts) => { let creds = match self.credentials.get(&cont.name) { Some(Credentials::S3(creds)) => creds, - Some(_) => { - Err(VirtualReferenceError::InvalidCredentials("S3".to_string()))? - } + Some(_) => Err(VirtualReferenceErrorKind::InvalidCredentials( + "S3".to_string(), + ))?, None => { if opts.anonymous { &S3Credentials::Anonymous @@ -300,70 +302,77 @@ impl S3Fetcher { checksum: Option<&Checksum>, ) -> Result, VirtualReferenceError> { let client = &self.client; - let results = split_in_multiple_requests( - range, - self.settings.concurrency().ideal_concurrent_request_size().get(), - self.settings.concurrency().max_concurrent_requests_for_object().get(), - ) - .map(|range| async move { - let key = key.to_string(); - let client = Arc::clone(client); - let bucket = bucket.to_string(); - let mut b = client - .get_object() - .bucket(bucket) - .key(key) - .range(range_to_header(&range)); - - match checksum { - Some(Checksum::LastModified(SecondsSinceEpoch(seconds))) => { - b = b.if_unmodified_since( - aws_sdk_s3::primitives::DateTime::from_secs(*seconds as i64), - ) - } - Some(Checksum::ETag(etag)) => { - b = b.if_match(etag); - } - None => {} - }; + let results = + split_in_multiple_requests( + range, + self.settings.concurrency().ideal_concurrent_request_size().get(), + self.settings.concurrency().max_concurrent_requests_for_object().get(), + ) + .map(|range| async move { + let key = key.to_string(); + let client = Arc::clone(client); + let bucket = bucket.to_string(); + let mut b = client + .get_object() + .bucket(bucket) + .key(key) + .range(range_to_header(&range)); + + match checksum { + Some(Checksum::LastModified(SecondsSinceEpoch(seconds))) => { + b = b.if_unmodified_since( + aws_sdk_s3::primitives::DateTime::from_secs(*seconds as i64), + ) + } + Some(Checksum::ETag(etag)) => { + b = b.if_match(&etag.0); + } + None => {} + }; - b.send() - .await - .map_err(|e| match e { - // minio returns this - SdkError::ServiceError(err) => { - if err.err().meta().code() == Some("PreconditionFailed") { - VirtualReferenceError::ObjectModified(location.to_string()) - } else { - VirtualReferenceError::FetchError(Box::new(SdkError::< - GetObjectError, - >::ServiceError( - err - ))) + b.send() + .await + .map_err(|e| match e { + // minio returns this + SdkError::ServiceError(err) => { + if err.err().meta().code() == Some("PreconditionFailed") { + VirtualReferenceErrorKind::ObjectModified( + location.to_string(), + ) + } else { + VirtualReferenceErrorKind::FetchError(Box::new( + SdkError::::ServiceError(err), + )) + } } - } - // S3 API documents this - SdkError::ResponseError(err) => { - let status = err.raw().status().as_u16(); - // see https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html#API_PutObject_RequestSyntax - if status == 409 || status == 412 { - VirtualReferenceError::ObjectModified(location.to_string()) - } else { - VirtualReferenceError::FetchError(Box::new(SdkError::< - GetObjectError, - >::ResponseError( - err - ))) + // S3 API documents this + SdkError::ResponseError(err) => { + let status = err.raw().status().as_u16(); + // see https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html#API_PutObject_RequestSyntax + if status == 409 || status == 412 { + VirtualReferenceErrorKind::ObjectModified( + location.to_string(), + ) + } else { + VirtualReferenceErrorKind::FetchError(Box::new( + SdkError::::ResponseError(err), + )) + } } - } - other_err => VirtualReferenceError::FetchError(Box::new(other_err)), - })? - .body - .collect() - .await - .map_err(|e| VirtualReferenceError::FetchError(Box::new(e))) - }) - .collect::>(); + other_err => { + VirtualReferenceErrorKind::FetchError(Box::new(other_err)) + } + })? + .body + .collect() + .await + .map_err(|e| { + VirtualReferenceError::from( + VirtualReferenceErrorKind::FetchError(Box::new(e)), + ) + }) + }) + .collect::>(); let init: Box = Box::new(&[][..]); results @@ -385,12 +394,13 @@ impl ChunkFetcher for S3Fetcher { range: &Range, checksum: Option<&Checksum>, ) -> Result { - let url = Url::parse(location).map_err(VirtualReferenceError::CannotParseUrl)?; + let url = + Url::parse(location).map_err(VirtualReferenceErrorKind::CannotParseUrl)?; let bucket_name = if let Some(host) = url.host_str() { host.to_string() } else { - Err(VirtualReferenceError::CannotParseBucketName( + Err(VirtualReferenceErrorKind::CannotParseBucketName( "No bucket name found".to_string(), ))? }; @@ -403,10 +413,11 @@ impl ChunkFetcher for S3Fetcher { let needed_bytes = range.end - range.start; let remaining = buf.remaining() as u64; if remaining != needed_bytes { - Err(VirtualReferenceError::InvalidObjectSize { + Err(VirtualReferenceErrorKind::InvalidObjectSize { expected: needed_bytes, available: remaining, - }) + } + .into()) } else { Ok(buf.copy_to_bytes(needed_bytes as usize)) } @@ -434,7 +445,8 @@ impl ChunkFetcher for LocalFSFetcher { range: &Range, checksum: Option<&Checksum>, ) -> Result { - let url = Url::parse(location).map_err(VirtualReferenceError::CannotParseUrl)?; + let url = + Url::parse(location).map_err(VirtualReferenceErrorKind::CannotParseUrl)?; let usize_range = range.start as usize..range.end as usize; let mut options = GetOptions { range: Some(usize_range.into()), ..Default::default() }; @@ -447,22 +459,23 @@ impl ChunkFetcher for LocalFSFetcher { .expect("Bad last modified field in virtual chunk reference"); options.if_unmodified_since = Some(d); } - Some(Checksum::ETag(etag)) => options.if_match = Some(etag.clone()), + Some(Checksum::ETag(etag)) => options.if_match = Some(etag.0.clone()), None => {} } let path = Path::parse(url.path()) - .map_err(|e| VirtualReferenceError::OtherError(Box::new(e)))?; + .map_err(|e| VirtualReferenceErrorKind::OtherError(Box::new(e)))?; match self.client.get_opts(&path, options).await { Ok(res) => res .bytes() .await - .map_err(|e| VirtualReferenceError::FetchError(Box::new(e))), + .map_err(|e| VirtualReferenceErrorKind::FetchError(Box::new(e)).into()), Err(object_store::Error::Precondition { .. }) => { - Err(VirtualReferenceError::ObjectModified(location.to_string())) + Err(VirtualReferenceErrorKind::ObjectModified(location.to_string()) + .into()) } - Err(err) => Err(VirtualReferenceError::FetchError(Box::new(err))), + Err(err) => Err(VirtualReferenceErrorKind::FetchError(Box::new(err)).into()), } } } diff --git a/icechunk/tests/test_concurrency.rs b/icechunk/tests/test_concurrency.rs index 252b0fc7..a153237d 100644 --- a/icechunk/tests/test_concurrency.rs +++ b/icechunk/tests/test_concurrency.rs @@ -1,10 +1,7 @@ #![allow(clippy::expect_used, clippy::unwrap_used)] use bytes::Bytes; use icechunk::{ - format::{snapshot::ZarrArrayMetadata, ByteRange, ChunkIndices, Path}, - metadata::{ - ChunkKeyEncoding, ChunkShape, Codec, DataType, FillValue, StorageTransformer, - }, + format::{snapshot::ArrayShape, ByteRange, ChunkIndices, Path}, session::{get_chunk, Session}, storage::new_in_memory_storage, Repository, Storage, @@ -13,7 +10,6 @@ use pretty_assertions::assert_eq; use rand::{rng, Rng}; use std::{ collections::{HashMap, HashSet}, - num::NonZeroU64, sync::Arc, time::Duration, }; @@ -35,30 +31,15 @@ const N: usize = 20; /// read. While that happens, another Task lists the chunk contents and only finishes when it finds /// all chunks written. async fn test_concurrency() -> Result<(), Box> { - let storage: Arc = new_in_memory_storage()?; + let storage: Arc = new_in_memory_storage().await?; let repo = Repository::create(None, storage, HashMap::new()).await?; let mut ds = repo.writable_session("main").await?; - ds.add_group(Path::root()).await?; - let zarr_meta = ZarrArrayMetadata { - shape: vec![N as u64, N as u64], - data_type: DataType::Float64, - chunk_shape: ChunkShape(vec![ - NonZeroU64::new(1).expect("Cannot create NonZero"), - NonZeroU64::new(1).expect("Cannot create NonZero"), - ]), - chunk_key_encoding: ChunkKeyEncoding::Slash, - fill_value: FillValue::Float64(f64::NAN), - codecs: vec![Codec { name: "mycodec".to_string(), configuration: None }], - storage_transformers: Some(vec![StorageTransformer { - name: "mytransformer".to_string(), - configuration: None, - }]), - dimension_names: Some(vec![Some("x".to_string()), Some("y".to_string())]), - }; - + let shape = ArrayShape::new(vec![(N as u64, 1), (N as u64, 1)]).unwrap(); + let dimension_names = Some(vec!["x".into(), "y".into()]); + let user_data = Bytes::new(); let new_array_path: Path = "/array".try_into().unwrap(); - ds.add_array(new_array_path.clone(), zarr_meta.clone()).await?; + ds.add_array(new_array_path.clone(), shape, dimension_names, user_data).await?; let ds = Arc::new(RwLock::new(ds)); @@ -154,8 +135,7 @@ async fn list_task(ds: Arc>, barrier: Arc) { expected_indices.insert(ChunkIndices(vec![x, y])); } } - let expected_nodes: HashSet = - HashSet::from_iter(vec!["/".to_string(), "/array".to_string()]); + let expected_nodes: HashSet = HashSet::from_iter(vec!["/array".to_string()]); barrier.wait().await; loop { @@ -165,7 +145,7 @@ async fn list_task(ds: Arc>, barrier: Arc) { .list_nodes() .await .expect("list_nodes failed") - .map(|n| n.path.to_string()) + .map(|n| n.unwrap().path.to_string()) .collect::>(); assert_eq!(expected_nodes, nodes); diff --git a/icechunk/tests/test_distributed_writes.rs b/icechunk/tests/test_distributed_writes.rs index e12f21d3..f5e25158 100644 --- a/icechunk/tests/test_distributed_writes.rs +++ b/icechunk/tests/test_distributed_writes.rs @@ -1,12 +1,11 @@ #![allow(clippy::unwrap_used)] use pretty_assertions::assert_eq; -use std::{collections::HashMap, num::NonZeroU64, ops::Range, sync::Arc}; +use std::{collections::HashMap, ops::Range, sync::Arc}; use bytes::Bytes; use icechunk::{ config::{S3Credentials, S3Options, S3StaticCredentials}, - format::{snapshot::ZarrArrayMetadata, ByteRange, ChunkIndices, Path, SnapshotId}, - metadata::{ChunkKeyEncoding, ChunkShape, DataType, FillValue}, + format::{snapshot::ArrayShape, ByteRange, ChunkIndices, Path, SnapshotId}, repository::VersionInfo, session::{get_chunk, Session}, storage::new_s3_storage, @@ -124,22 +123,11 @@ async fn test_distributed_writes() -> Result<(), Box Result<(), Box> { let repo = Repository::create( Some(RepositoryConfig { inline_chunk_threshold_bytes: Some(0), - unsafe_overwrite_refs: Some(true), ..Default::default() }), Arc::clone(&storage), @@ -66,20 +67,13 @@ pub async fn test_gc() -> Result<(), Box> { let mut ds = repo.writable_session("main").await?; - ds.add_group(Path::root()).await?; - let zarr_meta = ZarrArrayMetadata { - shape: vec![1100], - data_type: DataType::Int8, - chunk_shape: ChunkShape(vec![NonZeroU64::new(1).expect("Cannot create NonZero")]), - chunk_key_encoding: ChunkKeyEncoding::Slash, - fill_value: FillValue::Int8(0), - codecs: vec![], - storage_transformers: None, - dimension_names: None, - }; + let user_data = Bytes::new(); + ds.add_group(Path::root(), user_data.clone()).await?; + + let shape = ArrayShape::new(vec![(1100, 1)]).unwrap(); let array_path: Path = "/array".try_into().unwrap(); - ds.add_array(array_path.clone(), zarr_meta.clone()).await?; + ds.add_array(array_path.clone(), shape, None, user_data.clone()).await?; // we write more than 1k chunks to go beyond the chunk size for object listing and delete for idx in 0..1100 { let bytes = Bytes::copy_from_slice(&42i8.to_be_bytes()); @@ -132,7 +126,6 @@ pub async fn test_gc() -> Result<(), Box> { "main", first_snap_id, Some(&second_snap_id), - false, ) .await?; @@ -197,56 +190,57 @@ async fn make_design_doc_repo( repo: &mut Repository, ) -> Result, Box> { let mut session = repo.writable_session("main").await?; - session.add_group(Path::root()).await?; + let user_data = Bytes::new(); + session.add_group(Path::root(), user_data.clone()).await?; session.commit("1", None).await?; let mut session = repo.writable_session("main").await?; - session.add_group(Path::try_from("/2").unwrap()).await?; + session.add_group(Path::try_from("/2").unwrap(), user_data.clone()).await?; let commit_2 = session.commit("2", None).await?; let mut session = repo.writable_session("main").await?; - session.add_group(Path::try_from("/4").unwrap()).await?; + session.add_group(Path::try_from("/4").unwrap(), user_data.clone()).await?; session.commit("4", None).await?; let mut session = repo.writable_session("main").await?; - session.add_group(Path::try_from("/5").unwrap()).await?; + session.add_group(Path::try_from("/5").unwrap(), user_data.clone()).await?; let snap_id = session.commit("5", None).await?; repo.create_tag("tag2", &snap_id).await?; repo.create_branch("develop", &commit_2).await?; let mut session = repo.writable_session("develop").await?; - session.add_group(Path::try_from("/3").unwrap()).await?; + session.add_group(Path::try_from("/3").unwrap(), user_data.clone()).await?; let snap_id = session.commit("3", None).await?; repo.create_tag("tag1", &snap_id).await?; let mut session = repo.writable_session("develop").await?; - session.add_group(Path::try_from("/6").unwrap()).await?; + session.add_group(Path::try_from("/6").unwrap(), user_data.clone()).await?; let commit_6 = session.commit("6", None).await?; repo.create_branch("test", &commit_6).await?; let mut session = repo.writable_session("test").await?; - session.add_group(Path::try_from("/7").unwrap()).await?; + session.add_group(Path::try_from("/7").unwrap(), user_data.clone()).await?; let commit_7 = session.commit("7", None).await?; let expire_older_than = Utc::now(); repo.create_branch("qa", &commit_7).await?; let mut session = repo.writable_session("qa").await?; - session.add_group(Path::try_from("/8").unwrap()).await?; + session.add_group(Path::try_from("/8").unwrap(), user_data.clone()).await?; session.commit("8", None).await?; let mut session = repo.writable_session("main").await?; - session.add_group(Path::try_from("/12").unwrap()).await?; + session.add_group(Path::try_from("/12").unwrap(), user_data.clone()).await?; session.commit("12", None).await?; let mut session = repo.writable_session("main").await?; - session.add_group(Path::try_from("/13").unwrap()).await?; + session.add_group(Path::try_from("/13").unwrap(), user_data.clone()).await?; session.commit("13", None).await?; let mut session = repo.writable_session("main").await?; - session.add_group(Path::try_from("/14").unwrap()).await?; + session.add_group(Path::try_from("/14").unwrap(), user_data.clone()).await?; session.commit("14", None).await?; let mut session = repo.writable_session("develop").await?; - session.add_group(Path::try_from("/10").unwrap()).await?; + session.add_group(Path::try_from("/10").unwrap(), user_data.clone()).await?; session.commit("10", None).await?; let mut session = repo.writable_session("develop").await?; - session.add_group(Path::try_from("/11").unwrap()).await?; + session.add_group(Path::try_from("/11").unwrap(), user_data.clone()).await?; session.commit("11", None).await?; let mut session = repo.writable_session("test").await?; - session.add_group(Path::try_from("/9").unwrap()).await?; + session.add_group(Path::try_from("/9").unwrap(), user_data.clone()).await?; session.commit("9", None).await?; // let's double check the structuer of the commit tree @@ -276,7 +270,7 @@ async fn make_design_doc_repo( /// We then, expire the branches and tags in the same order as the document /// and we verify we get the same results. pub async fn test_expire_ref() -> Result<(), Box> { - let storage: Arc = new_in_memory_storage().unwrap(); + let storage: Arc = new_in_memory_storage().await?; let storage_settings = storage.default_settings(); let mut repo = Repository::create(None, Arc::clone(&storage), HashMap::new()).await?; @@ -288,16 +282,25 @@ pub async fn test_expire_ref() -> Result<(), Box> { 1, )); - let expired_snaps = expire_ref( + match expire_ref( storage.as_ref(), &storage_settings, asset_manager.clone(), &Ref::Branch("main".to_string()), expire_older_than, ) - .await?; - - assert_eq!(expired_snaps.len(), 4); + .await? + { + ExpireRefResult::RefIsExpired => { + panic!() + } + ExpireRefResult::NothingToDo => { + panic!() + } + ExpireRefResult::Done { released_snapshots, .. } => { + assert_eq!(released_snapshots.len(), 4); + } + } let repo = Repository::open(None, Arc::clone(&storage), HashMap::new()).await?; @@ -318,16 +321,21 @@ pub async fn test_expire_ref() -> Result<(), Box> { Vec::from(["8", "7", "6", "3", "2", "1", "Repository initialized"]) ); - let expired_snaps = expire_ref( + match expire_ref( storage.as_ref(), &storage_settings, asset_manager.clone(), &Ref::Branch("develop".to_string()), expire_older_than, ) - .await?; - - assert_eq!(expired_snaps.len(), 4); + .await? + { + ExpireRefResult::RefIsExpired => panic!(), + ExpireRefResult::NothingToDo => panic!(), + ExpireRefResult::Done { released_snapshots, .. } => { + assert_eq!(released_snapshots.len(), 4); + } + } let repo = Repository::open(None, Arc::clone(&storage), HashMap::new()).await?; @@ -348,16 +356,21 @@ pub async fn test_expire_ref() -> Result<(), Box> { Vec::from(["8", "7", "6", "3", "2", "1", "Repository initialized"]) ); - let expired_snaps = expire_ref( + match expire_ref( storage.as_ref(), &storage_settings, asset_manager.clone(), &Ref::Branch("test".to_string()), expire_older_than, ) - .await?; - - assert_eq!(expired_snaps.len(), 5); + .await? + { + ExpireRefResult::RefIsExpired => panic!(), + ExpireRefResult::NothingToDo => panic!(), + ExpireRefResult::Done { released_snapshots, .. } => { + assert_eq!(released_snapshots.len(), 5); + } + } let repo = Repository::open(None, Arc::clone(&storage), HashMap::new()).await?; @@ -378,16 +391,21 @@ pub async fn test_expire_ref() -> Result<(), Box> { Vec::from(["8", "7", "6", "3", "2", "1", "Repository initialized"]) ); - let expired_snaps = expire_ref( + match expire_ref( storage.as_ref(), &storage_settings, asset_manager.clone(), &Ref::Branch("qa".to_string()), expire_older_than, ) - .await?; - - assert_eq!(expired_snaps.len(), 5); + .await? + { + ExpireRefResult::RefIsExpired => panic!(), + ExpireRefResult::NothingToDo => panic!(), + ExpireRefResult::Done { released_snapshots, .. } => { + assert_eq!(released_snapshots.len(), 5); + } + } let repo = Repository::open(None, Arc::clone(&storage), HashMap::new()).await?; @@ -417,7 +435,7 @@ pub async fn test_expire_ref() -> Result<(), Box> { /// We then, expire old snapshots and garbage collect. We verify we end up /// with what is expected according to the design document. pub async fn test_expire_and_garbage_collect() -> Result<(), Box> { - let storage: Arc = new_in_memory_storage().unwrap(); + let storage: Arc = new_in_memory_storage().await?; let storage_settings = storage.default_settings(); let mut repo = Repository::create(None, Arc::clone(&storage), HashMap::new()).await?; @@ -429,15 +447,17 @@ pub async fn test_expire_and_garbage_collect() -> Result<(), Box StorageResult StorageResult> { - // Add 2 so prefix does not match native s3 storage tests - let url = format!("s3://testbucket/{}2", prefix); - let config = ObjectStorageConfig { - url, - prefix: "".to_string(), - options: vec![ - ("aws_access_key_id".to_string(), "minio123".to_string()), - ("aws_secret_access_key".to_string(), "minio123".to_string()), - ("aws_region".to_string(), "us-east-1".to_string()), - ("aws_endpoint".to_string(), "http://localhost:9000".to_string()), - ("allow_http".to_string(), "true".to_string()), - ("conditional_put".to_string(), "etag".to_string()), - ], - }; - let storage: Arc = Arc::new( - ObjectStorage::from_config(config) - .expect("Creating minio s3 storage with object_store failed"), + let storage = Arc::new( + ObjectStorage::new_s3( + "testbucket".to_string(), + Some(prefix.to_string()), + Some(S3Credentials::Static(S3StaticCredentials { + access_key_id: "minio123".into(), + secret_access_key: "minio123".into(), + session_token: None, + expires_after: None, + })), + Some(S3Options { + region: Some("us-east-1".to_string()), + endpoint_url: Some("http://localhost:9000".to_string()), + allow_http: true, + anonymous: false, + }), + ) + .await?, ); Ok(storage) } -fn mk_azure_blob_storage(prefix: &str) -> StorageResult { - let url = format!("azure://testcontainer/{}", prefix); - let config = ObjectStorageConfig { - url, - prefix: "".to_string(), - options: vec![ - ("account_name".to_string(), "devstoreaccount1".to_string()), - ("use_emulator".to_string(), true.to_string()), - ], - }; - ObjectStorage::from_config(config) +async fn mk_azure_blob_storage( + prefix: &str, +) -> StorageResult> { + let storage = Arc::new( + ObjectStorage::new_azure( + "devstoreaccount1".to_string(), + "testcontainer".to_string(), + Some(prefix.to_string()), + None, + Some(HashMap::from([(AzureConfigKey::UseEmulator, "true".to_string())])), + ) + .await?, + ); + + Ok(storage) } +#[allow(clippy::expect_used)] async fn with_storage(f: F) -> Result<(), Box> where - F: Fn(Arc) -> Fut, + F: Fn(&'static str, Arc) -> Fut, Fut: Future>>, { let prefix = format!("{:?}", ChunkId::random()); let s1 = mk_s3_storage(prefix.as_str()).await?; #[allow(clippy::unwrap_used)] - let s2 = new_in_memory_storage().unwrap(); - let s3 = mk_s3_object_store_storage(prefix.as_str()).await?; - let s4 = Arc::new(mk_azure_blob_storage(prefix.as_str())?); - f(s1).await?; - f(s2).await?; - f(s3).await?; - f(s4).await?; + let s2 = new_in_memory_storage().await.unwrap(); + let s3 = mk_s3_object_store_storage(format!("{prefix}2").as_str()).await?; + let s4 = mk_azure_blob_storage(prefix.as_str()).await?; + let dir = tempdir().expect("cannot create temp dir"); + let s5 = new_local_filesystem_storage(dir.path()) + .await + .expect("Cannot create local Storage"); + + println!("Using in memory storage"); + f("in_memory", s2).await?; + println!("Using local filesystem storage"); + f("local_filesystem", s5).await?; + println!("Using s3 native storage"); + f("s3_native", s1).await?; + println!("Using s3 object_store storage"); + f("s3_object_store", s3).await?; + println!("Using azure_blob storage"); + f("azure_blob", s4).await?; Ok(()) } #[tokio::test] pub async fn test_snapshot_write_read() -> Result<(), Box> { - with_storage(|storage| async move { + with_storage(|_, storage| async move { let storage_settings = storage.default_settings(); let id = SnapshotId::random(); let bytes: [u8; 1024] = core::array::from_fn(|_| rand::random()); @@ -122,7 +147,7 @@ pub async fn test_snapshot_write_read() -> Result<(), Box #[tokio::test] pub async fn test_manifest_write_read() -> Result<(), Box> { - with_storage(|storage| async move { + with_storage(|_, storage| async move { let storage_settings = storage.default_settings(); let id = ManifestId::random(); let bytes: [u8; 1024] = core::array::from_fn(|_| rand::random()); @@ -154,7 +179,7 @@ pub async fn test_manifest_write_read() -> Result<(), Box #[tokio::test] pub async fn test_chunk_write_read() -> Result<(), Box> { - with_storage(|storage| async move { + with_storage(|_, storage| async move { let storage_settings = storage.default_settings(); let id = ChunkId::random(); let bytes = Bytes::from_static(b"hello"); @@ -170,11 +195,10 @@ pub async fn test_chunk_write_read() -> Result<(), Box> { #[tokio::test] pub async fn test_tag_write_get() -> Result<(), Box> { - with_storage(|storage| async move { + with_storage(|_, storage| async move { let storage_settings = storage.default_settings(); let id = SnapshotId::random(); - create_tag(storage.as_ref(), &storage_settings, "mytag", id.clone(), false) - .await?; + create_tag(storage.as_ref(), &storage_settings, "mytag", id.clone()).await?; let back = fetch_tag(storage.as_ref(), &storage_settings, "mytag").await?; assert_eq!(id, back.snapshot); Ok(()) @@ -185,15 +209,15 @@ pub async fn test_tag_write_get() -> Result<(), Box> { #[tokio::test] pub async fn test_fetch_non_existing_tag() -> Result<(), Box> { - with_storage(|storage| async move { + with_storage(|_, storage| async move { let storage_settings = storage.default_settings(); let id = SnapshotId::random(); - create_tag(storage.as_ref(), &storage_settings, "mytag", id.clone(), false) + create_tag(storage.as_ref(), &storage_settings, "mytag", id.clone()) .await?; let back = fetch_tag(storage.as_ref(), &storage_settings, "non-existing-tag").await; - assert!(matches!(back, Err(RefError::RefNotFound(r)) if r == "non-existing-tag")); + assert!(matches!(back, Err(RefError{kind: RefErrorKind::RefNotFound(r), ..}) if r == "non-existing-tag")); Ok(()) }) .await?; @@ -202,16 +226,16 @@ pub async fn test_fetch_non_existing_tag() -> Result<(), Box Result<(), Box> { - with_storage(|storage| async move { + with_storage(|_, storage| async move { let storage_settings = storage.default_settings(); let id = SnapshotId::random(); - create_tag(storage.as_ref(), &storage_settings, "mytag", id.clone(), false) + create_tag(storage.as_ref(), &storage_settings, "mytag", id.clone()) .await?; let res = - create_tag(storage.as_ref(), &storage_settings, "mytag", id.clone(), false) + create_tag(storage.as_ref(), &storage_settings, "mytag", id.clone()) .await; - assert!(matches!(res, Err(RefError::TagAlreadyExists(r)) if r == "mytag")); + assert!(matches!(res, Err(RefError{kind: RefErrorKind::TagAlreadyExists(r), ..}) if r == "mytag")); Ok(()) }) .await?; @@ -220,20 +244,18 @@ pub async fn test_create_existing_tag() -> Result<(), Box #[tokio::test] pub async fn test_branch_initialization() -> Result<(), Box> { - with_storage(|storage| async move { + with_storage(|_, storage| async move { let storage_settings = storage.default_settings(); let id = SnapshotId::random(); - let res = update_branch( + update_branch( storage.as_ref(), &storage_settings, "some-branch", id.clone(), None, - false, ) .await?; - assert_eq!(res.0, 0); let res = fetch_branch_tip(storage.as_ref(), &storage_settings, "some-branch").await?; @@ -247,7 +269,7 @@ pub async fn test_branch_initialization() -> Result<(), Box Result<(), Box> { - with_storage(|storage| async move { + with_storage(|_, storage| async move { let storage_settings = storage.default_settings(); let id = SnapshotId::random(); update_branch( @@ -256,7 +278,6 @@ pub async fn test_fetch_non_existing_branch() -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box> { - with_storage(|storage| async move { + with_storage(|_, storage| async move { let storage_settings = storage.default_settings(); let id1 = SnapshotId::random(); let id2 = SnapshotId::random(); let id3 = SnapshotId::random(); - let res = update_branch( + update_branch( storage.as_ref(), &storage_settings, "some-branch", id1.clone(), None, - false, ) .await?; - assert_eq!(res.0, 0); - let res = update_branch( + update_branch( storage.as_ref(), &storage_settings, "some-branch", id2.clone(), Some(&id1), - false, ) .await?; - assert_eq!(res.0, 1); - let res = update_branch( + update_branch( storage.as_ref(), &storage_settings, "some-branch", id3.clone(), Some(&id2), - false, ) .await?; - assert_eq!(res.0, 2); let res = fetch_branch_tip(storage.as_ref(), &storage_settings, "some-branch").await?; @@ -325,56 +340,27 @@ pub async fn test_branch_update() -> Result<(), Box> { #[tokio::test] pub async fn test_ref_names() -> Result<(), Box> { - with_storage(|storage| async move { + with_storage(|_, storage| async move { let storage_settings = storage.default_settings(); let id1 = SnapshotId::random(); let id2 = SnapshotId::random(); - update_branch( - storage.as_ref(), - &storage_settings, - "main", - id1.clone(), - None, - false, - ) - .await?; + update_branch(storage.as_ref(), &storage_settings, "main", id1.clone(), None) + .await?; update_branch( storage.as_ref(), &storage_settings, "main", id2.clone(), Some(&id1), - false, - ) - .await?; - update_branch( - storage.as_ref(), - &storage_settings, - "foo", - id1.clone(), - None, - false, - ) - .await?; - update_branch( - storage.as_ref(), - &storage_settings, - "bar", - id1.clone(), - None, - false, ) .await?; - create_tag(storage.as_ref(), &storage_settings, "my-tag", id1.clone(), false) + update_branch(storage.as_ref(), &storage_settings, "foo", id1.clone(), None) + .await?; + update_branch(storage.as_ref(), &storage_settings, "bar", id1.clone(), None) + .await?; + create_tag(storage.as_ref(), &storage_settings, "my-tag", id1.clone()).await?; + create_tag(storage.as_ref(), &storage_settings, "my-other-tag", id1.clone()) .await?; - create_tag( - storage.as_ref(), - &storage_settings, - "my-other-tag", - id1.clone(), - false, - ) - .await?; let res: HashSet<_> = HashSet::from_iter(list_refs(storage.as_ref(), &storage_settings).await?); @@ -395,15 +381,19 @@ pub async fn test_ref_names() -> Result<(), Box> { } #[tokio::test] +#[allow(clippy::panic)] pub async fn test_write_config_on_empty() -> Result<(), Box> { - with_storage(|storage| async move { + with_storage(|_, storage| async move { let storage_settings = storage.default_settings(); let config = Bytes::copy_from_slice(b"hello"); - let etag = storage.update_config(&storage_settings, config.clone(), None).await?; - assert_ne!(etag, ""); + let version = match storage.update_config(&storage_settings, config.clone(), &VersionInfo::for_creation()).await? { + UpdateConfigResult::Updated { new_version } => new_version, + UpdateConfigResult::NotOnLatestVersion => panic!(), +}; + assert_ne!(version, VersionInfo::for_creation()); let res = storage.fetch_config(&storage_settings, ).await?; assert!( - matches!(res, Some((bytes, actual_etag)) if actual_etag == etag && bytes == config ) + matches!(res, FetchConfigResult::Found{bytes, version: actual_version} if actual_version == version && bytes == config ) ); Ok(()) }).await?; @@ -411,16 +401,23 @@ pub async fn test_write_config_on_empty() -> Result<(), Box Result<(), Box> { - with_storage(|storage| async move { + with_storage(|_, storage| async move { let storage_settings = storage.default_settings(); - let first_etag = storage.update_config(&storage_settings, Bytes::copy_from_slice(b"hello"), None).await?; + let first_version = match storage.update_config(&storage_settings, Bytes::copy_from_slice(b"hello"), &VersionInfo::for_creation()).await? { + UpdateConfigResult::Updated { new_version } => new_version, + _ => panic!(), + }; let config = Bytes::copy_from_slice(b"bye"); - let second_etag = storage.update_config(&storage_settings, config.clone(), Some(first_etag.as_str())).await?; - assert_ne!(second_etag, first_etag); + let second_version = match storage.update_config(&storage_settings, config.clone(), &first_version).await? { + UpdateConfigResult::Updated { new_version } => new_version, + _ => panic!(), + }; + assert_ne!(second_version, first_version); let res = storage.fetch_config(&storage_settings, ).await?; assert!( - matches!(res, Some((bytes, actual_etag)) if actual_etag == second_etag && bytes == config ) + matches!(res, FetchConfigResult::Found{bytes, version: actual_version} if actual_version == second_version && bytes == config ) ); Ok(()) }).await?; @@ -428,44 +425,118 @@ pub async fn test_write_config_on_existing() -> Result<(), Box Result<(), Box> { // FIXME: this test fails in MiniIO but seems to work on S3 #[allow(clippy::unwrap_used)] - let storage = new_in_memory_storage().unwrap(); + let storage = new_in_memory_storage().await.unwrap(); let storage_settings = storage.default_settings(); - let etag = storage + let version = storage .update_config( &storage_settings, Bytes::copy_from_slice(b"hello"), - Some("00000000000000000000000000000000"), + &VersionInfo::from_etag_only("00000000000000000000000000000000".to_string()), ) .await; - assert!(etag.is_err()); + assert!(matches!(version, Ok(UpdateConfigResult::NotOnLatestVersion))); Ok(()) } #[tokio::test] -pub async fn test_write_config_fails_on_bad_etag_when_existing( +#[allow(clippy::panic)] +pub async fn test_write_config_fails_on_bad_version_when_existing( ) -> Result<(), Box> { - with_storage(|storage| async move { + with_storage(|storage_type, storage| async move { let storage_settings = storage.default_settings(); let config = Bytes::copy_from_slice(b"hello"); - let etag = storage.update_config(&storage_settings, config.clone(), None).await?; - let res = storage + let version = match storage.update_config(&storage_settings, config.clone(), &VersionInfo::for_creation()).await? { + UpdateConfigResult::Updated { new_version } => new_version, + _ => panic!(), + }; + let update_res = storage .update_config(&storage_settings, Bytes::copy_from_slice(b"bye"), - Some("00000000000000000000000000000000"), + &VersionInfo{ + etag: Some(ETag("00000000000000000000000000000000".to_string())), + generation: Some(Generation("0".to_string())), + }, + ) + .await?; + if storage_type == "local_filesystem" { + // FIXME: local file system doesn't have conditional updates yet + assert!(matches!(update_res, UpdateConfigResult::Updated{..})); + + } else { + assert!(matches!(update_res, UpdateConfigResult::NotOnLatestVersion)); + } + + let fetch_res = storage.fetch_config(&storage_settings, ).await?; + if storage_type == "local_filesystem" { + // FIXME: local file system doesn't have conditional updates yet + assert!( + matches!(fetch_res, FetchConfigResult::Found{bytes, version: actual_version} + if actual_version != version && bytes == Bytes::copy_from_slice(b"bye")) + ); + } else { + assert!( + matches!(fetch_res, FetchConfigResult::Found{bytes, version: actual_version} + if actual_version == version && bytes == config ) + ); + } + Ok(()) + }).await?; + Ok(()) +} + +#[tokio::test] +#[allow(clippy::panic)] +pub async fn test_write_config_can_overwrite_with_unsafe_config( +) -> Result<(), Box> { + with_storage(|_, storage| async move { + let storage_settings = storage::Settings { + unsafe_use_conditional_update: Some(false), + unsafe_use_conditional_create: Some(false), + ..storage.default_settings() + }; + + // create the initial version + let config = Bytes::copy_from_slice(b"hello"); + match storage + .update_config( + &storage_settings, + config.clone(), + &VersionInfo { + etag: Some(ETag("some-bad-etag".to_string())), + generation: Some(Generation("42".to_string())), + } + ) + .await? + { + UpdateConfigResult::Updated { new_version } => new_version, + _ => panic!(), + }; + + // attempt a bad change that should succeed in this config + let update_res = storage + .update_config( + &storage_settings, + Bytes::copy_from_slice(b"bye"), + &VersionInfo { + etag: Some(ETag("other-bad-etag".to_string())), + generation: Some(Generation("55".to_string())), + }, ) - .await; - assert!(matches!(res, Err(StorageError::ConfigUpdateConflict))); + .await?; - let res = storage.fetch_config(&storage_settings, ).await?; + assert!(matches!(update_res, UpdateConfigResult::Updated { .. })); + + let fetch_res = storage.fetch_config(&storage_settings).await?; assert!( - matches!(res, Some((bytes, actual_etag)) if actual_etag == etag && bytes == config ) + matches!(fetch_res, FetchConfigResult::Found{bytes, ..} if bytes.as_ref() == b"bye") ); - Ok(()) - }).await?; + Ok(()) + }) + .await?; Ok(()) } diff --git a/icechunk/tests/test_virtual_refs.rs b/icechunk/tests/test_virtual_refs.rs index 37f76ad2..034f64ba 100644 --- a/icechunk/tests/test_virtual_refs.rs +++ b/icechunk/tests/test_virtual_refs.rs @@ -7,25 +7,23 @@ mod tests { format::{ manifest::{ Checksum, ChunkPayload, SecondsSinceEpoch, VirtualChunkLocation, - VirtualChunkRef, VirtualReferenceError, + VirtualChunkRef, VirtualReferenceErrorKind, }, - snapshot::ZarrArrayMetadata, + snapshot::ArrayShape, ByteRange, ChunkId, ChunkIndices, Path, }, - metadata::{ChunkKeyEncoding, ChunkShape, DataType, FillValue}, repository::VersionInfo, - session::{get_chunk, SessionError}, + session::{get_chunk, SessionErrorKind}, storage::{ - self, new_s3_storage, s3::mk_client, ConcurrencySettings, ObjectStorage, + self, new_s3_storage, s3::mk_client, ConcurrencySettings, ETag, ObjectStorage, }, - store::StoreError, + store::{StoreError, StoreErrorKind}, virtual_chunks::VirtualChunkContainer, ObjectStoreConfig, Repository, RepositoryConfig, Storage, Store, }; use std::{ collections::{HashMap, HashSet}, error::Error, - num::NonZeroU64, path::PathBuf, vec, }; @@ -103,6 +101,7 @@ mod tests { async fn create_local_repository(path: &StdPath) -> Repository { let storage: Arc = Arc::new( ObjectStorage::new_local_filesystem(path) + .await .expect("Creating local storage failed"), ); @@ -195,20 +194,9 @@ mod tests { let repo_dir = TempDir::new()?; let repo = create_local_repository(repo_dir.path()).await; let mut ds = repo.writable_session("main").await.unwrap(); - let zarr_meta = ZarrArrayMetadata { - shape: vec![1, 1, 2], - data_type: DataType::Int32, - chunk_shape: ChunkShape(vec![ - NonZeroU64::new(2).unwrap(), - NonZeroU64::new(2).unwrap(), - NonZeroU64::new(1).unwrap(), - ]), - chunk_key_encoding: ChunkKeyEncoding::Slash, - fill_value: FillValue::Int32(0), - codecs: vec![], - storage_transformers: None, - dimension_names: None, - }; + + let shape = ArrayShape::new(vec![(1, 1), (1, 1), (2, 1)]).unwrap(); + let user_data = Bytes::new(); let payload1 = ChunkPayload::Virtual(VirtualChunkRef { location: VirtualChunkLocation::from_absolute_path(&format!( // intentional extra '/' @@ -230,7 +218,7 @@ mod tests { }); let new_array_path: Path = "/array".try_into().unwrap(); - ds.add_array(new_array_path.clone(), zarr_meta.clone()).await.unwrap(); + ds.add_array(new_array_path.clone(), shape, None, user_data).await.unwrap(); ds.set_chunk_ref( new_array_path.clone(), @@ -312,20 +300,8 @@ mod tests { let repo = create_minio_repository().await; let mut ds = repo.writable_session("main").await.unwrap(); - let zarr_meta = ZarrArrayMetadata { - shape: vec![1, 1, 2], - data_type: DataType::Int32, - chunk_shape: ChunkShape(vec![ - NonZeroU64::new(2).unwrap(), - NonZeroU64::new(2).unwrap(), - NonZeroU64::new(1).unwrap(), - ]), - chunk_key_encoding: ChunkKeyEncoding::Slash, - fill_value: FillValue::Int32(0), - codecs: vec![], - storage_transformers: None, - dimension_names: None, - }; + let shape = ArrayShape::new(vec![(1, 1), (1, 1), (2, 1)]).unwrap(); + let user_data = Bytes::new(); let payload1 = ChunkPayload::Virtual(VirtualChunkRef { location: VirtualChunkLocation::from_absolute_path(&format!( // intentional extra '/' @@ -347,7 +323,7 @@ mod tests { }); let new_array_path: Path = "/array".try_into().unwrap(); - ds.add_array(new_array_path.clone(), zarr_meta.clone()).await.unwrap(); + ds.add_array(new_array_path.clone(), shape, None, user_data).await.unwrap(); ds.set_chunk_ref( new_array_path.clone(), @@ -423,16 +399,19 @@ mod tests { max_concurrent_requests_for_object: Some(100.try_into()?), ideal_concurrent_request_size: Some(1.try_into()?), }), + ..repo.storage().default_settings() }); let repo = repo.reopen(Some(config), None)?; assert_eq!( repo.config() - .storage() + .storage + .as_ref() + .unwrap() + .concurrency .as_ref() .unwrap() - .concurrency() - .ideal_concurrent_request_size(), - 1.try_into()? + .ideal_concurrent_request_size, + Some(1.try_into()?) ); let session = repo .readonly_session(&VersionInfo::BranchTipRef("main".to_string())) @@ -526,7 +505,7 @@ mod tests { }; assert!(matches!( store.set_virtual_ref("array/c/0/0/0", bad_ref, true).await, - Err(StoreError::InvalidVirtualChunkContainer { chunk_location }) if chunk_location == bad_location.0)); + Err(StoreError{kind: StoreErrorKind::InvalidVirtualChunkContainer { chunk_location },..}) if chunk_location == bad_location.0)); Ok(()) } @@ -733,7 +712,7 @@ mod tests { ))?, offset: 1, length: 5, - checksum: Some(Checksum::ETag(String::from("invalid etag"))), + checksum: Some(Checksum::ETag(ETag(String::from("invalid etag")))), }; store.set_virtual_ref("array/c/0/0/2", ref1, false).await?; @@ -755,7 +734,7 @@ mod tests { )?, offset: 22306, length: 288, - checksum: Some(Checksum::ETag(String::from("invalid etag"))), + checksum: Some(Checksum::ETag(ETag(String::from("invalid etag")))), }; store.set_virtual_ref("array/c/1/1/1", public_ref, false).await?; @@ -772,9 +751,9 @@ mod tests { // try to fetch the modified chunk assert!(matches!( store.get("array/c/1/0/0", &ByteRange::ALL).await, - Err(StoreError::SessionError(SessionError::VirtualReferenceError( - VirtualReferenceError::ObjectModified(location) - ))) if location == "s3://testbucket/path/to/chunk-3" + Err(StoreError{kind: StoreErrorKind::SessionError(SessionErrorKind::VirtualReferenceError( + VirtualReferenceErrorKind::ObjectModified(location) + )),..}) if location == "s3://testbucket/path/to/chunk-3" )); // these are the local file chunks @@ -786,9 +765,9 @@ mod tests { // try to fetch the modified chunk assert!(matches!( store.get("array/c/1/0/1", &ByteRange::ALL).await, - Err(StoreError::SessionError(SessionError::VirtualReferenceError( - VirtualReferenceError::ObjectModified(location) - ))) if location.contains("chunk-3") + Err(StoreError{kind: StoreErrorKind::SessionError(SessionErrorKind::VirtualReferenceError( + VirtualReferenceErrorKind::ObjectModified(location) + )),..}) if location.contains("chunk-3") )); // these are the public bucket chunks @@ -804,9 +783,9 @@ mod tests { // try to fetch the modified chunk assert!(matches!( store.get("array/c/1/1/2", &ByteRange::ALL).await, - Err(StoreError::SessionError(SessionError::VirtualReferenceError( - VirtualReferenceError::ObjectModified(location) - ))) if location == "s3://earthmover-sample-data/netcdf/oscar_vel2018.nc" + Err(StoreError{kind: StoreErrorKind::SessionError(SessionErrorKind::VirtualReferenceError( + VirtualReferenceErrorKind::ObjectModified(location) + )),..}) if location == "s3://earthmover-sample-data/netcdf/oscar_vel2018.nc" )); let session = store.session();