diff --git a/Cargo.lock b/Cargo.lock index 355e31028d..9835592590 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1099,9 +1099,9 @@ dependencies = [ [[package]] name = "bilrost" -version = "0.1014.0" +version = "0.1014.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1ec781b82aaa1ead5e8c6ab1cf7b3485f8909e76521f87eb3091a8db5bc00df" +checksum = "be62a88fe2e8da9012cbf81085cc86f53ce9a8584507180e3eb9918a9b77b02f" dependencies = [ "autocfg", "bilrost-derive", @@ -1112,9 +1112,9 @@ dependencies = [ [[package]] name = "bilrost-derive" -version = "0.1014.0" +version = "0.1014.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f96db92d1373235cede86875c0589f1e053eb36f8e26f2fc131690bf7d03de6" +checksum = "a770fdbaa701326b4fe9400bb5dada1ec01a8b026f078154f010ffed544a5da1" dependencies = [ "eyre", "itertools 0.14.0", @@ -3402,10 +3402,11 @@ dependencies = [ [[package]] name = "generic-array" -version = "1.2.0" +version = "1.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8c8444bc9d71b935156cc0ccab7f622180808af7867b1daae6547d773591703" +checksum = "eaf57c49a95fd1fe24b90b3033bee6dc7e8f1288d51494cb44e627c295e38542" dependencies = [ + "rustversion", "typenum", ] @@ -3957,9 +3958,9 @@ dependencies = [ [[package]] name = "indenter" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" [[package]] name = "indexmap" @@ -4484,7 +4485,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" dependencies = [ "cfg-if", - "windows-targets 0.52.6", + "windows-targets 0.48.5", ] [[package]] @@ -5949,7 +5950,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6c3320f9abac597dcbc668774ef006702672474aad53c6d596b62e487b40b1" dependencies = [ "heck", - "itertools 0.14.0", + "itertools 0.13.0", "log", "multimap", "once_cell", @@ -5984,7 +5985,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.13.0", "proc-macro2", "quote", "syn 2.0.106", @@ -7645,6 +7646,7 @@ dependencies = [ "restate-metadata-server", "restate-metadata-store", "restate-node", + "restate-object-store-util", "restate-rocksdb", "restate-service-client", "restate-test-util", @@ -7914,6 +7916,49 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "restate-ty" +version = "1.6.0-dev" +dependencies = [ + "anyhow", + "base62", + "base64 0.22.1", + "bilrost", + "bytes", + "bytestring", + "criterion", + "derive_more", + "downcast-rs", + "enum-map", + "generic-array 1.3.5", + "googletest", + "itertools 0.14.0", + "num-traits", + "opentelemetry", + "paste", + "prost 0.14.1", + "prost-build", + "prost-dto", + "prost-types 0.14.1", + "rand 0.9.1", + "restate-base64-util", + "restate-encoding", + "restate-ty", + "restate-workspace-hack", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "serde_with", + "sha2", + "smartstring", + "static_assertions", + "strum 0.27.1", + "thiserror 2.0.17", + "ulid", + "xxhash-rust", +] + [[package]] name = "restate-types" version = "1.6.0-dev" @@ -7944,7 +7989,7 @@ dependencies = [ "flexbuffers", "futures", "gardal", - "generic-array 1.2.0", + "generic-array 1.3.5", "googletest", "hostname", "http 1.3.1", @@ -7977,6 +8022,7 @@ dependencies = [ "restate-serde-util", "restate-test-util", "restate-time-util", + "restate-ty", "restate-types", "restate-utoipa", "restate-workspace-hack", @@ -8152,6 +8198,8 @@ dependencies = [ "comfy-table", "criterion", "crossbeam-epoch", + "darling 0.20.10", + "darling_core 0.20.10", "datafusion-common", "datafusion-expr", "digest", @@ -8181,7 +8229,6 @@ dependencies = [ "idna", "indexmap 1.9.3", "indexmap 2.11.4", - "itertools 0.13.0", "itertools 0.14.0", "lexical-parse-float", "lexical-parse-integer", @@ -8231,6 +8278,7 @@ dependencies = [ "serde_json", "serde_with", "smallvec", + "smartstring", "sqlparser", "stable_deref_trait", "syn 2.0.106", @@ -8526,9 +8574,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.19" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" @@ -8581,6 +8629,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "schemars" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "schemars_derive" version = "0.8.22" @@ -8755,9 +8815,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.13.0" +version = "3.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf65a400f8f66fb7b0552869ad70157166676db75ed8181f8104ea91cf9d0b42" +checksum = "aa66c845eee442168b2c8134fec70ac50dc20e760769c8ba0ad1319ca1959b04" dependencies = [ "base64 0.22.1", "chrono", @@ -8765,8 +8825,8 @@ dependencies = [ "indexmap 1.9.3", "indexmap 2.11.4", "schemars 0.9.0", - "serde", - "serde_derive", + "schemars 1.0.4", + "serde_core", "serde_json", "serde_with_macros", "time", @@ -8774,11 +8834,11 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.13.0" +version = "3.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81679d9ed988d5e9a5e6531dc3f2c28efbd639cbd1dfb628df08edea6004da77" +checksum = "b91a903660542fced4e99881aa481bdbaec1634568ee02e0b8bd57c64cb38955" dependencies = [ - "darling 0.20.10", + "darling 0.21.3", "proc-macro2", "quote", "syn 2.0.106", @@ -10039,9 +10099,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "typify" @@ -10496,7 +10556,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d9e5d6efc2..0d038b6925 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ members = [ "cli", "crates/*", + "crates/lib/ty", "crates/core/derive", "crates/encoding/derive", "crates/codederror/derive", @@ -15,11 +16,13 @@ members = [ "tools/xtask", "workspace-hack", ] + +exclude = [ + "crates/lib", +] + default-members = [ "cli", - "crates/*", - "crates/core/derive", - "crates/codederror/derive", "server", "tools/restatectl", ] @@ -162,7 +165,7 @@ hyper-rustls = { version = "0.27.2", default-features = false, features = [ hyper-util = { version = "0.1" } indexmap = "2.7" itertools = "0.14.0" -jiff = "0.2.14" +jiff = { version = "0.2.14" } jsonschema = { version = "0.28.3", default-features = false } metrics = { version = "0.24" } metrics-exporter-prometheus = { version = "0.17", default-features = false, features = [ @@ -184,7 +187,7 @@ paste = "1.0" pin-project = "1.0" pin-project-lite = { version = "0.2" } prost = { version = "0.14.1" } -prost-build = { version = "0.14.1" } +prost-build = { version = "0.14.1", default-features = false } priority-queue = { version = "2.7.0" } prost-dto = { version = "0.0.4" } prost-types = { version = "0.14.1" } @@ -206,10 +209,10 @@ rustls = { version = "0.23.26", default-features = false, features = ["ring"] } schemars = { version = "0.8", features = ["bytes", "enumset"] } semver = { version = "1.0", features = ["serde"] } serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -serde_with = "3.8" +serde_json = { version = "1.0" } +serde_with = { version = "3.15" } serde_yaml = "0.9" -sha2 = "0.10.8" +sha2 = { version = "0.10.8" } smartstring = { version = "1.0.1" } static_assertions = { version = "1.1.0" } strum = { version = "0.27.1", features = ["derive"] } @@ -253,7 +256,7 @@ ulid = { version = "1.2.0" } url = { version = "2.5" } urlencoding = { version = "2.1" } uuid = { version = "1.3.0", features = ["v7", "serde"] } -xxhash-rust = { version = "0.8", features = ["xxh3"] } +xxhash-rust = { version = "0.8" } zstd = { version = "0.13" } [patch.crates-io.restate-workspace-hack] diff --git a/crates/lib/ty/Cargo.toml b/crates/lib/ty/Cargo.toml new file mode 100644 index 0000000000..765e9aca65 --- /dev/null +++ b/crates/lib/ty/Cargo.toml @@ -0,0 +1,62 @@ +[package] +name = "restate-ty" +version.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish = false + +[features] +default = [] +test-util = [] + +[dependencies] +restate-workspace-hack = { workspace = true } + +restate-base64-util = { workspace = true } +restate-encoding = { workspace = true } + +anyhow = { workspace = true } +base62 = { version = "2.2.2" } +base64 = { workspace = true } +bilrost = { workspace = true, features = ["derive"] } +bytes = { workspace = true } +bytestring = { workspace = true } +derive_more = { workspace = true } +downcast-rs = { workspace = true } +enum-map = { workspace = true } +generic-array = { version = "1.3.5" } +itertools = { workspace = true } +num-traits = { workspace = true } +opentelemetry = { workspace = true, features = ["trace"] } +paste = { workspace = true } +prost = { workspace = true } +prost-dto = { workspace = true } +prost-types = { workspace = true } +rand = { workspace = true } +schemars = { workspace = true, optional = true } +semver = { workspace = true } +serde = { workspace = true } +serde_with = { workspace = true } +sha2 = { workspace = true } +smartstring = { workspace = true } +static_assertions = { workspace = true } +strum = { workspace = true } +thiserror = { workspace = true } +ulid = { workspace = true } +xxhash-rust = { workspace = true, features = ["xxh3"] } + +[dev-dependencies] +restate-ty = { path = ".", default-features = false, features = ["test-util"]} + +googletest = { workspace = true } +criterion = { workspace = true } +serde_json = { workspace = true } + +[build-dependencies] +prost-build = { workspace = true } + +[[bench]] +name = "id_encoding" +harness = false diff --git a/crates/lib/ty/benches/id_encoding.rs b/crates/lib/ty/benches/id_encoding.rs new file mode 100644 index 0000000000..72ef711bf3 --- /dev/null +++ b/crates/lib/ty/benches/id_encoding.rs @@ -0,0 +1,58 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +use std::fmt::Write; + +use criterion::{Criterion, criterion_group, criterion_main}; + +use restate_ty::identifiers::AwakeableIdentifier; +use restate_ty::identifiers::InvocationId; +use restate_ty::identifiers::ResourceId; + +pub fn id_encoding(c: &mut Criterion) { + c.bench_function("invocation-id-display", |b| { + let mut buf = String::with_capacity(InvocationId::str_encoded_len()); + b.iter_batched( + InvocationId::mock_random, + |id| { + buf.clear(); + write!(&mut buf, "{id}") + }, + criterion::BatchSize::SmallInput, + ); + }) + .bench_function("invocation-id-to_string", |b| { + b.iter_batched( + InvocationId::mock_random, + |id| id.to_string(), + criterion::BatchSize::SmallInput, + ); + }) + .bench_function("awakeable-id-to_string", |b| { + b.iter_batched( + || { + let invocation_id = InvocationId::mock_random(); + let entry_index = rand::random(); + + AwakeableIdentifier::new(invocation_id, entry_index) + }, + |id| id.to_string(), + criterion::BatchSize::SmallInput, + ); + }); +} + +criterion_group!( + name=benches; + config = Criterion::default(); + targets=id_encoding +); + +criterion_main!(benches); diff --git a/crates/lib/ty/build.rs b/crates/lib/ty/build.rs new file mode 100644 index 0000000000..86111b0e2a --- /dev/null +++ b/crates/lib/ty/build.rs @@ -0,0 +1,36 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +use std::env; +use std::path::PathBuf; + +fn main() -> std::io::Result<()> { + let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); + + prost_build::Config::new() + .bytes(["."]) + .enum_attribute( + "ServiceTag", + "#[derive(::derive_more::IsVariant, ::derive_more::Display)]", + ) + .enum_attribute( + "NodeStatus", + "#[derive(::serde::Serialize, ::derive_more::IsVariant)]", + ) + .enum_attribute("AdminStatus", "#[derive(::serde::Serialize)]") + .enum_attribute("LogServerStatus", "#[derive(::serde::Serialize)]") + .enum_attribute("WorkerStatus", "#[derive(::serde::Serialize)]") + .enum_attribute("MetadataServerStatus", "#[derive(::serde::Serialize)]") + .file_descriptor_set_path(out_dir.join("common_descriptor.bin")) + // allow older protobuf compiler to be used + .protoc_arg("--experimental_allow_proto3_optional") + .compile_protos(&["./protobuf/restate/common.proto"], &["protobuf"])?; + Ok(()) +} diff --git a/crates/lib/ty/protobuf/restate/common.proto b/crates/lib/ty/protobuf/restate/common.proto new file mode 100644 index 0000000000..ecbbad62e7 --- /dev/null +++ b/crates/lib/ty/protobuf/restate/common.proto @@ -0,0 +1,127 @@ +// Copyright (c) 2024 - Restate Software, Inc., Restate GmbH +// +// This file is part of the Restate service protocol, which is +// released under the MIT license. +// +// You can find a copy of the license in file LICENSE in the root +// directory of this repository or package, or at +// https://github.com/restatedev/proto/blob/main/LICENSE + +syntax = "proto3"; + +package restate.common; + +enum ProtocolVersion { + ProtocolVersion_UNKNOWN = 0; + reserved 1; + // Released in v1.2. Support dropped in v1.5.0 + // V1 = 1; + // [Native RPC] Released in >= v1.3.3 + V2 = 2; +} + +message NodeId { + uint32 id = 1; + optional uint32 generation = 2; +} + +message GenerationalNodeId { + uint32 id = 1; + uint32 generation = 2; +} + +// Partition Processor leadership epoch number +message LeaderEpoch { uint64 value = 1; } + +// Log sequence number +message Lsn { uint64 value = 1; } + +// A generic type for versioned metadata +message Version { uint32 value = 1; } + +// The handle name or type tag of the message. For every service there must be +// exactly one message handler implementation. +enum ServiceTag { + reserved 1 to 25, 40 to 43, 50 to 53, 60, 61, 80 to 85 ; + ServiceTag_UNKNOWN = 0; + // LogServer + LOG_SERVER_DATA_SERVICE = 26; + LOG_SERVER_META_SERVICE = 27; + + // ReplicatedLoglet + SEQUENCER_DATA_SERVICE = 44; + SEQUENCER_META_SERVICE = 45; + // Partition Processor + PARTITION_MANAGER_SERVICE = 54; + PARTITION_LEADER_SERVICE = 55; + + // Failure detector + GOSSIP_SERVICE = 62; + + // Data fusion + REMOTE_DATA_FUSION_SERVICE = 86; + + // Metadata management + METADATA_MANAGER_SERVICE = 100; +} + +// ** Health & Per-role Status + +enum NodeStatus { + NodeStatus_UNKNOWN = 0; + // The node has joined the cluster and is fully operational. + ALIVE = 1; + // The node is not fully running yet. + STARTING_UP = 2; + // The node is performing a graceful shutdown. + SHUTTING_DOWN = 3; +} + +enum NodeRpcStatus { + NodeRpcStatus_UNKNOWN = 0; + NodeRpcStatus_READY = 1; + NodeRpcStatus_STARTING_UP = 2; + NodeRpcStatus_STOPPING = 3; +} + +enum WorkerStatus { + WorkerStatus_UNKNOWN = 0; + WorkerStatus_READY = 1; + WorkerStatus_STARTING_UP = 2; +} + +enum AdminStatus { + AdminStatus_UNKNOWN = 0; + AdminStatus_READY = 1; + AdminStatus_STARTING_UP = 2; +} + +enum LogServerStatus { + LogServerStatus_UNKNOWN = 0; + LogServerStatus_READY = 1; + LogServerStatus_STARTING_UP = 2; + LogServerStatus_FAILSAFE = 3; + LogServerStatus_STOPPING = 4; +} + +enum MetadataServerStatus { + MetadataServerStatus_UNKNOWN = 0; + MetadataServerStatus_STARTING_UP = 1; + MetadataServerStatus_AWAITING_PROVISIONING = 2; + MetadataServerStatus_MEMBER = 3; + MetadataServerStatus_STANDBY = 4; +} + +enum IngressStatus { + IngressStatus_UNKNOWN = 0; + IngressStatus_READY = 1; + IngressStatus_STARTING_UP = 2; +} + +enum MetadataKind { + MetadataKind_UNKNOWN = 0; + NODES_CONFIGURATION = 1; + SCHEMA = 2; + PARTITION_TABLE = 3; + LOGS = 4; +} diff --git a/crates/lib/ty/src/base62_util.rs b/crates/lib/ty/src/base62_util.rs new file mode 100644 index 0000000000..c198f1d791 --- /dev/null +++ b/crates/lib/ty/src/base62_util.rs @@ -0,0 +1,109 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +//! Note: We use base62 [0-9a-zA-Z], that's why we use encode_alternative functions on +//! base62 crate. Otherwise, by default it uses [0-9A-Za-z]. + +use std::mem::size_of; + +const BITS_PER_BASE62_CHAR: usize = 6; +const BITS_PER_BYTE: usize = 8; + +const BASE: u64 = 62; +const BASE_TO_2: u64 = BASE * BASE; +const BASE_TO_3: u64 = BASE_TO_2 * BASE; +const BASE_TO_4: u64 = BASE_TO_3 * BASE; +const BASE_TO_5: u64 = BASE_TO_4 * BASE; +const BASE_TO_6: u64 = BASE_TO_5 * BASE; +const BASE_TO_7: u64 = BASE_TO_6 * BASE; +const BASE_TO_8: u64 = BASE_TO_7 * BASE; +const BASE_TO_9: u64 = BASE_TO_8 * BASE; +const BASE_TO_10: u128 = (BASE_TO_9 * BASE) as u128; +const BASE_TO_11: u128 = BASE_TO_10 * BASE as u128; +const BASE_TO_12: u128 = BASE_TO_11 * BASE as u128; +const BASE_TO_13: u128 = BASE_TO_12 * BASE as u128; +const BASE_TO_14: u128 = BASE_TO_13 * BASE as u128; +const BASE_TO_15: u128 = BASE_TO_14 * BASE as u128; +const BASE_TO_16: u128 = BASE_TO_15 * BASE as u128; +const BASE_TO_17: u128 = BASE_TO_16 * BASE as u128; +const BASE_TO_18: u128 = BASE_TO_17 * BASE as u128; +const BASE_TO_19: u128 = BASE_TO_18 * BASE as u128; +const BASE_TO_20: u128 = BASE_TO_19 * BASE as u128; +const BASE_TO_21: u128 = BASE_TO_20 * BASE as u128; + +fn digit_count(n: u128) -> usize { + const POWERS: [u128; 22] = [ + 0, + BASE as u128, + BASE_TO_2 as u128, + BASE_TO_3 as u128, + BASE_TO_4 as u128, + BASE_TO_5 as u128, + BASE_TO_6 as u128, + BASE_TO_7 as u128, + BASE_TO_8 as u128, + BASE_TO_9 as u128, + BASE_TO_10, + BASE_TO_11, + BASE_TO_12, + BASE_TO_13, + BASE_TO_14, + BASE_TO_15, + BASE_TO_16, + BASE_TO_17, + BASE_TO_18, + BASE_TO_19, + BASE_TO_20, + BASE_TO_21, + ]; + + match POWERS.binary_search(&n) { + Ok(n) => n.wrapping_add(1), + Err(n) => n, + } +} + +/// Encodes a u64 into base62 string, this function offsets the string with +/// enough bytes (assuming the input buffer is zeroed with b'0') to ensure the string is of fixed length 11. +pub fn base62_encode_fixed_width_u64(i: u64, out: &mut [u8]) -> usize { + const MAX_LENGTH: usize = base62_max_length_for_type::(); + + let i = i.to_be(); + + let digits = digit_count(i.into()); + let offset = MAX_LENGTH - digits; + + let digits2 = base62::encode_alternative_bytes(i, &mut out[offset..offset + digits]) + .expect("a u64 must fit into 11 digits"); + debug_assert_eq!(digits, digits2); + + MAX_LENGTH +} + +/// Encodes a u128 into base62 string, this function offsets the string with +/// enough bytes (assuming the input buffer is zeroed) to ensure the string is of fixed length 22. +pub fn base62_encode_fixed_width_u128(i: u128, out: &mut [u8]) -> usize { + const MAX_LENGTH: usize = base62_max_length_for_type::(); + + let i = i.to_be(); + let digits = digit_count(i); + let offset = MAX_LENGTH - digits; + + let digits2 = base62::encode_alternative_bytes(i, &mut out[offset..offset + digits]) + .expect("a u128 must fit into 22 digits"); + debug_assert_eq!(digits, digits2); + + MAX_LENGTH +} + +/// Calculate the max number of chars needed to encode this type as base62 +pub const fn base62_max_length_for_type() -> usize { + (size_of::() * BITS_PER_BYTE).div_ceil(BITS_PER_BASE62_CHAR) +} diff --git a/crates/lib/ty/src/errors.rs b/crates/lib/ty/src/errors.rs new file mode 100644 index 0000000000..d509555ee4 --- /dev/null +++ b/crates/lib/ty/src/errors.rs @@ -0,0 +1,88 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +use std::fmt; + +/// Error type which abstracts away the actual [`std::error::Error`] type. Use this type +/// if you don't know the actual error type or if it is not important. +pub type GenericError = Box; + +pub type BoxedMaybeRetryableError = Box; + +/// Tells whether an error should be retried by upper layers or not. +pub trait MaybeRetryableError: std::error::Error + 'static { + /// Signal upper layers whether this error should be retried or not. + fn retryable(&self) -> bool { + false + } +} + +static_assertions::assert_obj_safe!(MaybeRetryableError); + +pub trait IntoMaybeRetryable: Sized { + /// Marks the error marked as retryable + fn into_retryable(self) -> RetryableError { + RetryableError(self) + } + + /// Marks the error marked as non-retryable + fn into_terminal(self) -> TerminalError { + TerminalError(self) + } +} + +impl IntoMaybeRetryable for T where + T: fmt::Debug + fmt::Display + Send + Sync + std::error::Error + 'static +{ +} + +/// Wraps any source error and marks it as retryable +#[derive(Debug, thiserror::Error, derive_more::Deref, derive_more::From)] +pub struct RetryableError(#[source] T); + +/// Wraps any source error and marks it as non-retryable +#[derive(Debug, thiserror::Error, derive_more::Deref, derive_more::From)] +pub struct TerminalError(#[source] T); + +impl MaybeRetryableError for RetryableError +where + T: std::error::Error + 'static, +{ + fn retryable(&self) -> bool { + true + } +} + +impl MaybeRetryableError for TerminalError +where + T: std::error::Error + 'static, +{ + fn retryable(&self) -> bool { + false + } +} + +impl fmt::Display for RetryableError +where + T: fmt::Debug + fmt::Display + std::error::Error, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "[retryable] {}", self.0) + } +} + +impl fmt::Display for TerminalError +where + T: fmt::Debug + fmt::Display + std::error::Error + 'static, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "[terminal] {}", self.0) + } +} diff --git a/crates/lib/ty/src/identifiers.rs b/crates/lib/ty/src/identifiers.rs new file mode 100644 index 0000000000..a2e8f21de5 --- /dev/null +++ b/crates/lib/ty/src/identifiers.rs @@ -0,0 +1,550 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +//! Restate uses many identifiers to uniquely identify its services and entities. +mod awakeable; +mod deployment; +mod idempotency; +mod invocation; +#[cfg(any(test, feature = "test-util"))] +pub mod mocks; +mod partition_processor_rpc_request; +mod signal; +mod snapshot; +mod subscription; + +use std::str::FromStr; + +use generic_array::ArrayLength; +use generic_array::GenericArray; +use generic_array::sequence::GenericSequence; +use num_traits::PrimInt; + +use crate::base62_util::{ + base62_encode_fixed_width_u64, base62_encode_fixed_width_u128, base62_max_length_for_type, +}; + +pub const ID_RESOURCE_SEPARATOR: char = '_'; + +/// Error parsing/decoding a resource ID. +#[derive(Debug, thiserror::Error, Clone, Eq, PartialEq)] +pub enum IdDecodeError { + #[error("bad length")] + Length, + #[error("base62 decode error")] + Codec, + #[error("bad format")] + Format, + #[error("unrecognized codec version")] + Version, + #[error("id doesn't match the expected type")] + TypeMismatch, + #[error("unrecognized resource type: {0}")] + UnrecognizedType(String), +} + +prefixed_ids! { + /// The set of resources that we can generate IDs for. Those resource IDs will + /// follow the same encoding scheme according to the [default] version. + #[derive(Debug, Clone, Copy, Eq, PartialEq)] + pub enum IdResourceType { + Invocation("inv"), + Deployment("dp"), + Subscription("sub"), + Awakeable("prom"), + Signal("sign"), + Snapshot("snap"), + } +} + +pub use awakeable::AwakeableIdentifier; +pub use deployment::DeploymentId; +pub use idempotency::IdempotencyId; +pub use invocation::*; +pub use partition_processor_rpc_request::PartitionProcessorRpcRequestId; +pub use signal::ExternalSignalIdentifier; +pub use snapshot::SnapshotId; +pub use subscription::SubscriptionId; + +/// versions of the ID encoding scheme that we use to generate user-facing ID tokens. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum IdSchemeVersion { + /// V1 is the first version of the ID encoding scheme. + /// + /// V1 IDs are encoded as follows: + /// - up to 4c for the resource type (defined in [`IdResourceType`]) + /// - a separator character `_` as defined in [`ID_RESOURCE_SEPARATOR`] + /// - 1c for the codec version, currently `1` + /// - A type-specific base62 encoded string for the ID type. + V1, +} + +impl IdSchemeVersion { + pub const fn latest() -> Self { + Self::V1 + } + + pub const fn as_char(&self) -> char { + match self { + Self::V1 => '1', + } + } +} + +impl FromStr for IdSchemeVersion { + type Err = IdDecodeError; + + fn from_str(value: &str) -> Result { + match value { + "1" => Ok(Self::V1), + _ => Err(IdDecodeError::Version), + } + } +} + +// A marker trait for serializable IDs that represent restate resources or entities. +// Those could be user-facing or not. +pub trait ResourceId { + const RAW_BYTES_LEN: usize; + const RESOURCE_TYPE: IdResourceType; + + type StrEncodedLen: ArrayLength; + + /// The number of characters/bytes needed to string-serialize this resource identifier + fn str_encoded_len() -> usize { + ::USIZE + } + + /// Adds the various fields of this resource ID into the pre-initialized encoder + fn push_to_encoder(&self, encoder: &mut IdEncoder); +} + +/// A family of resource identifiers that tracks the timestamp of its creation. +pub trait TimestampAwareId { + /// The timestamp when this ID was created. + fn timestamp_ms(&self) -> u64; +} + +/// strings and extracts the next encoded token and tracks the buffer offset. +pub struct IdStrCursor<'a> { + inner: &'a str, + offset: usize, +} + +impl<'a> IdStrCursor<'a> { + pub fn new(inner: &'a str) -> Self { + Self { inner, offset: 0 } + } + + /// Reads the next encoded token from the wrapped string and advances the offset. + /// The number of characters to read depends on the type T. Type T is any integer + /// primitive that is of size u128 or smaller. + pub fn decode_next(&mut self) -> Result + where + T: PrimInt + TryFrom, + >::Error: std::fmt::Debug, + { + let size_to_read = base62_max_length_for_type::(); + let sliced_view = self + .inner + .get(self.offset..self.offset + size_to_read) + .ok_or(IdDecodeError::Length)?; + // de-pad. + let decoded = base62::decode_alternative(sliced_view.trim_start_matches('0')).or_else( + |e| match e { + // If we trim all zeros and nothing left, we assume there was a + // single zero value in the original input. + base62::DecodeError::EmptyInput => Ok(0), + _ => Err(IdDecodeError::Codec), + }, + )?; + let out = T::from_be(decoded.try_into().map_err(|_| IdDecodeError::Codec)?); + self.offset += size_to_read; + Ok(out) + } + + /// Reads an exact slice of the input string based on the number of "bytes" specified + /// in `length` + pub fn next_str_exact(&mut self, length: usize) -> Result<&'a str, IdDecodeError> { + let out = self + .inner + .get(self.offset..self.offset + length) + .ok_or(IdDecodeError::Length)?; + self.offset += length; + Ok(out) + } + + /// Reads remaining bytes as string slice without decoding + pub fn take_remaining(self) -> Result<&'a str, IdDecodeError> { + let out = self.inner.get(self.offset..).ok_or(IdDecodeError::Length)?; + Ok(out) + } + + /// The number of characters remaining in the wrapped string that haven't been decoded yet. + #[allow(dead_code)] + pub fn remaining(&self) -> usize { + self.inner.len().saturating_sub(self.offset) + } +} + +pub struct IdDecoder<'a> { + #[allow(unused)] + pub version: IdSchemeVersion, + pub resource_type: IdResourceType, + pub cursor: IdStrCursor<'a>, +} + +impl<'a> IdDecoder<'a> { + /// Decode an string that doesn't have the prefix, type, nor version fields. + pub fn new_ignore_prefix( + version: IdSchemeVersion, + resource_type: IdResourceType, + input: &'a str, + ) -> Result { + Ok(Self { + version, + resource_type, + cursor: IdStrCursor::new(input), + }) + } + + /// Start decoding a well-formed ID string. + pub fn new(input: &'a str) -> Result { + if input.is_empty() { + return Err(IdDecodeError::Length); + } + // prefix token + let (prefix, id_part) = input + .split_once(ID_RESOURCE_SEPARATOR) + .ok_or(IdDecodeError::Format)?; + + // Which resource type is this? + let resource_type: IdResourceType = prefix.parse()?; + let mut cursor = IdStrCursor::new(id_part); + // Version + let version: IdSchemeVersion = cursor.next_str_exact(1)?.parse()?; + + Ok(Self { + version, + resource_type, + cursor, + }) + } +} + +pub struct IdEncoder { + buf: GenericArray::StrEncodedLen>, + pos: usize, + _marker: std::marker::PhantomData, +} + +impl IdEncoder { + pub(crate) fn new() -> IdEncoder { + use std::io::{IoSlice, Write}; + + static SEP_AND_VER: [u8; 2] = [ + ID_RESOURCE_SEPARATOR as u8, + IdSchemeVersion::latest().as_char() as u8, + ]; + + let buf = generic_array::GenericArray::generate(|_| b'0'); + + let mut encoder = Self { + buf, + pos: 0, + _marker: std::marker::PhantomData, + }; + + let pos = (&mut encoder.buf[..]) + .write_vectored(&[ + // prefix token + IoSlice::new(T::RESOURCE_TYPE.as_str().as_bytes()), + // Separator + ID Scheme Version + IoSlice::new(&SEP_AND_VER), + ]) + .expect("buf must fit"); + + encoder.pos = pos; + encoder + } + /// Appends a u64 value as a padded base62 encoded string to the underlying buffer + pub(crate) fn push_u64(&mut self, i: u64) { + let width = base62_encode_fixed_width_u64(i, &mut self.buf[self.pos..]); + self.pos += width; + debug_assert!(self.pos <= self.buf.len()); + } + + /// Appends a u128 value as a padded base62 encoded string to the underlying buffer + pub(crate) fn push_u128(&mut self, i: u128) { + let width = base62_encode_fixed_width_u128(i, &mut self.buf[self.pos..]); + self.pos += width; + debug_assert!(self.pos <= self.buf.len()); + } + + pub(crate) fn remaining_mut(&mut self) -> &mut [u8] { + &mut self.buf[self.pos..] + } + + pub(crate) fn advance(&mut self, cnt: usize) { + self.pos += cnt; + } + + pub fn as_str(&self) -> &str { + debug_assert!(self.pos <= self.buf.len()); + // SAFETY; the array was initialised with valid utf8 and we only write valid utf8 to the + // buffer. + unsafe { std::str::from_utf8_unchecked(&self.buf[..self.pos]) } + } +} + +// Helper macro to generate serialization primitives back and forth for id types with well-defined prefixes. +macro_rules! prefixed_ids { + ( + $(#[$m:meta])* + $type_vis:vis enum $typename:ident { + $( + $(#[$variant_meta:meta])* + $variant:ident($variant_prefix:literal), + )+ + } + ) => { + #[allow(clippy::all)] + $(#[$m])* + $type_vis enum $typename { + $( + $(#[$variant_meta])* + $variant, + )+ + } + + impl $typename { + #[doc = "The prefix string for this identifier"] + pub const fn as_str(&self) -> &'static str { + match self { + $( + $typename::$variant => $variant_prefix, + )+ + } + } + pub fn iter() -> ::core::slice::Iter<'static, $typename> { + static VARIANTS: &'static [$typename] = &[ + $( + $typename::$variant, + )+ + ]; + VARIANTS.iter() + } + } + + + #[automatically_derived] + impl ::core::str::FromStr for $typename { + type Err = IdDecodeError; + + fn from_str(value: &str) -> Result { + match value { + $( + $variant_prefix => Ok($typename::$variant), + )+ + _ => Err(IdDecodeError::UnrecognizedType(value.to_string())), + } + } + } + + }; +} + +/// Generate an identifier backed by ULID. +/// +/// This generates the Id struct and some associated methods: `new`, `from_parts`, `from_slice`, `from_bytes`, `to_bytes`, +/// plus implements `Default`, `Display`, `Debug`, `FromStr`, `JsonSchema` and `TimestampAwareId`. +/// +/// To use: +/// +/// ```ignore +/// ulid_backed_id!(MyResource); +/// ``` +/// +/// If the resource has an associated [`ResourceId`]: +/// +/// ```ignore +/// ulid_backed_id!(MyResource @with_resource_id); +/// ``` +/// +/// The difference between the two will be the usage of ResourceId for serde and string representations. +macro_rules! ulid_backed_id { + ($res_name:ident) => { + ulid_backed_id!(@common $res_name); + + paste::paste! { + impl ::std::fmt::Display for [< $res_name Id >] { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + ::std::fmt::Display::fmt(&self.0, f) + } + } + + impl ::std::str::FromStr for [< $res_name Id >] { + type Err = ulid::DecodeError; + + fn from_str(s: &str) -> Result { + Ok(Self(ulid::Ulid::from_string(s)?)) + } + } + } + }; + ($res_name:ident @with_resource_id) => { + ulid_backed_id!(@common $res_name); + + paste::paste! { + impl crate::identifiers::ResourceId for [< $res_name Id >] { + const RAW_BYTES_LEN: usize = ::std::mem::size_of::<::ulid::Ulid>(); + const RESOURCE_TYPE: crate::identifiers::IdResourceType = crate::identifiers::IdResourceType::$res_name; + + type StrEncodedLen = ::generic_array::ConstArrayLength< + // prefix + separator + version + suffix + { Self::RESOURCE_TYPE.as_str().len() + 2 + crate::base62_util::base62_max_length_for_type::() }, + >; + + fn push_to_encoder(&self, encoder: &mut crate::identifiers::IdEncoder) { + let raw: u128 = self.0.into(); + encoder.push_u128(raw); + } + } + + impl ::std::str::FromStr for [< $res_name Id >] { + type Err = crate::identifiers::IdDecodeError; + + fn from_str(input: &str) -> Result { + use crate::identifiers::ResourceId; + let mut decoder = crate::identifiers::IdDecoder::new(input)?; + // Ensure we are decoding the correct resource type + if decoder.resource_type != Self::RESOURCE_TYPE { + return Err(crate::identifiers::IdDecodeError::TypeMismatch); + } + + // ulid (u128) + let raw_ulid: u128 = decoder.cursor.decode_next()?; + if decoder.cursor.remaining() > 0 { + return Err(crate::identifiers::IdDecodeError::Length); + } + + Ok(Self::from(raw_ulid)) + } + } + + impl ::std::fmt::Display for [< $res_name Id >] { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + use crate::identifiers::ResourceId; + let mut encoder = crate::identifiers::IdEncoder::new(); + self.push_to_encoder(&mut encoder); + f.write_str(encoder.as_str()) + } + } + } + }; + (@common $res_name:ident) => { + paste::paste! { + #[derive( + PartialEq, + Eq, + Clone, + Copy, + Hash, + PartialOrd, + Ord, + serde_with::SerializeDisplay, + serde_with::DeserializeFromStr, + ::restate_encoding::BilrostAs + )] + #[bilrost_as([< $res_name IdMessage >])] + pub struct [< $res_name Id >](pub(crate) ::ulid::Ulid); + + impl [< $res_name Id >] { + pub fn new() -> Self { + Self(::ulid::Ulid::new()) + } + + pub const fn from_parts(timestamp_ms: u64, random: u128) -> Self { + Self(::ulid::Ulid::from_parts(timestamp_ms, random)) + } + + pub fn from_slice(b: &[u8]) -> Result { + let ulid = ::ulid::Ulid::from_bytes(b.try_into().map_err(|_| crate::identifiers::IdDecodeError::Length)?); + debug_assert!(!ulid.is_nil()); + Ok(Self(ulid)) + } + + pub fn from_bytes(bytes: [u8; 16]) -> Self { + let ulid = ::ulid::Ulid::from_bytes(bytes); + debug_assert!(!ulid.is_nil()); + Self(ulid) + } + + pub fn to_bytes(&self) -> [u8; 16] { + self.0.to_bytes() + } + } + + impl Default for [< $res_name Id >] { + fn default() -> Self { + Self::new() + } + } + + impl crate::identifiers::TimestampAwareId for [< $res_name Id >] { + fn timestamp_ms(&self) -> u64 { + self.0.timestamp_ms() + } + } + + impl ::std::fmt::Debug for [< $res_name Id >] { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + // use the same formatting for debug and display to show a consistent representation + ::std::fmt::Display::fmt(self, f) + } + } + + impl From for [< $res_name Id >] { + fn from(value: u128) -> Self { + Self(::ulid::Ulid::from(value)) + } + } + + #[cfg(feature = "schemars")] + impl schemars::JsonSchema for [< $res_name Id >] { + fn schema_name() -> String { + ::schema_name() + } + + fn json_schema(g: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema { + ::json_schema(g) + } + } + + #[derive(::bilrost::Message)] + struct [< $res_name IdMessage >](::restate_encoding::U128); + + impl From<&[< $res_name Id >]> for [< $res_name IdMessage >] { + fn from(value: &[< $res_name Id >]) -> Self { + Self(u128::from(value.0).into()) + } + } + + impl From<[< $res_name IdMessage >]> for [< $res_name Id >] { + fn from(value: [< $res_name IdMessage >]) -> Self { + Self(u128::from(value.0).into()) + } + } + } + }; +} + +// to allow the macro to be used even if the macro is defined at the end of the file +use {prefixed_ids, ulid_backed_id}; diff --git a/crates/lib/ty/src/identifiers/awakeable.rs b/crates/lib/ty/src/identifiers/awakeable.rs new file mode 100644 index 0000000000..bf1d3b053a --- /dev/null +++ b/crates/lib/ty/src/identifiers/awakeable.rs @@ -0,0 +1,140 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +use base64::Engine as _; + +use super::invocation::EncodedInvocationId; +use super::{IdDecodeError, IdDecoder, IdEncoder, IdResourceType, InvocationId, ResourceId}; +use crate::journal::EntryIndex; + +#[derive( + Debug, Clone, PartialEq, Eq, serde_with::SerializeDisplay, serde_with::DeserializeFromStr, +)] +pub struct AwakeableIdentifier { + invocation_id: InvocationId, + entry_index: EntryIndex, +} + +impl ResourceId for AwakeableIdentifier { + const RAW_BYTES_LEN: usize = InvocationId::RAW_BYTES_LEN + size_of::(); + const RESOURCE_TYPE: IdResourceType = IdResourceType::Awakeable; + + type StrEncodedLen = ::generic_array::ConstArrayLength< + // prefix + separator + version + suffix (38 chars) + { + Self::RESOURCE_TYPE.as_str().len() + + 2 + + base64::encoded_len( + size_of::() + size_of::(), + false, + ) + .expect("awakeable id is far from usize limit") + }, + >; + + /// We use a custom strategy for awakeable identifiers since they need to be encoded as base64 + /// for wider language support. + fn push_to_encoder(&self, encoder: &mut IdEncoder) { + let mut input_buf = [0u8; Self::RAW_BYTES_LEN]; + let pos = self + .invocation_id + .encode_raw_bytes(&mut input_buf[..InvocationId::RAW_BYTES_LEN]); + input_buf[pos..].copy_from_slice(&self.entry_index.to_be_bytes()); + + let written = restate_base64_util::URL_SAFE + .encode_slice(input_buf, encoder.remaining_mut()) + .expect("base64 encoding succeeds for system-generated ids"); + encoder.advance(written); + } +} + +impl AwakeableIdentifier { + pub fn new(invocation_id: InvocationId, entry_index: EntryIndex) -> Self { + Self { + invocation_id, + entry_index, + } + } + + pub fn into_inner(self) -> (InvocationId, EntryIndex) { + (self.invocation_id, self.entry_index) + } +} + +impl std::str::FromStr for AwakeableIdentifier { + type Err = IdDecodeError; + + fn from_str(input: &str) -> Result { + let decoder = IdDecoder::new(input)?; + // Ensure we are decoding the right type + if decoder.resource_type != Self::RESOURCE_TYPE { + return Err(IdDecodeError::TypeMismatch); + } + let remaining = decoder.cursor.take_remaining()?; + + let buffer = restate_base64_util::URL_SAFE + .decode(remaining) + .map_err(|_| IdDecodeError::Codec)?; + + if buffer.len() != size_of::() + size_of::() { + return Err(IdDecodeError::Length); + } + + let invocation_id: InvocationId = + InvocationId::from_slice(&buffer[..size_of::()])?; + let entry_index = EntryIndex::from_be_bytes( + buffer[size_of::()..] + .try_into() + // Unwrap is safe because we check the size above. + .unwrap(), + ); + + Ok(Self { + invocation_id, + entry_index, + }) + } +} + +impl std::fmt::Display for AwakeableIdentifier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // encode the id such that it is possible to do a string prefix search for a + // partition key using the first 17 characters. + let mut encoder = IdEncoder::new(); + self.push_to_encoder(&mut encoder); + f.write_str(encoder.as_str()) + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use super::*; + + #[test] + fn roundtrip_awakeable_id() { + let expected_invocation_id = InvocationId::mock_random(); + let expected_entry_index = 2_u32; + + let input_str = AwakeableIdentifier { + invocation_id: expected_invocation_id, + entry_index: expected_entry_index, + } + .to_string(); + dbg!(&input_str); + + let actual = AwakeableIdentifier::from_str(&input_str).unwrap(); + let (actual_invocation_id, actual_entry_index) = actual.into_inner(); + + assert_eq!(expected_invocation_id, actual_invocation_id); + assert_eq!(expected_entry_index, actual_entry_index); + } +} diff --git a/crates/lib/ty/src/identifiers/deployment.rs b/crates/lib/ty/src/identifiers/deployment.rs new file mode 100644 index 0000000000..b12d2c3ab5 --- /dev/null +++ b/crates/lib/ty/src/identifiers/deployment.rs @@ -0,0 +1,54 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +use super::ulid_backed_id; + +ulid_backed_id!(Deployment @with_resource_id); + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use crate::identifiers::{DeploymentId, IdDecodeError, ResourceId, TimestampAwareId}; + + #[test] + fn test_deployment_id_from_str() { + let deployment_id = "dp_11nGQpCRmau6ypL82KH2TnP"; + let from_str_result = DeploymentId::from_str(deployment_id); + assert!(from_str_result.is_ok()); + assert_eq!( + from_str_result.unwrap().to_string(), + deployment_id.to_string() + ); + + let deployment_id = "dp_11nGQpCRmau6ypL82KH2TnP123456"; + let from_str_result = DeploymentId::from_str(deployment_id); + assert!(from_str_result.is_err()); + assert_eq!(from_str_result.unwrap_err(), IdDecodeError::Length); + } + + #[test] + fn test_deployment_id_format() { + let a = DeploymentId::new(); + assert!(a.timestamp_ms() > 0); + let a_str = a.to_string(); + assert!(a_str.starts_with("dp_")); + assert_eq!(a_str.len(), DeploymentId::str_encoded_len(),); + assert_eq!(26, a_str.len()); + } + + #[test] + fn test_deployment_roundtrip() { + let a = DeploymentId::new(); + let b: DeploymentId = a.to_string().parse().unwrap(); + assert_eq!(a, b); + assert_eq!(a.to_string(), b.to_string()); + } +} diff --git a/crates/lib/ty/src/identifiers/idempotency.rs b/crates/lib/ty/src/identifiers/idempotency.rs new file mode 100644 index 0000000000..86db9bd710 --- /dev/null +++ b/crates/lib/ty/src/identifiers/idempotency.rs @@ -0,0 +1,77 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +use bytestring::ByteString; + +use super::InvocationId; +use crate::invocation::InvocationTarget; +use crate::partitions::{PartitionKey, WithPartitionKey, deterministic_partition_key}; + +#[derive(Eq, Hash, PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct IdempotencyId { + /// Identifies the invoked service + pub service_name: ByteString, + /// Service key, if any + pub service_key: Option, + /// Identifies the invoked service handler + pub service_handler: ByteString, + /// The user supplied idempotency_key + pub idempotency_key: ByteString, + + pub(super) partition_key: PartitionKey, +} + +impl IdempotencyId { + pub fn new( + service_name: ByteString, + service_key: Option, + service_handler: ByteString, + idempotency_key: ByteString, + ) -> Self { + // The ownership model for idempotent invocations is the following: + // + // * For services without key, the partition key is the hash(idempotency key). + // This makes sure that for a given idempotency key and its scope, we always land in the same partition. + // * For services with key, the partition key is the hash(service key), this due to the virtual object locking requirement. + let partition_key = deterministic_partition_key( + service_key.as_ref().map(|bs| bs.as_ref()), + Some(&idempotency_key), + ) + .expect("A deterministic partition key can always be generated for idempotency id"); + + Self { + service_name, + service_key, + service_handler, + idempotency_key, + partition_key, + } + } + + pub fn combine( + invocation_id: InvocationId, + invocation_target: &InvocationTarget, + idempotency_key: ByteString, + ) -> Self { + IdempotencyId { + service_name: invocation_target.service_name().clone(), + service_key: invocation_target.key().cloned(), + service_handler: invocation_target.handler_name().clone(), + idempotency_key, + partition_key: invocation_id.partition_key(), + } + } +} + +impl WithPartitionKey for IdempotencyId { + fn partition_key(&self) -> PartitionKey { + self.partition_key + } +} diff --git a/crates/lib/ty/src/identifiers/invocation.rs b/crates/lib/ty/src/identifiers/invocation.rs new file mode 100644 index 0000000000..4eb7ac389b --- /dev/null +++ b/crates/lib/ty/src/identifiers/invocation.rs @@ -0,0 +1,494 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +use std::fmt::{self, Display, Formatter}; +use std::hash::Hash; +use std::mem::size_of; +use std::str::FromStr; + +use bytes::Bytes; +use rand::RngCore; +use sha2::{Digest, Sha256}; +use ulid::Ulid; + +use crate::base62_util::{base62_encode_fixed_width_u128, base62_max_length_for_type}; +use crate::invocation::{InvocationTarget, InvocationTargetType, WorkflowHandlerType}; +use crate::partitions::{PartitionKey, WithPartitionKey, deterministic_partition_key}; + +use super::{IdDecodeError, IdDecoder, IdEncoder, IdResourceType, IdSchemeVersion, ResourceId}; + +/// InvocationId is a unique identifier of the invocation, +/// including enough routing information for the network service +/// to route requests to the correct partition processors. +#[derive( + Eq, + Hash, + PartialEq, + Clone, + Copy, + Debug, + PartialOrd, + Ord, + serde_with::SerializeDisplay, + serde_with::DeserializeFromStr, + Default, +)] +pub struct InvocationId { + /// Partition key of the called service + partition_key: PartitionKey, + /// Uniquely identifies this invocation instance + inner: InvocationUuid, +} + +restate_encoding::bilrost_as_display_from_str!(InvocationId); + +pub trait WithInvocationId { + /// Returns the invocation id + fn invocation_id(&self) -> InvocationId; +} + +pub type EncodedInvocationId = [u8; InvocationId::RAW_BYTES_LEN]; + +impl InvocationId { + pub fn generate(invocation_target: &InvocationTarget, idempotency_key: Option<&str>) -> Self { + // --- Partition key generation + let partition_key = + // Either try to generate the deterministic partition key, if possible + deterministic_partition_key( + invocation_target.key().map(|bs| bs.as_ref()), + idempotency_key, + ) + // If no deterministic partition key can be generated, just pick a random number + .unwrap_or_else(|| rand::rng().next_u64()); + + // --- Invocation UUID generation + InvocationId::from_parts( + partition_key, + InvocationUuid::generate(invocation_target, idempotency_key), + ) + } + + #[inline] + pub const fn from_parts(partition_key: PartitionKey, invocation_uuid: InvocationUuid) -> Self { + Self { + partition_key, + inner: invocation_uuid, + } + } + + pub fn from_slice(b: &[u8]) -> Result { + Self::try_from(b) + } + + pub fn invocation_uuid(&self) -> InvocationUuid { + self.inner + } + + pub fn to_bytes(&self) -> EncodedInvocationId { + let mut buf = EncodedInvocationId::default(); + self.encode_raw_bytes(&mut buf); + buf + } + + /// Returns the number of bytes written to the buffer + /// + /// The buffer must be at least `InvocationId::RAW_BYTES_LEN` bytes long. + pub fn encode_raw_bytes(&self, buf: &mut [u8]) -> usize { + let pk = self.partition_key.to_be_bytes(); + let uuid = self.inner.to_bytes(); + + buf[..size_of::()].copy_from_slice(&pk); + buf[size_of::()..].copy_from_slice(&uuid); + pk.len() + uuid.len() + } + + /// Generate random seed to feed RNG in SDKs. + pub fn to_random_seed(&self) -> u64 { + use std::hash::{DefaultHasher, Hash, Hasher}; + + let mut hasher = DefaultHasher::new(); + self.to_bytes().hash(&mut hasher); + hasher.finish() + } +} + +impl From for Bytes { + fn from(value: InvocationId) -> Self { + Bytes::copy_from_slice(&value.to_bytes()) + } +} + +impl ResourceId for InvocationId { + const RAW_BYTES_LEN: usize = size_of::() + InvocationUuid::RAW_BYTES_LEN; + const RESOURCE_TYPE: IdResourceType = IdResourceType::Invocation; + + type StrEncodedLen = generic_array::ConstArrayLength< + // prefix + separator + version + suffix + { + Self::RESOURCE_TYPE.as_str().len() + // separator + version + + 2 + + base62_max_length_for_type::() + + base62_max_length_for_type::() + }, + >; + + fn push_to_encoder(&self, encoder: &mut IdEncoder) { + encoder.push_u64(self.partition_key); + encoder.push_u128(self.inner.0); + } +} + +impl TryFrom<&[u8]> for InvocationId { + type Error = IdDecodeError; + + fn try_from(encoded_id: &[u8]) -> Result { + if encoded_id.len() < size_of::() { + return Err(IdDecodeError::Length); + } + let buf: [u8; InvocationId::RAW_BYTES_LEN] = + encoded_id.try_into().map_err(|_| IdDecodeError::Length)?; + Ok(buf.into()) + } +} + +impl From for InvocationId { + fn from(encoded_id: EncodedInvocationId) -> Self { + // This optimizes nicely by the compiler. We unwrap because array length is guaranteed to + // fit both services according to EncodedInvocatioId type definition. + let partition_key_bytes = encoded_id[..size_of::()].try_into().unwrap(); + let partition_key = PartitionKey::from_be_bytes(partition_key_bytes); + + let offset = size_of::(); + let inner_id_bytes = encoded_id[offset..offset + InvocationUuid::RAW_BYTES_LEN] + .try_into() + .unwrap(); + let inner = InvocationUuid::from_bytes(inner_id_bytes); + + Self { + partition_key, + inner, + } + } +} + +impl WithPartitionKey for InvocationId { + fn partition_key(&self) -> PartitionKey { + self.partition_key + } +} + +impl WithPartitionKey for T { + fn partition_key(&self) -> PartitionKey { + self.invocation_id().partition_key + } +} + +impl Display for InvocationId { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + // encode the id such that it is possible to do a string prefix search for a + // partition key using the first 17 characters. + let mut encoder = IdEncoder::new(); + self.push_to_encoder(&mut encoder); + f.write_str(encoder.as_str()) + } +} + +impl FromStr for InvocationId { + type Err = IdDecodeError; + + fn from_str(input: &str) -> Result { + let mut decoder = IdDecoder::new(input)?; + // Ensure we are decoding the right type + if decoder.resource_type != Self::RESOURCE_TYPE { + return Err(IdDecodeError::TypeMismatch); + } + + // partition key (u64) + let partition_key: PartitionKey = decoder.cursor.decode_next()?; + + // ulid (u128) + let raw_ulid: u128 = decoder.cursor.decode_next()?; + let inner = InvocationUuid::from(raw_ulid); + Ok(Self { + partition_key, + inner, + }) + } +} + +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for InvocationId { + fn schema_name() -> String { + ::schema_name() + } + + fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::schema::Schema { + ::json_schema(g) + } +} + +/// Discriminator for invocation instances +#[derive( + Eq, + Hash, + PartialEq, + Clone, + Copy, + Debug, + Default, + Ord, + PartialOrd, + serde_with::SerializeDisplay, + serde_with::DeserializeFromStr, +)] +pub struct InvocationUuid(u128); + +impl InvocationUuid { + pub const RAW_BYTES_LEN: usize = size_of::(); + + pub fn from_slice(b: &[u8]) -> Result { + Ok(Self::from_u128(u128::from_be_bytes( + b.try_into().map_err(|_| IdDecodeError::Length)?, + ))) + } + + pub const fn from_u128(id: u128) -> Self { + debug_assert!(id != 0); + Self(id) + } + + pub const fn from_bytes(b: [u8; Self::RAW_BYTES_LEN]) -> Self { + Self::from_u128(u128::from_be_bytes(b)) + } + + pub const fn to_bytes(&self) -> [u8; Self::RAW_BYTES_LEN] { + self.0.to_be_bytes() + } + + pub fn generate(invocation_target: &InvocationTarget, idempotency_key: Option<&str>) -> Self { + const HASH_SEPARATOR: u8 = 0x2c; + + // --- Rules for deterministic ID + // * If the target IS a workflow run, use workflow name + key + // * If the target IS an idempotent request, use the idempotency scope + key + // * If the target IS NEITHER an idempotent request or a workflow run, then just generate a random ulid + + let id = match (idempotency_key, invocation_target.invocation_target_ty()) { + (_, InvocationTargetType::Workflow(WorkflowHandlerType::Workflow)) => { + // Workflow run + let mut hasher = Sha256::new(); + hasher.update(b"wf"); + hasher.update([HASH_SEPARATOR]); + hasher.update(invocation_target.service_name()); + hasher.update([HASH_SEPARATOR]); + hasher.update( + invocation_target + .key() + .expect("Workflow targets MUST contain a key"), + ); + let result = hasher.finalize(); + let (int_bytes, _) = result.split_at(size_of::()); + u128::from_be_bytes( + int_bytes + .try_into() + .expect("Conversion after split can't fail"), + ) + } + (Some(idempotency_key), _) => { + // Invocations with Idempotency key + let mut hasher = Sha256::new(); + hasher.update(b"ik"); + hasher.update([HASH_SEPARATOR]); + hasher.update(invocation_target.service_name()); + if let Some(key) = invocation_target.key() { + hasher.update([HASH_SEPARATOR]); + hasher.update(key); + } + hasher.update([HASH_SEPARATOR]); + hasher.update(invocation_target.handler_name()); + hasher.update([HASH_SEPARATOR]); + hasher.update(idempotency_key); + let result = hasher.finalize(); + let (int_bytes, _) = result.split_at(size_of::()); + u128::from_be_bytes( + int_bytes + .try_into() + .expect("Conversion after split can't fail"), + ) + } + (_, _) => { + // Regular invocation + Ulid::new().into() + } + }; + + debug_assert!(id != 0); + InvocationUuid(id) + } +} + +impl Display for InvocationUuid { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + let mut buf = [b'0'; base62_max_length_for_type::()]; + let raw: u128 = self.0; + let written = base62_encode_fixed_width_u128(raw, &mut buf); + // SAFETY; the array was initialised with valid utf8 and encode_alternative_bytes only writes utf8 + f.write_str(unsafe { std::str::from_utf8_unchecked(&buf[0..written]) }) + } +} + +impl FromStr for InvocationUuid { + type Err = IdDecodeError; + + fn from_str(input: &str) -> Result { + let mut decoder = IdDecoder::new_ignore_prefix( + IdSchemeVersion::latest(), + IdResourceType::Invocation, + input, + )?; + + // ulid (u128) + let raw_ulid: u128 = decoder.cursor.decode_next()?; + Ok(Self::from(raw_ulid)) + } +} + +impl From for Bytes { + fn from(value: InvocationUuid) -> Self { + Bytes::copy_from_slice(&value.to_bytes()) + } +} + +impl From for InvocationUuid { + fn from(value: u128) -> Self { + Self(value) + } +} + +impl From for u128 { + fn from(value: InvocationUuid) -> Self { + value.0 + } +} + +impl From for opentelemetry::trace::TraceId { + fn from(value: InvocationUuid) -> Self { + Self::from_bytes(value.to_bytes()) + } +} + +impl From for opentelemetry::trace::SpanId { + fn from(value: InvocationUuid) -> Self { + let raw_be_bytes = value.to_bytes(); + let last8: [u8; 8] = std::convert::TryInto::try_into(&raw_be_bytes[8..16]).unwrap(); + Self::from_bytes(last8) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::invocation::VirtualObjectHandlerType; + use rand::distr::{Alphanumeric, SampleString}; + + #[test] + fn service_id_and_invocation_id_partition_key_should_match() { + let invocation_target = InvocationTarget::virtual_object( + "MyService", + "MyKey", + "MyMethod", + VirtualObjectHandlerType::Exclusive, + ); + let invocation_id = InvocationId::mock_generate(&invocation_target); + + assert_eq!( + invocation_id.partition_key(), + invocation_target + .as_keyed_service_id() + .unwrap() + .partition_key() + ); + } + + #[test] + fn roundtrip_invocation_id() { + let target = InvocationTarget::mock_service(); + let expected = InvocationId::from_parts(92, InvocationUuid::mock_generate(&target)); + assert_eq!( + expected, + InvocationId::from_slice(&expected.to_bytes()).unwrap() + ) + } + + #[test] + fn invocation_codec_capacity() { + assert_eq!(38, InvocationId::str_encoded_len()) + } + + #[test] + fn roundtrip_invocation_id_str() { + // torture test (poor's man property check test) + for _ in 0..100000 { + let expected = InvocationId::mock_random(); + let serialized = expected.to_string(); + assert_eq!(38, serialized.len(), "{serialized} => {expected:?}"); + let parsed = InvocationId::from_str(&serialized).unwrap(); + assert_eq!(expected, parsed, "serialized: {serialized}"); + } + } + + #[test] + fn bad_invocation_id_str() { + let bad_strs = [ + ("", IdDecodeError::Length), + ( + "mxvgUOrwIb8cYrGPHkAAKSKY3O!6IEy_g", + IdDecodeError::UnrecognizedType("mxvgUOrwIb8cYrGPHkAAKSKY3O!6IEy".to_string()), + ), + ("mxvgUOrwIb8", IdDecodeError::Format), + ( + "inv_ub23411ba", // wrong version + IdDecodeError::Version, + ), + ("inv_1b234d1ba", IdDecodeError::Length), + ]; + + for (bad, error) in bad_strs { + assert_eq!( + error, + InvocationId::from_str(bad).unwrap_err(), + "invocation id: '{bad}' fails with {error}" + ) + } + } + + #[test] + fn deterministic_invocation_id_for_idempotent_request() { + let invocation_target = InvocationTarget::mock_service(); + let idempotent_key = Alphanumeric.sample_string(&mut rand::rng(), 16); + + assert_eq!( + InvocationId::generate(&invocation_target, Some(&idempotent_key)), + InvocationId::generate(&invocation_target, Some(&idempotent_key)) + ); + } + + #[test] + fn deterministic_invocation_id_for_workflow_request() { + let invocation_target = InvocationTarget::mock_workflow(); + + assert_eq!( + InvocationId::mock_generate(&invocation_target), + InvocationId::mock_generate(&invocation_target) + ); + } +} diff --git a/crates/lib/ty/src/identifiers/mocks.rs b/crates/lib/ty/src/identifiers/mocks.rs new file mode 100644 index 0000000000..c5b5d4a5b8 --- /dev/null +++ b/crates/lib/ty/src/identifiers/mocks.rs @@ -0,0 +1,67 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +use bytestring::ByteString; +use rand::Rng; +use rand::distr::{Alphanumeric, SampleString}; + +use super::invocation::InvocationUuid; +use super::{IdempotencyId, InvocationId}; +use crate::invocation::InvocationTarget; +use crate::partitions::PartitionKey; + +impl InvocationUuid { + pub fn mock_generate(invocation_target: &InvocationTarget) -> Self { + InvocationUuid::generate(invocation_target, None) + } + + pub fn mock_random() -> Self { + InvocationUuid::mock_generate(&InvocationTarget::mock_service()) + } +} + +impl InvocationId { + pub fn mock_generate(invocation_target: &InvocationTarget) -> Self { + InvocationId::generate(invocation_target, None) + } + + pub fn mock_random() -> Self { + Self::from_parts( + rand::rng().sample::(rand::distr::StandardUniform), + InvocationUuid::mock_random(), + ) + } +} + +impl IdempotencyId { + pub const fn unkeyed( + partition_key: PartitionKey, + service_name: &'static str, + service_handler: &'static str, + idempotency_key: &'static str, + ) -> Self { + Self { + service_name: ByteString::from_static(service_name), + service_key: None, + service_handler: ByteString::from_static(service_handler), + idempotency_key: ByteString::from_static(idempotency_key), + partition_key, + } + } + + pub fn mock_random() -> Self { + Self::new( + Alphanumeric.sample_string(&mut rand::rng(), 8).into(), + Some(Alphanumeric.sample_string(&mut rand::rng(), 16).into()), + Alphanumeric.sample_string(&mut rand::rng(), 8).into(), + Alphanumeric.sample_string(&mut rand::rng(), 8).into(), + ) + } +} diff --git a/crates/lib/ty/src/identifiers/partition_processor_rpc_request.rs b/crates/lib/ty/src/identifiers/partition_processor_rpc_request.rs new file mode 100644 index 0000000000..7475a976fc --- /dev/null +++ b/crates/lib/ty/src/identifiers/partition_processor_rpc_request.rs @@ -0,0 +1,13 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +use super::ulid_backed_id; + +ulid_backed_id!(PartitionProcessorRpcRequest); diff --git a/crates/lib/ty/src/identifiers/signal.rs b/crates/lib/ty/src/identifiers/signal.rs new file mode 100644 index 0000000000..e23f919453 --- /dev/null +++ b/crates/lib/ty/src/identifiers/signal.rs @@ -0,0 +1,144 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +use base64::Engine as _; + +use super::invocation::{EncodedInvocationId, WithInvocationId}; +use super::{IdDecodeError, IdDecoder, IdEncoder, IdResourceType, InvocationId, ResourceId}; +use crate::journal::EntryIndex; + +#[derive( + Debug, Clone, PartialEq, Eq, serde_with::SerializeDisplay, serde_with::DeserializeFromStr, +)] +pub struct ExternalSignalIdentifier { + invocation_id: InvocationId, + signal_index: u32, +} + +impl ResourceId for ExternalSignalIdentifier { + const RAW_BYTES_LEN: usize = size_of::() + size_of::(); + const RESOURCE_TYPE: IdResourceType = IdResourceType::Signal; + + type StrEncodedLen = ::generic_array::ConstArrayLength< + // prefix + separator + version + suffix (38 chars) + { + Self::RESOURCE_TYPE.as_str().len() + + 2 + + base64::encoded_len( + size_of::() + size_of::(), + false, + ) + .expect("awakeable id is far from usize limit") + }, + >; + + /// We use a custom strategy for awakeable identifiers since they need to be encoded as base64 + /// for wider language support. + fn push_to_encoder(&self, encoder: &mut IdEncoder) { + let mut input_buf = [0u8; Self::RAW_BYTES_LEN]; + let pos = self + .invocation_id + .encode_raw_bytes(&mut input_buf[..InvocationId::RAW_BYTES_LEN]); + input_buf[pos..].copy_from_slice(&self.signal_index.to_be_bytes()); + + let written = restate_base64_util::URL_SAFE + .encode_slice(input_buf, encoder.remaining_mut()) + .expect("base64 encoding succeeds for system-generated ids"); + encoder.advance(written); + } +} + +impl ExternalSignalIdentifier { + pub fn new(invocation_id: InvocationId, signal_index: u32) -> Self { + Self { + invocation_id, + signal_index, + } + } + + pub fn into_inner(self) -> (InvocationId, u32) { + (self.invocation_id, self.signal_index) + } +} + +impl std::str::FromStr for ExternalSignalIdentifier { + type Err = IdDecodeError; + + fn from_str(input: &str) -> Result { + let decoder = IdDecoder::new(input)?; + // Ensure we are decoding the right type + if decoder.resource_type != Self::RESOURCE_TYPE { + return Err(IdDecodeError::TypeMismatch); + } + let remaining = decoder.cursor.take_remaining()?; + + let buffer = restate_base64_util::URL_SAFE + .decode(remaining) + .map_err(|_| IdDecodeError::Codec)?; + + if buffer.len() != size_of::() + size_of::() { + return Err(IdDecodeError::Length); + } + + let invocation_id: InvocationId = + InvocationId::from_slice(&buffer[..size_of::()])?; + let signal_index = u32::from_be_bytes( + buffer[size_of::()..] + .try_into() + // Unwrap is safe because we check the size above. + .unwrap(), + ); + + Ok(Self { + invocation_id, + signal_index, + }) + } +} + +impl std::fmt::Display for ExternalSignalIdentifier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut encoder = IdEncoder::new(); + self.push_to_encoder(&mut encoder); + f.write_str(encoder.as_str()) + } +} + +impl WithInvocationId for ExternalSignalIdentifier { + fn invocation_id(&self) -> InvocationId { + self.invocation_id + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use super::*; + + #[test] + fn roundtrip_signal_id() { + let expected_invocation_id = InvocationId::mock_random(); + let expected_signal_index = 2_u32; + + let input_str = ExternalSignalIdentifier { + invocation_id: expected_invocation_id, + signal_index: expected_signal_index, + } + .to_string(); + dbg!(&input_str); + + let actual = ExternalSignalIdentifier::from_str(&input_str).unwrap(); + let (actual_invocation_id, actual_signal_id) = actual.into_inner(); + + assert_eq!(expected_invocation_id, actual_invocation_id); + assert_eq!(expected_signal_index, actual_signal_id); + } +} diff --git a/crates/lib/ty/src/identifiers/snapshot.rs b/crates/lib/ty/src/identifiers/snapshot.rs new file mode 100644 index 0000000000..82c95b286d --- /dev/null +++ b/crates/lib/ty/src/identifiers/snapshot.rs @@ -0,0 +1,13 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +use super::ulid_backed_id; + +ulid_backed_id!(Snapshot @with_resource_id); diff --git a/crates/lib/ty/src/identifiers/subscription.rs b/crates/lib/ty/src/identifiers/subscription.rs new file mode 100644 index 0000000000..d0e6e7b7a3 --- /dev/null +++ b/crates/lib/ty/src/identifiers/subscription.rs @@ -0,0 +1,36 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +use super::ulid_backed_id; + +ulid_backed_id!(Subscription @with_resource_id); + +#[cfg(test)] +mod tests { + use crate::identifiers::TimestampAwareId; + + use super::*; + + #[test] + fn test_subscription_id_format() { + let a = SubscriptionId::new(); + assert!(a.timestamp_ms() > 0); + let a_str = a.to_string(); + assert!(a_str.starts_with("sub_")); + } + + #[test] + fn test_subscription_roundtrip() { + let a = SubscriptionId::new(); + let b: SubscriptionId = a.to_string().parse().unwrap(); + assert_eq!(a, b); + assert_eq!(a.to_string(), b.to_string()); + } +} diff --git a/crates/lib/ty/src/invocation.rs b/crates/lib/ty/src/invocation.rs new file mode 100644 index 0000000000..6fbecc1158 --- /dev/null +++ b/crates/lib/ty/src/invocation.rs @@ -0,0 +1,394 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +//! This module contains all the core types representing a service invocation. + +use std::fmt; +use std::hash::Hash; + +use bytestring::ByteString; + +use crate::partitions::{PartitionKey, WithPartitionKey, partitioner}; + +/// Incremental id defining the service revision. +pub type ServiceRevision = u32; + +/// The invocation epoch represents the restarts count of the invocation, as seen from the Partition processor. +pub type InvocationEpoch = u32; + +/// Id of a keyed service instance. +/// +/// Services are isolated by key. This means that there cannot be two concurrent +/// invocations for the same service instance (service name, key). +#[derive( + Eq, Hash, PartialEq, PartialOrd, Ord, Clone, Debug, serde::Serialize, serde::Deserialize, +)] +pub struct ServiceId { + // TODO rename this to KeyedServiceId. This type can be used only by keyed service types (virtual objects and workflows) + /// Identifies the grpc service + pub service_name: ByteString, + /// Identifies the service instance for the given service name + pub key: ByteString, + + partition_key: PartitionKey, +} + +impl ServiceId { + pub fn new(service_name: impl Into, key: impl Into) -> Self { + let key = key.into(); + let partition_key = partitioner::HashPartitioner::compute_partition_key(&key); + Self::with_partition_key(partition_key, service_name, key) + } + + /// # Important + /// The `partition_key` must be hash of the `key` computed via [`HashPartitioner`]. + pub fn with_partition_key( + partition_key: PartitionKey, + service_name: impl Into, + key: impl Into, + ) -> Self { + Self::from_parts(partition_key, service_name.into(), key.into()) + } + + /// # Important + /// The `partition_key` must be hash of the `key` computed via [`HashPartitioner`]. + pub const fn from_parts( + partition_key: PartitionKey, + service_name: ByteString, + key: ByteString, + ) -> Self { + Self { + service_name, + key, + partition_key, + } + } +} + +impl WithPartitionKey for ServiceId { + fn partition_key(&self) -> PartitionKey { + self.partition_key + } +} + +impl fmt::Display for ServiceId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}/{}", self.service_name, self.key) + } +} + +#[derive(Eq, Hash, PartialEq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum ServiceType { + Service, + VirtualObject, + Workflow, +} + +impl ServiceType { + pub fn is_keyed(&self) -> bool { + matches!(self, ServiceType::VirtualObject | ServiceType::Workflow) + } + + pub fn has_state(&self) -> bool { + self.is_keyed() + } +} + +impl fmt::Display for ServiceType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(self, f) + } +} + +#[derive( + Eq, Hash, PartialEq, Clone, Copy, Debug, Default, serde::Serialize, serde::Deserialize, +)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum VirtualObjectHandlerType { + #[default] + Exclusive, + Shared, +} + +impl fmt::Display for VirtualObjectHandlerType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(self, f) + } +} + +#[derive( + Eq, Hash, PartialEq, Clone, Copy, Debug, Default, serde::Serialize, serde::Deserialize, +)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum WorkflowHandlerType { + #[default] + Workflow, + Shared, +} + +impl fmt::Display for WorkflowHandlerType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(self, f) + } +} + +#[derive(Eq, Hash, PartialEq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum InvocationTargetType { + Service, + VirtualObject(VirtualObjectHandlerType), + Workflow(WorkflowHandlerType), +} + +impl InvocationTargetType { + pub fn is_keyed(&self) -> bool { + matches!( + self, + InvocationTargetType::VirtualObject(_) | InvocationTargetType::Workflow(_) + ) + } + + pub fn can_read_state(&self) -> bool { + self.is_keyed() + } + + pub fn can_write_state(&self) -> bool { + matches!( + self, + InvocationTargetType::VirtualObject(VirtualObjectHandlerType::Exclusive) + | InvocationTargetType::Workflow(WorkflowHandlerType::Workflow) + ) + } +} + +impl fmt::Display for InvocationTargetType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(self, f) + } +} + +impl From for ServiceType { + fn from(value: InvocationTargetType) -> Self { + match value { + InvocationTargetType::Service => ServiceType::Service, + InvocationTargetType::VirtualObject(_) => ServiceType::VirtualObject, + InvocationTargetType::Workflow(_) => ServiceType::Workflow, + } + } +} + +#[derive(Debug, derive_more::Display)] +/// Short is used to create a short [`Display`] implementation +/// for InvocationTarget. it's mainly use for tracing purposes +pub enum Short<'a> { + #[display("{name}/{{key}}/{handler}")] + Keyed { name: &'a str, handler: &'a str }, + #[display("{name}/{handler}")] + UnKeyed { name: &'a str, handler: &'a str }, +} + +#[derive(Eq, Hash, PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] +pub enum InvocationTarget { + Service { + name: ByteString, + handler: ByteString, + }, + VirtualObject { + name: ByteString, + key: ByteString, + handler: ByteString, + handler_ty: VirtualObjectHandlerType, + }, + Workflow { + name: ByteString, + key: ByteString, + handler: ByteString, + handler_ty: WorkflowHandlerType, + }, +} + +impl InvocationTarget { + pub fn service(name: impl Into, handler: impl Into) -> Self { + Self::Service { + name: name.into(), + handler: handler.into(), + } + } + + pub fn short(&self) -> Short<'_> { + match self { + Self::Service { name, handler } => Short::UnKeyed { name, handler }, + Self::VirtualObject { name, handler, .. } | Self::Workflow { name, handler, .. } => { + Short::Keyed { name, handler } + } + } + } + + pub fn virtual_object( + name: impl Into, + key: impl Into, + handler: impl Into, + handler_ty: VirtualObjectHandlerType, + ) -> Self { + Self::VirtualObject { + name: name.into(), + key: key.into(), + handler: handler.into(), + handler_ty, + } + } + + pub fn workflow( + name: impl Into, + key: impl Into, + handler: impl Into, + handler_ty: WorkflowHandlerType, + ) -> Self { + Self::Workflow { + name: name.into(), + key: key.into(), + handler: handler.into(), + handler_ty, + } + } + + pub fn service_name(&self) -> &ByteString { + match self { + InvocationTarget::Service { name, .. } => name, + InvocationTarget::VirtualObject { name, .. } => name, + InvocationTarget::Workflow { name, .. } => name, + } + } + + pub fn key(&self) -> Option<&ByteString> { + match self { + InvocationTarget::Service { .. } => None, + InvocationTarget::VirtualObject { key, .. } => Some(key), + InvocationTarget::Workflow { key, .. } => Some(key), + } + } + + pub fn handler_name(&self) -> &ByteString { + match self { + InvocationTarget::Service { handler, .. } => handler, + InvocationTarget::VirtualObject { handler, .. } => handler, + InvocationTarget::Workflow { handler, .. } => handler, + } + } + + pub fn as_keyed_service_id(&self) -> Option { + match self { + InvocationTarget::Service { .. } => None, + InvocationTarget::VirtualObject { name, key, .. } => { + Some(ServiceId::new(name.clone(), key.clone())) + } + InvocationTarget::Workflow { name, key, .. } => { + Some(ServiceId::new(name.clone(), key.clone())) + } + } + } + + pub fn service_ty(&self) -> ServiceType { + match self { + InvocationTarget::Service { .. } => ServiceType::Service, + InvocationTarget::VirtualObject { .. } => ServiceType::VirtualObject, + InvocationTarget::Workflow { .. } => ServiceType::Workflow, + } + } + + pub fn invocation_target_ty(&self) -> InvocationTargetType { + match self { + InvocationTarget::Service { .. } => InvocationTargetType::Service, + InvocationTarget::VirtualObject { handler_ty, .. } => { + InvocationTargetType::VirtualObject(*handler_ty) + } + InvocationTarget::Workflow { handler_ty, .. } => { + InvocationTargetType::Workflow(*handler_ty) + } + } + } +} + +impl fmt::Display for InvocationTarget { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}/", self.service_name())?; + if let Some(key) = self.key() { + write!(f, "{key}/")?; + } + write!(f, "{}", self.handler_name())?; + Ok(()) + } +} + +#[cfg(any(test, feature = "test-util"))] +mod mocks { + use super::*; + + use rand::distr::{Alphanumeric, SampleString}; + + fn generate_string() -> ByteString { + Alphanumeric.sample_string(&mut rand::rng(), 8).into() + } + + impl ServiceId { + pub fn mock_random() -> Self { + Self::new( + Alphanumeric.sample_string(&mut rand::rng(), 8), + Alphanumeric.sample_string(&mut rand::rng(), 16), + ) + } + + pub const fn from_static( + partition_key: PartitionKey, + service_name: &'static str, + service_key: &'static str, + ) -> Self { + Self { + service_name: ByteString::from_static(service_name), + key: ByteString::from_static(service_key), + partition_key, + } + } + } + + impl InvocationTarget { + pub fn mock_service() -> Self { + InvocationTarget::service(generate_string(), generate_string()) + } + + pub fn mock_virtual_object() -> Self { + InvocationTarget::virtual_object( + generate_string(), + generate_string(), + generate_string(), + VirtualObjectHandlerType::Exclusive, + ) + } + + pub fn mock_workflow() -> Self { + InvocationTarget::workflow( + generate_string(), + generate_string(), + generate_string(), + WorkflowHandlerType::Workflow, + ) + } + + pub fn mock_from_service_id(service_id: ServiceId) -> Self { + InvocationTarget::virtual_object( + service_id.service_name, + service_id.key, + "MyMethod", + VirtualObjectHandlerType::Exclusive, + ) + } + } +} diff --git a/crates/lib/ty/src/journal.rs b/crates/lib/ty/src/journal.rs new file mode 100644 index 0000000000..f312e6dfc9 --- /dev/null +++ b/crates/lib/ty/src/journal.rs @@ -0,0 +1,46 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +use crate::identifiers::InvocationId; +use crate::identifiers::WithInvocationId; + +// Just an alias +pub type EntryIndex = u32; + +#[derive(Eq, Hash, PartialEq, Clone, Copy, Debug)] +pub struct JournalEntryId { + invocation_id: InvocationId, + journal_index: EntryIndex, +} + +impl JournalEntryId { + pub const fn from_parts(invocation_id: InvocationId, journal_index: EntryIndex) -> Self { + Self { + invocation_id, + journal_index, + } + } + + pub fn journal_index(&self) -> EntryIndex { + self.journal_index + } +} + +impl From<(InvocationId, EntryIndex)> for JournalEntryId { + fn from(value: (InvocationId, EntryIndex)) -> Self { + Self::from_parts(value.0, value.1) + } +} + +impl WithInvocationId for JournalEntryId { + fn invocation_id(&self) -> InvocationId { + self.invocation_id + } +} diff --git a/crates/lib/ty/src/lambda.rs b/crates/lib/ty/src/lambda.rs new file mode 100644 index 0000000000..9cbdb51ef5 --- /dev/null +++ b/crates/lib/ty/src/lambda.rs @@ -0,0 +1,144 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +use std::fmt::{self, Display, Formatter}; +use std::str::FromStr; +use std::sync::Arc; + +#[derive(Debug, Clone, PartialEq, serde_with::SerializeDisplay, serde_with::DeserializeFromStr)] +pub struct LambdaARN { + arn: Arc, + region: std::ops::Range, +} + +impl LambdaARN { + pub fn region(&self) -> &str { + &self.arn[(self.region.start as usize)..(self.region.end as usize)] + } +} + +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for LambdaARN { + fn schema_name() -> String { + "LambdaARN".into() + } + + fn json_schema(_: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema { + schemars::schema::SchemaObject { + instance_type: Some(schemars::schema::InstanceType::String.into()), + format: Some("arn".to_string()), + ..Default::default() + } + .into() + } +} + +impl Display for LambdaARN { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + self.arn.fmt(f) + } +} + +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum InvalidLambdaARN { + #[error("A qualified ARN must have 8 components delimited by `:`")] + InvalidFormat, + #[error( + "A qualified ARN needs a version or alias suffix. If you want to use the unpublished version, provide $LATEST and make sure your shell doesn't treat it as a variable" + )] + MissingVersionSuffix, + #[error("First component of the ARN must be `arn`")] + InvalidPrefix, + #[error("ARN must refer to a `function` resource")] + InvalidResourceType, + #[error( + "Partition, service, region, account ID, function name and version must all be non-empty" + )] + InvalidComponent, + #[error("ARN must be for the lambda service")] + InvalidService, + #[error("Could not create valid URI for this ARN; likely malformed")] + InvalidURI, +} + +impl FromStr for LambdaARN { + type Err = InvalidLambdaARN; + + fn from_str(arn: &str) -> Result { + let mut split = arn.splitn(8, ':'); + let invalid_format = || InvalidLambdaARN::InvalidFormat; + let prefix = split.next().ok_or_else(invalid_format)?; + let partition = split.next().ok_or_else(invalid_format)?; + let service = split.next().ok_or_else(invalid_format)?; + let region = split.next().ok_or_else(invalid_format)?; + let account_id = split.next().ok_or_else(invalid_format)?; + let resource_type = split.next().ok_or_else(invalid_format)?; + let name = split.next().ok_or_else(invalid_format)?; + let version = split.next().ok_or(InvalidLambdaARN::MissingVersionSuffix)?; + + if prefix != "arn" { + return Err(InvalidLambdaARN::InvalidPrefix); + } + if resource_type != "function" { + return Err(InvalidLambdaARN::InvalidResourceType); + } + if service != "lambda" { + return Err(InvalidLambdaARN::InvalidService); + } + if partition.is_empty() || region.is_empty() || account_id.is_empty() || name.is_empty() { + return Err(InvalidLambdaARN::InvalidComponent); + } + + if version.is_empty() { + // special case this common mistake + return Err(InvalidLambdaARN::MissingVersionSuffix); + } + + // arn::lambda:: + // ^ ^ + let region_start = 3 + 1 + (partition.len() as u32) + 1 + 6 + 1; + let region_end = region_start + (region.len() as u32); + let lambda = Self { + arn: Arc::::from(arn), + region: region_start..region_end, + }; + + Ok(lambda) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_lambda_arn() { + let good = "arn:aws:lambda:eu-central-1:1234567890:function:e2e-node-services:version"; + + let expected = LambdaARN::from_str(good).unwrap(); + let parsed = expected.to_string(); + + assert_eq!(good, parsed); + assert_eq!("eu-central-1", expected.region()); + } + + #[test] + fn missing_version_lambda_arn() { + for bad in [ + "arn:aws:lambda:eu-central-1:1234567890:function:e2e-node-services", + "arn:aws:lambda:eu-central-1:1234567890:function:e2e-node-services:", + ] { + assert_eq!( + LambdaARN::from_str(bad).unwrap_err(), + InvalidLambdaARN::MissingVersionSuffix + ); + } + } +} diff --git a/crates/lib/ty/src/lib.rs b/crates/lib/ty/src/lib.rs new file mode 100644 index 0000000000..770d9605a6 --- /dev/null +++ b/crates/lib/ty/src/lib.rs @@ -0,0 +1,58 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +//! This crate contains the core types used by various Restate components. + +mod base62_util; +mod merge; +mod node_id; +mod restate_version; +mod shared_string; +mod version; + +pub mod errors; +pub mod identifiers; +pub mod invocation; +pub mod journal; +pub mod lambda; +pub mod locality; +pub mod logs; +pub mod metadata; +pub mod net; +pub mod partitions; +pub mod protobuf; +pub mod storage; + +// pub should be removed when call sites are updated +pub use lambda::*; +pub use merge::*; +pub use node_id::*; +pub use restate_version::*; +pub use version::*; + +// Re-export metrics' SharedString (Space-efficient Cow + RefCounted variant) +// pub type SharedString = metrics::SharedString; +// pub use shared_string::SharedString; + +/// An allocation-optimized string. +/// +/// `SharedString` uses a custom copy-on-write implementation that is optimized for metric keys, +/// providing ergonomic sharing of single instances, or slices, of strings and labels. This +/// copy-on-write implementation is optimized to allow for constant-time construction (using static +/// values), as well as accepting owned values and values shared through [`Arc`](std::sync::Arc). +/// +/// End users generally will not need to interact with this type directly, as the top-level macros +/// (`counter!`, etc), as well as the various conversion implementations +/// ([`From`](std::convert::From)), generally allow users to pass whichever variant of a value +/// (static, owned, shared) is best for them. +pub type SharedString = shared_string::Cow<'static, str>; + +/// Index type used messages in the runtime +pub type MessageIndex = u64; diff --git a/crates/lib/ty/src/locality.rs b/crates/lib/ty/src/locality.rs new file mode 100644 index 0000000000..f9d145dd1f --- /dev/null +++ b/crates/lib/ty/src/locality.rs @@ -0,0 +1,15 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +mod location_scope; +mod node_location; + +pub use location_scope::LocationScope; +pub use node_location::NodeLocation; diff --git a/crates/lib/ty/src/locality/location_scope.rs b/crates/lib/ty/src/locality/location_scope.rs new file mode 100644 index 0000000000..95aac58f8f --- /dev/null +++ b/crates/lib/ty/src/locality/location_scope.rs @@ -0,0 +1,81 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +/// [`LocationScope`] specifies the location of a node in the cluster. The location +/// is expressed by a set of hierarchical scopes. Restate assumes the cluster topology +/// to be a tree-like structure. +#[derive( + Debug, + Copy, + Clone, + Hash, + Eq, + PartialEq, + Ord, + PartialOrd, + strum::EnumIter, + strum::Display, + strum::EnumString, + serde::Serialize, + serde::Deserialize, +)] +#[serde(rename_all = "kebab-case")] +#[strum(ascii_case_insensitive)] +#[repr(u8)] +pub enum LocationScope { + /// Special; Indicating the smallest scope (an individual node) + Node = 0, + + // Actual scopes representing the location of a node + Zone, + Region, + + // Special; Includes all lower-level scopes. + #[strum(disabled)] + Root, +} + +impl LocationScope { + /// Returns None if self is already the largest scope, i.e. `Root` + pub const fn next_greater_scope(self) -> Option { + // we know `self + 1` won't overflow + let next = (self as u8) + 1; + Self::from_u8(next) + } + + /// Returns None if self is already the smallest scope, i.e. `Node` + pub const fn next_smaller_scope(self) -> Option { + if matches!(self, Self::Node) { + None + } else { + Self::from_u8(self as u8 - 1) + } + } + + pub const fn is_special(self) -> bool { + matches!(self, LocationScope::Root | LocationScope::Node) + } + + // Returns the number of non-special scopes. + pub const fn num_scopes() -> usize { + LocationScope::Root as usize - 1 + } + + #[inline] + pub const fn from_u8(value: u8) -> Option { + match value { + 0 => Some(Self::Node), + 1 => Some(Self::Zone), + 2 => Some(Self::Region), + 3 => Some(Self::Root), + _ => None, + } + } +} diff --git a/crates/lib/ty/src/locality/node_location.rs b/crates/lib/ty/src/locality/node_location.rs new file mode 100644 index 0000000000..2b93486d11 --- /dev/null +++ b/crates/lib/ty/src/locality/node_location.rs @@ -0,0 +1,524 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +use std::str::FromStr; + +use itertools::Itertools; + +use crate::PlainNodeId; + +use super::LocationScope; + +type SmartString = smartstring::SmartString; + +#[derive(thiserror::Error, Debug)] +#[error("Invalid node location string: {0}")] +pub struct InvalidNodeLocationError(String); + +/// Delimiter for location scopes in a location string (e.g. `us-east2.use2-az3`) +pub(super) const SCOPE_DELIMITER: &str = "."; + +/// Delimiter for node-id after location string (e.g. `us-east2.use2-az3:N4`) +const NODE_DELIMITER: &str = ":"; + +/// Stores location information of non-special scopes in range (Node, Root) exclusively. +/// +/// Identifies some domain, by giving a path from [`LocationScope::Root`] to the domain. +/// +/// It's a vector of labels for scopes from biggest to smallest. For instance, +/// {"us-east-2", "use2-az3"} identifies zone "use2-az3" in region "us-east-2". +/// +/// The vector always starts at the biggest possible (non-Root) scope, but doesn't +/// necessarily go all the way to the smallest (non-Node) scope. +/// E.g. {"us-east-2"} is valid and it identifies region "us-east-2" but doesn't +/// specify the zone. +/// +/// To construct NodeLocation, use our `FromStr` implementation, a la `"region1.zone".parse()` style. +/// +/// Technical details: this is a home-made smallvec-like structure to avoid heap-allocations and to +/// improve cache-friendlyness. It's a fixed-size array of labels, where each label is a highly-likely +/// stack-inlined `SmartString`. +#[derive( + Clone, Default, PartialEq, Eq, serde_with::DeserializeFromStr, serde_with::SerializeDisplay, +)] +pub struct NodeLocation { + /// Internal storage for labels of all scopes, the order of scopes in the array + /// is the reverse order of their type value: largest scope (i.e. Region) gets + /// stored at index 0, and so on... + labels: [SmartString; LocationScope::num_scopes()], + /// number of (non-empty) scope labels specified + num_defined_scopes: u8, +} + +impl NodeLocation { + /// Creates a new empty NodeLocation + pub const fn new() -> Self { + Self { + labels: [const { SmartString::new_const() }; LocationScope::num_scopes()], + num_defined_scopes: 0, + } + } + /// Given a scope specified in `scope`, returns a domain string that + /// identifies the location of the node. The domain string will include the + /// name for the specified scope as well as names for all parent scopes. + /// E.g. a possible output for scope `Region` is "us-east1". + /// + /// If `node_id` is `None` and scope [`LocationScope::Node`] is treated differently: + /// it's equivalent to `Zone`; i.e. the returned string will identify a zone, + /// not the node. If `node_id` is given, and `scope` is [`LocationScope::Node`], + /// the returned string will be identify a node, e.g. "us-east-2.use2-az3:N4" + /// or ":N4" if no location was supplied. + /// + /// ## Panics + /// if `scope` is [`LocationScope::Root`] + pub fn domain_string(&self, scope: LocationScope, node_id: Option) -> String { + debug_assert!(scope != LocationScope::Root); + if self.is_empty() && node_id.is_none() { + return String::new(); + } + let effective_scopes = effective_scopes(scope); + + let mut result = self + .labels + .iter() + .take(self.num_defined_scopes.into()) + .take(effective_scopes) + .join(SCOPE_DELIMITER); + + if scope == LocationScope::Node + && let Some(node_id) = node_id + { + result += NODE_DELIMITER; + result += &node_id.to_string(); + } + + result + } + + /// Node location label at the given `scope`. + /// + /// Note that on special scopes (Root, Node), the label is an empty string. + pub fn label_at(&self, scope: LocationScope) -> &str { + static EMPTY_LABEL: &str = ""; + if scope.is_special() { + return EMPTY_LABEL; + } + + &self.labels[scope_to_index(scope)] + } + + /// Returns true if a label is assigned at this scope + pub fn is_scope_defined(&self, scope: LocationScope) -> bool { + !scope.is_special() && scope_to_index(scope) < self.num_defined_scopes as usize + } + + /// Returns true if no labels are assigned + pub fn is_empty(&self) -> bool { + self.num_defined_scopes == 0 + } + + /// The number of scopes with assigned labels + pub fn num_defined_scopes(&self) -> usize { + self.num_defined_scopes.into() + } + + /// Returns the smallest (narrowest) defined location scope + pub fn smallest_defined_scope(&self) -> LocationScope { + if self.num_defined_scopes == 0 { + return LocationScope::Root; + } + + LocationScope::from_u8(LocationScope::Root as u8 - self.num_defined_scopes).unwrap() + } + + /// Checks if this location is a prefix match for the input. In other words, the input location + /// `prefix` must be a prefix of (or equal to) this location. + pub fn matches_prefix(&self, prefix: &str) -> bool { + let prefix = prefix.trim(); + if prefix.is_empty() || prefix == SCOPE_DELIMITER { + return true; + } + // Unconditionally adding scope delimiter to our location at the end to ensure we don't + // perform partial matching with input prefix. + // + // If input prefix is `region.us-east`, we don't want this to match `region.us-east1`. We + // only consider this a prefix match if the zone matches. For this to work, we add `.` + // suffix to the input `prefix` and unconditionally add `.` to our own value. + // + // The previous case will be prefix=`region.us-east.` which is not a prefix of + // `region.us-east1.` + let domain_str = self.domain_string(LocationScope::Node, None) + SCOPE_DELIMITER; + if prefix.ends_with(SCOPE_DELIMITER) { + domain_str.starts_with(prefix) + } else { + domain_str.starts_with(&format!("{prefix}{SCOPE_DELIMITER}")) + } + } + + /// Checks if this location shares the same domain with the input location at the given scope. + /// For instance, if self is "us-east2" the input location is "us-east2.use2-az3" and the scope is `Zone`, + /// then the function returns `false` because while we share the region, we don't share the zone. + /// It returns `true` if the same check was done at `Region` instead. + /// + /// This is a little more efficient that `matches_prefix` if the location has been already + /// parsed. + /// + /// * Input scope [`LocationScope::Root`] always yields `true` since Root is always shared. + /// * Input scope [`LocationScope::Node`] always yields `false` since Node is implicit and is never shared. + pub fn shares_domain_with(&self, location: &NodeLocation, scope: LocationScope) -> bool { + if scope == LocationScope::Root { + return true; + } + if scope == LocationScope::Node { + return false; + } + + let effective_scopes = effective_scopes(scope); + + // compare up-to the effective scopes of the input `scope` + self.labels + .iter() + .take(effective_scopes) + .eq(location.labels.iter().take(effective_scopes)) + } +} +impl std::fmt::Display for NodeLocation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.domain_string(LocationScope::Node, None)) + } +} + +impl std::fmt::Debug for NodeLocation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{self}") + } +} + +impl FromStr for NodeLocation { + type Err = InvalidNodeLocationError; + /// Fills the NodeLocation from the given location string in which labels + /// of each location scope are specified. + /// + /// Definition: + /// Location Domain: a string that identifies the location of a node, it + /// consists of labels of all location scopes, separated by + /// [`DELIMITER`]. + /// Label: part of the location domain string that belongs to + /// one location scope. + /// + /// Notes: the location domain string must have all [`LocationScope::num_scopes()`] separated by + /// [`DELIMITER`]. The left most scope must be the biggest scope defined [`LocationScope::MAX`]. + /// An empty label is allowed, meaning that location for the scope and + /// all subscopes are not specified. + /// + /// Legit domain string examples: + /// "us-east2.use2-az3", "us-east2", "us-east2.", ".", "" + /// Invalid examples: + /// "us-east2...", ".use2-az3" + fn from_str(s: &str) -> Result { + // remove leading and trailing whitespaces + let s = s.trim(); + if s.is_empty() { + return Ok(Self::default()); + } + + let tokens: Vec<&str> = s + .split(SCOPE_DELIMITER) + .map(|token| validate_token(s, token)) + .try_collect()?; + + if tokens.len() > LocationScope::num_scopes() { + return Err(InvalidNodeLocationError(format!( + "Wrong number of scopes in location string '{}'. Got {}, maximum {}", + s, + tokens.len(), + LocationScope::num_scopes(), + ))); + } + + let mut n_tokens: usize = 0; + let mut labels = [const { SmartString::new_const() }; LocationScope::num_scopes()]; + let mut tokens = tokens.into_iter(); + + while n_tokens < LocationScope::num_scopes() { + match tokens.next() { + None => break, + Some("") => break, + Some(token) => labels[n_tokens] = token.into(), + } + + n_tokens += 1; + } + + // if any of the remaining tokens are empty string, then we fail. + if tokens.any(|s| !s.is_empty()) { + return Err(InvalidNodeLocationError(format!( + "Non-empty label exists after an empty label in location string: '{s}'", + ))); + } + + Ok(Self { + labels, + num_defined_scopes: n_tokens as u8, + }) + } +} + +/// Converts a non-special scope into the label-index. +/// +/// **Requires scope to be non-special** +const fn scope_to_index(scope: LocationScope) -> usize { + assert!(!scope.is_special()); + // 0 - Node -- excluded + // 1 - Zone + // 2 - Region + // 3 - Root -- excluded + // num_scopes = 2 + // + // zone = 2 - 1 == labels[1] + // region = 2 - 2 == labels[0] + LocationScope::num_scopes() - (scope as usize) +} + +/// How many scopes actual (non-special) scopes are equal or greater than this scope. +/// +/// For instance, for a Node scope, we have 2 greater "non-special" scopes (Zone, Region), +/// for Region, it's "1", and for Root, it's 0. +const fn effective_scopes(scope: LocationScope) -> usize { + match scope { + LocationScope::Root => 0, + LocationScope::Node => LocationScope::num_scopes(), + scope => scope_to_index(scope) + 1, + } +} + +fn validate_token<'a>(input: &'a str, token: &'a str) -> Result<&'a str, InvalidNodeLocationError> { + if token.contains(" ") || token.contains(":") { + return Err(InvalidNodeLocationError(format!( + "Illegal character(s) in location string: '{input}', in label '{token}'" + ))); + } + Ok(token) +} + +#[cfg(test)] +mod tests { + // write tests for NodeLocation string parsing + use super::*; + use googletest::prelude::*; + + #[test] + fn node_location_parsing_simple() { + // valid case 1 + let location = NodeLocation::from_str("us-east2.use2-az3").unwrap(); + assert_that!(location.smallest_defined_scope(), eq(LocationScope::Zone)); + assert_that!(location.label_at(LocationScope::Region), eq("us-east2")); + assert_that!(location.label_at(LocationScope::Zone), eq("use2-az3")); + assert_that!(location.label_at(LocationScope::Node), eq("")); + assert_that!(location.label_at(LocationScope::Root), eq("")); + assert_that!(location.num_defined_scopes(), eq(2)); + assert_that!( + location.domain_string(LocationScope::Region, None), + eq("us-east2") + ); + assert_that!( + location.domain_string(LocationScope::Zone, None), + eq("us-east2.use2-az3") + ); + assert_that!( + location.domain_string(LocationScope::Node, None), + eq("us-east2.use2-az3") + ); + assert_that!( + location.domain_string(LocationScope::Node, Some(PlainNodeId::new(4))), + eq("us-east2.use2-az3:N4") + ); + // node-id is ignored unless we are printing the Node scope. + assert_that!( + location.domain_string(LocationScope::Zone, Some(PlainNodeId::new(4))), + eq("us-east2.use2-az3") + ); + + assert_that!(location.to_string(), eq("us-east2.use2-az3")); + + // valid case 2 + let location = NodeLocation::from_str("us-east2.").unwrap(); + assert_that!(location.smallest_defined_scope(), eq(LocationScope::Region)); + assert_that!(location.label_at(LocationScope::Region), eq("us-east2")); + assert_that!(location.label_at(LocationScope::Zone), eq("")); + assert_that!(location.label_at(LocationScope::Node), eq("")); + assert_that!(location.num_defined_scopes(), eq(1)); + assert_that!( + location.domain_string(LocationScope::Region, None), + eq("us-east2") + ); + assert_that!( + location.domain_string(LocationScope::Zone, None), + eq("us-east2") + ); + assert_that!( + location.domain_string(LocationScope::Node, None), + eq("us-east2") + ); + assert_that!( + location.domain_string(LocationScope::Node, Some(PlainNodeId::new(4))), + eq("us-east2:N4") + ); + + assert_that!(location.to_string(), eq("us-east2")); + assert_that!(location, eq(NodeLocation::from_str("us-east2").unwrap())); + + // valid case 3 + let location = NodeLocation::from_str("us-east1").unwrap(); + assert_that!(location.label_at(LocationScope::Region), eq("us-east1")); + assert_that!(location.label_at(LocationScope::Zone), eq("")); + assert_that!(location.label_at(LocationScope::Node), eq("")); + assert_that!(location.num_defined_scopes(), eq(1)); + assert_that!( + location.domain_string(LocationScope::Region, None), + eq("us-east1") + ); + assert_that!( + location.domain_string(LocationScope::Node, None), + eq("us-east1") + ); + assert_that!( + location.domain_string(LocationScope::Node, Some(5.into())), + eq("us-east1:N5") + ); + } + + #[test] + fn node_location_parsing_with_empty_labels() { + // valid case 1 -- empty str + let location = NodeLocation::from_str("").unwrap(); + assert_that!(location.smallest_defined_scope(), eq(LocationScope::Root)); + assert_that!(location.label_at(LocationScope::Region), eq("")); + assert_that!(location.label_at(LocationScope::Zone), eq("")); + assert_that!(location.label_at(LocationScope::Node), eq("")); + assert_that!(location.num_defined_scopes(), eq(0)); + assert_that!(location.domain_string(LocationScope::Region, None), eq("")); + assert_that!(location.domain_string(LocationScope::Node, None), eq("")); + assert_that!( + location.domain_string(LocationScope::Node, Some(5.into())), + eq(":N5") + ); + + assert_that!(location, eq(NodeLocation::from_str(".").unwrap())); + assert_that!(location, eq(NodeLocation::from_str(" ").unwrap())); + assert_that!(location, eq(NodeLocation::from_str(" ").unwrap())); + + // valid case 1 -- region only + } + + #[test] + fn node_location_parsing_invalid() { + // invalid case 1 -- empty region, but zone is set + assert!(NodeLocation::from_str(".az1").is_err()); + + // invalid case 2 -- too many tokens + assert!(NodeLocation::from_str("region1.zone1.cluster5").is_err()); + + // invalid case 3 -- too many tokens + assert!(NodeLocation::from_str("region1..").is_err()); + + // invalid case 4 -- too many tokens + assert!(NodeLocation::from_str("region1..as").is_err()); + + // invalid case 5 -- illegal characters + assert!(NodeLocation::from_str(":").is_err()); + assert!(NodeLocation::from_str("my region").is_err()); + assert!(NodeLocation::from_str("region.my zone").is_err()); + assert!(NodeLocation::from_str("region.my:zone").is_err()); + } + + #[test] + fn node_location_matching() { + let location = NodeLocation::from_str("us-east2.use2-az3").unwrap(); + assert_that!(location.matches_prefix(""), eq(true)); + assert_that!(location.matches_prefix("."), eq(true)); + assert_that!(location.matches_prefix("us-east2"), eq(true)); + assert_that!(location.matches_prefix("us-east2."), eq(true)); + assert_that!(location.matches_prefix("us-east"), eq(false)); + assert_that!(location.matches_prefix("us-east2.use2"), eq(false)); + assert_that!(location.matches_prefix("us-east2.use2-az3"), eq(true)); + assert_that!(location.matches_prefix("us-east2.use2-az3."), eq(true)); + + let location2 = NodeLocation::from_str("us-east2.use2").unwrap(); + assert_that!( + location.shares_domain_with(&location2, LocationScope::Root), + eq(true) + ); + + assert_that!( + location.shares_domain_with(&location2, LocationScope::Region), + eq(true) + ); + + assert_that!( + location.shares_domain_with(&location2, LocationScope::Zone), + eq(false) + ); + + assert_that!( + location.shares_domain_with(&location2, LocationScope::Node), + eq(false) + ); + + // even identical locations can't match on node scope + assert_that!( + location.shares_domain_with(&location, LocationScope::Node), + eq(false) + ); + + // validates the same behaviour when locations are of different lengths + // + let location2 = NodeLocation::from_str("us-east2").unwrap(); + assert_that!( + location.shares_domain_with(&location2, LocationScope::Root), + eq(true) + ); + + assert_that!( + location.shares_domain_with(&location2, LocationScope::Region), + eq(true) + ); + + assert_that!( + location.shares_domain_with(&location2, LocationScope::Zone), + eq(false) + ); + + assert_that!( + location.shares_domain_with(&location2, LocationScope::Node), + eq(false) + ); + // same if the check is flipped over + assert_that!( + location2.shares_domain_with(&location, LocationScope::Root), + eq(true) + ); + + assert_that!( + location2.shares_domain_with(&location, LocationScope::Region), + eq(true) + ); + + assert_that!( + location2.shares_domain_with(&location, LocationScope::Zone), + eq(false) + ); + + assert_that!( + location2.shares_domain_with(&location, LocationScope::Node), + eq(false) + ); + } +} diff --git a/crates/lib/ty/src/logs/loglet.rs b/crates/lib/ty/src/logs/loglet.rs new file mode 100644 index 0000000000..2cfbb75d33 --- /dev/null +++ b/crates/lib/ty/src/logs/loglet.rs @@ -0,0 +1,143 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +use std::fmt::{Display, Formatter}; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; + +use restate_encoding::{BilrostNewType, NetSerde}; + +use crate::logs::LogId; + +// Starts with 0 being the oldest loglet in the chain. +#[derive( + Default, + Clone, + Copy, + Eq, + PartialEq, + Ord, + PartialOrd, + Hash, + Serialize, + Deserialize, + derive_more::From, + derive_more::Into, + derive_more::Display, + derive_more::Debug, + BilrostNewType, +)] +#[repr(transparent)] +#[serde(transparent)] +#[debug("{}", _0)] +pub struct SegmentIndex(pub(crate) u32); + +impl SegmentIndex { + pub const OLDEST: SegmentIndex = SegmentIndex(0); + + #[cfg(feature = "test-util")] + pub fn from_raw(v: u32) -> Self { + Self(v) + } + + pub fn next(&self) -> SegmentIndex { + SegmentIndex( + self.0 + .checked_add(1) + .expect("we should never create more than 2^32 segments"), + ) + } +} + +/// LogletId is a helper type to generate reliably unique identifiers for individual loglets in a +/// single chain. +/// +/// This is not an essential type and loglet providers may choose to use their own type. This type +/// stitches the log-id and a segment-index in a u64 number which can be displayed as +/// `_` +#[derive( + serde::Serialize, + serde::Deserialize, + Debug, + Eq, + PartialEq, + Hash, + Ord, + PartialOrd, + Clone, + Copy, + derive_more::From, + derive_more::Deref, + derive_more::Into, + BilrostNewType, + NetSerde, +)] +#[serde(transparent)] +#[repr(transparent)] +pub struct LogletId(u64); + +impl LogletId { + /// Creates a new [`LogletId`] from a [`LogId`] and a [`SegmentIndex`]. The upper + /// 32 bits are the log_id and the lower are the segment_index. + pub fn new(log_id: LogId, segment_index: SegmentIndex) -> Self { + let id = (u64::from(u32::from(log_id)) << 32) | u64::from(u32::from(segment_index)); + Self(id) + } + + /// It's your responsibility that the value has the right meaning. + pub const fn new_unchecked(v: u64) -> Self { + Self(v) + } + + /// Creates a new [`LogletId`] by incrementing the lower 32 bits (segment index part). + pub fn next(&self) -> Self { + assert!( + self.0 & 0xFFFFFFFF < u64::from(u32::MAX), + "Segment part must not overflow into the LogId part" + ); + Self(self.0 + 1) + } + + fn log_id(&self) -> LogId { + LogId::new(u32::try_from(self.0 >> 32).expect("upper 32 bits should fit into u32")) + } + + fn segment_index(&self) -> SegmentIndex { + SegmentIndex::from( + u32::try_from(self.0 & 0xFFFFFFFF).expect("lower 32 bits should fit into u32"), + ) + } +} + +impl Display for LogletId { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}_{}", self.log_id(), self.segment_index()) + } +} + +impl FromStr for LogletId { + type Err = ::Err; + fn from_str(s: &str) -> Result { + if s.contains('_') { + let parts: Vec<&str> = s.split('_').collect(); + let log_id: u32 = parts[0].parse()?; + let segment_index: u32 = parts[1].parse()?; + Ok(LogletId::new( + LogId::from(log_id), + SegmentIndex::from(segment_index), + )) + } else { + // treat the string as raw replicated log-id + let id: u64 = s.parse()?; + Ok(LogletId(id)) + } + } +} diff --git a/crates/lib/ty/src/logs/mod.rs b/crates/lib/ty/src/logs/mod.rs new file mode 100644 index 0000000000..137ca51fce --- /dev/null +++ b/crates/lib/ty/src/logs/mod.rs @@ -0,0 +1,501 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +use std::ops::{Add, RangeInclusive}; + +use bytes::{Buf, BufMut}; +use serde::{Deserialize, Serialize}; + +use restate_encoding::{BilrostNewType, NetSerde}; + +use crate::partitions::PartitionId; +use crate::storage::StorageEncode; + +mod loglet; +mod tail; + +pub use loglet::*; +pub use tail::*; + +#[derive( + Clone, + Copy, + Eq, + PartialEq, + Hash, + Ord, + PartialOrd, + derive_more::Debug, + derive_more::Display, + derive_more::From, + derive_more::Into, + Serialize, + Deserialize, + BilrostNewType, +)] +#[debug("{}", _0)] +pub struct LogId(u32); + +impl LogId { + pub const MAX: LogId = LogId(u32::MAX); + pub const MIN: LogId = LogId(0); +} + +impl LogId { + pub const fn new(value: u32) -> Self { + Self(value) + } + + /// Use to create an authoritative partition-to-log association. Typically the log id value + /// should be read from the partition table. + pub fn default_for_partition(value: PartitionId) -> Self { + LogId(u32::from(*value)) + } +} + +#[cfg(any(test, feature = "test-util"))] +impl From for LogId { + fn from(value: PartitionId) -> Self { + LogId(u32::from(*value)) + } +} + +impl From for LogId { + fn from(value: u16) -> Self { + LogId(u32::from(value)) + } +} + +/// The log sequence number. +#[derive( + Debug, + Clone, + Copy, + Eq, + PartialEq, + Hash, + Ord, + PartialOrd, + derive_more::Into, + derive_more::From, + derive_more::Add, + derive_more::Display, + Serialize, + Deserialize, + BilrostNewType, + NetSerde, +)] +pub struct Lsn(u64); + +impl Lsn { + pub const fn new(lsn: u64) -> Self { + Lsn(lsn) + } + + pub fn as_u64(self) -> u64 { + self.0 + } +} + +// Allows using Lsn as a range bound +impl std::ops::RangeBounds for std::ops::Range> { + fn start_bound(&self) -> std::ops::Bound<&Lsn> { + self.start.as_ref() + } + + fn end_bound(&self) -> std::ops::Bound<&Lsn> { + self.end.as_ref() + } +} + +impl From for Lsn { + fn from(lsn: crate::protobuf::Lsn) -> Self { + Self::from(lsn.value) + } +} + +impl From for crate::protobuf::Lsn { + fn from(lsn: Lsn) -> Self { + let value: u64 = lsn.into(); + Self { value } + } +} + +impl SequenceNumber for Lsn { + /// The maximum possible sequence number, this is useful when creating a read stream + /// with an open-ended tail. + const MAX: Self = Lsn(u64::MAX); + /// 0 is not a valid sequence number. This sequence number represents invalid position + /// in the log, or that the log has been trimmed. + const INVALID: Self = Lsn(0); + /// Guaranteed to be less than or equal to the oldest possible sequence + /// number in a log. This is useful when seeking to the head of a log. + const OLDEST: Self = Lsn(1); + + fn next(self) -> Self { + Self(self.0.saturating_add(1)) + } + + fn prev(self) -> Self { + Self(self.0.saturating_sub(1)) + } +} + +pub trait SequenceNumber +where + Self: Copy + std::fmt::Debug + Sized + Into + Eq + PartialEq + Ord + PartialOrd, +{ + /// The maximum possible sequence number, this is useful when creating a read stream + const MAX: Self; + /// Not a valid sequence number. This sequence number represents invalid position + /// in the log, or that the log has been that has been trimmed. + const INVALID: Self; + + /// Guaranteed to be less than or equal to the oldest possible sequence + /// number in a log. This is useful when seeking to the head of a log. + const OLDEST: Self; + + fn next(self) -> Self; + fn prev(self) -> Self; +} + +#[derive( + Debug, + Clone, + Default, + PartialEq, + Eq, + Serialize, + Deserialize, + NetSerde, + bilrost::Oneof, + bilrost::Message, +)] +/// The keys that are associated with a record. This is used to filter the log when reading. +pub enum Keys { + /// No keys are associated with the record. This record will appear to *all* readers regardless + /// of the KeyFilter they use. + #[default] + #[bilrost(empty)] + None, + /// A single key is associated with the record + #[bilrost(1)] + Single(u64), + /// A pair of keys are associated with the record + #[bilrost(tag(2), message)] + Pair(#[bilrost(0)] u64, #[bilrost(1)] u64), + /// The record is associated with all keys within this range (inclusive) + #[bilrost(3)] + RangeInclusive(RangeInclusive), +} + +impl MatchKeyQuery for Keys { + /// Returns true if the key matches the supplied `query` + fn matches_key_query(&self, query: &KeyFilter) -> bool { + match (self, query) { + // regardless of the matcher. + (Keys::None, _) => true, + (_, KeyFilter::Any) => true, + (Keys::Single(key1), KeyFilter::Include(key2)) => key1 == key2, + (Keys::Single(key), KeyFilter::Within(range)) => range.contains(key), + (Keys::Pair(first, second), KeyFilter::Include(key)) => key == first || key == second, + (Keys::Pair(first, second), KeyFilter::Within(range)) => { + range.contains(first) || range.contains(second) + } + (Keys::RangeInclusive(range), KeyFilter::Include(key)) => range.contains(key), + (Keys::RangeInclusive(range1), KeyFilter::Within(range2)) => { + // A record matches if ranges intersect + range1.start() <= range2.end() && range1.end() >= range2.start() + } + } + } +} + +impl Keys { + pub fn iter(&self) -> Box + 'static> { + match self { + Keys::None => Box::new(std::iter::empty()), + Keys::Single(key) => Box::new(std::iter::once(*key)), + Keys::Pair(first, second) => Box::new([*first, *second].into_iter()), + Keys::RangeInclusive(range) => Box::new(range.clone()), + } + } +} + +impl IntoIterator for Keys { + type Item = u64; + type IntoIter = Box + 'static>; + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + +/// A type that describes which records a reader should pick +#[derive( + Debug, Clone, Default, Serialize, Deserialize, NetSerde, bilrost::Oneof, bilrost::Message, +)] +pub enum KeyFilter { + /// Matches any record + #[default] + #[bilrost(empty)] + Any, + /// Match records that have a specific key, or no keys at all. + #[bilrost(1)] + Include(u64), + /// Match records that have _any_ keys falling within this inclusive range, + /// in addition to records with no keys. + #[bilrost(2)] + Within(RangeInclusive), +} + +impl From for KeyFilter { + fn from(key: u64) -> Self { + KeyFilter::Include(key) + } +} + +impl From> for KeyFilter { + fn from(range: RangeInclusive) -> Self { + KeyFilter::Within(range) + } +} + +pub trait MatchKeyQuery { + /// returns true if this record matches the supplied `query` + fn matches_key_query(&self, query: &KeyFilter) -> bool; +} + +pub trait HasRecordKeys: Send + Sync { + /// Keys of the record. Keys are used to filter the log when reading. + fn record_keys(&self) -> Keys; +} + +impl HasRecordKeys for &T { + fn record_keys(&self) -> Keys { + HasRecordKeys::record_keys(*self) + } +} + +pub trait WithKeys: Sized { + fn with_keys(self, keys: Keys) -> BodyWithKeys; + + fn with_no_keys(self) -> BodyWithKeys + where + Self: StorageEncode, + { + BodyWithKeys::new(self, Keys::None) + } +} + +impl WithKeys for T { + fn with_keys(self, keys: Keys) -> BodyWithKeys { + BodyWithKeys::new(self, keys) + } +} + +/// A transparent wrapper that augments a type with some keys. This is a convenience +/// type to pass payloads to Bifrost without constructing [`restate_bifrost::InputRecord`] +/// or without implementing [`restate_bifrost::HasRecordKeys`] on your message type. +/// +/// When reading these records, you must directly decode with the inner type T. +#[derive(Debug, Clone)] +pub struct BodyWithKeys { + inner: T, + keys: Keys, +} + +impl BodyWithKeys { + pub fn new(inner: T, keys: Keys) -> Self { + Self { inner, keys } + } + + pub fn into_inner(self) -> T { + self.inner + } +} + +impl HasRecordKeys for BodyWithKeys +where + T: Send + Sync + 'static, +{ + fn record_keys(&self) -> Keys { + self.keys.clone() + } +} + +// Inner loglet offset +#[derive( + Debug, + Copy, + Clone, + PartialEq, + Eq, + Ord, + PartialOrd, + derive_more::From, + derive_more::Deref, + derive_more::Into, + derive_more::Display, + Serialize, + Deserialize, + Hash, + BilrostNewType, + NetSerde, +)] +#[repr(transparent)] +#[serde(transparent)] +pub struct LogletOffset(u32); + +impl LogletOffset { + pub const fn new(offset: u32) -> Self { + Self(offset) + } + + pub fn decode(mut data: B) -> Self { + Self(data.get_u32()) + } + + /// Encodes this value into its binary representation on the stack + pub fn to_binary_array(self) -> [u8; Self::size()] { + self.0.to_be_bytes() + } + + /// Encodes this value into its binary representation and advances the underlying buffer + pub fn encode(&self, buf: &mut B) { + buf.put_u32(self.0); + } + + /// The number of bytes required for the binary representation of this value + pub const fn size() -> usize { + size_of::() + } + + // allows going back to INVALID + pub fn prev_unchecked(self) -> Self { + Self(self.0.saturating_sub(1)) + } +} + +impl From for u64 { + fn from(value: LogletOffset) -> Self { + u64::from(value.0) + } +} + +impl Add for LogletOffset { + type Output = Self; + fn add(self, rhs: u32) -> Self { + Self( + self.0 + .checked_add(rhs) + .expect("loglet offset must not overflow over u32"), + ) + } +} + +impl SequenceNumber for LogletOffset { + const MAX: Self = LogletOffset(u32::MAX); + const INVALID: Self = LogletOffset(0); + const OLDEST: Self = LogletOffset(1); + + /// Saturates to Self::MAX + fn next(self) -> Self { + Self(self.0.saturating_add(1)) + } + + /// Saturates to Self::OLDEST. + fn prev(self) -> Self { + Self(std::cmp::max(Self::OLDEST.0, self.0.saturating_sub(1))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct Data { + src_key: u64, + dst_key: u64, + } + + impl HasRecordKeys for Data { + fn record_keys(&self) -> Keys { + Keys::Pair(self.src_key, self.dst_key) + } + } + + #[test] + fn has_record_keys() { + let data = Data { + src_key: 1, + dst_key: 10, + }; + + let keys = data.record_keys(); + assert!(!keys.matches_key_query(&KeyFilter::Include(5))); + assert!(keys.matches_key_query(&KeyFilter::Include(1))); + assert!(keys.matches_key_query(&KeyFilter::Include(10))); + + assert!(keys.matches_key_query(&KeyFilter::Any)); + assert!(keys.matches_key_query(&KeyFilter::Within(1..=200))); + assert!(keys.matches_key_query(&KeyFilter::Within(10..=200))); + assert!(!keys.matches_key_query(&KeyFilter::Within(11..=200))); + assert!(!keys.matches_key_query(&KeyFilter::Within(100..=200))); + + let keys: Vec<_> = data.record_keys().iter().collect(); + assert_eq!(vec![1, 10], keys); + } + + #[test] + fn key_matches() { + let keys = Keys::None; + // A record with no keys matches all filters. + assert!(keys.matches_key_query(&KeyFilter::Any)); + assert!(keys.matches_key_query(&KeyFilter::Include(u64::MIN))); + assert!(keys.matches_key_query(&KeyFilter::Include(100))); + assert!(keys.matches_key_query(&KeyFilter::Within(100..=1000))); + + let keys = Keys::Single(10); + assert!(keys.matches_key_query(&KeyFilter::Any)); + assert!(keys.matches_key_query(&KeyFilter::Include(10))); + assert!(keys.matches_key_query(&KeyFilter::Within(1..=100))); + assert!(keys.matches_key_query(&KeyFilter::Within(5..=10))); + assert!(!keys.matches_key_query(&KeyFilter::Include(100))); + assert!(!keys.matches_key_query(&KeyFilter::Within(1..=9))); + assert!(!keys.matches_key_query(&KeyFilter::Within(20..=900))); + + let keys = Keys::Pair(1, 10); + assert!(keys.matches_key_query(&KeyFilter::Any)); + assert!(keys.matches_key_query(&KeyFilter::Include(10))); + assert!(keys.matches_key_query(&KeyFilter::Include(1))); + assert!(!keys.matches_key_query(&KeyFilter::Include(0))); + assert!(!keys.matches_key_query(&KeyFilter::Include(100))); + assert!(keys.matches_key_query(&KeyFilter::Within(1..=3))); + assert!(keys.matches_key_query(&KeyFilter::Within(3..=10))); + + assert!(!keys.matches_key_query(&KeyFilter::Within(2..=7))); + assert!(!keys.matches_key_query(&KeyFilter::Within(11..=100))); + + let keys = Keys::RangeInclusive(5..=100); + assert!(keys.matches_key_query(&KeyFilter::Any)); + assert!(keys.matches_key_query(&KeyFilter::Include(5))); + assert!(keys.matches_key_query(&KeyFilter::Include(10))); + assert!(keys.matches_key_query(&KeyFilter::Include(100))); + assert!(!keys.matches_key_query(&KeyFilter::Include(4))); + assert!(!keys.matches_key_query(&KeyFilter::Include(101))); + + assert!(keys.matches_key_query(&KeyFilter::Within(5..=100))); + assert!(keys.matches_key_query(&KeyFilter::Within(1..=100))); + assert!(keys.matches_key_query(&KeyFilter::Within(2..=105))); + assert!(keys.matches_key_query(&KeyFilter::Within(10..=88))); + assert!(!keys.matches_key_query(&KeyFilter::Within(1..=4))); + assert!(!keys.matches_key_query(&KeyFilter::Within(101..=1000))); + } +} diff --git a/crates/lib/ty/src/logs/tail.rs b/crates/lib/ty/src/logs/tail.rs new file mode 100644 index 0000000000..6aef92882f --- /dev/null +++ b/crates/lib/ty/src/logs/tail.rs @@ -0,0 +1,98 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +use std::fmt::Display; + +use super::{Lsn, SequenceNumber}; + +/// Represents the state of the tail of the loglet. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TailState { + /// Loglet is open for appends + Open(Offset), + /// Loglet is sealed. This offset if the durable tail. + Sealed(Offset), +} + +/// "(S)" denotes that tail is sealed +impl Display for TailState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Open(n) => write!(f, "{n}"), + Self::Sealed(n) => write!(f, "{n} (S)"), + } + } +} + +impl TailState { + pub fn new(sealed: bool, offset: Offset) -> Self { + if sealed { + TailState::Sealed(offset) + } else { + TailState::Open(offset) + } + } + + /// Combines two TailStates together + /// + /// Only applies updates to the value according to the following rules: + /// - Offsets can only move forward. + /// - Tail cannot be unsealed once sealed. + /// + /// Returns true if the state was updated + pub fn combine(&mut self, sealed: bool, offset: Offset) -> bool { + let old_offset = self.offset(); + let is_already_sealed = self.is_sealed(); + + let new_offset = std::cmp::max(self.offset(), offset); + let new_sealed = self.is_sealed() || sealed; + if new_sealed != is_already_sealed || new_offset > old_offset { + *self = TailState::new(new_sealed, new_offset); + true + } else { + false + } + } + + /// Applies a seal on the tail state without changing the tail offset + /// Returns true if the state was updated + pub fn seal(&mut self) -> bool { + if self.is_sealed() { + false + } else { + *self = TailState::new(true, self.offset()); + true + } + } +} + +impl TailState { + pub fn map(self, f: F) -> TailState + where + F: FnOnce(Offset) -> T, + { + match self { + TailState::Open(offset) => TailState::Open(f(offset)), + TailState::Sealed(offset) => TailState::Sealed(f(offset)), + } + } + + #[inline(always)] + pub fn is_sealed(&self) -> bool { + matches!(self, TailState::Sealed(_)) + } + + #[inline(always)] + pub fn offset(&self) -> Offset { + match self { + TailState::Open(offset) | TailState::Sealed(offset) => *offset, + } + } +} diff --git a/crates/lib/ty/src/merge.rs b/crates/lib/ty/src/merge.rs new file mode 100644 index 0000000000..44dc5d1072 --- /dev/null +++ b/crates/lib/ty/src/merge.rs @@ -0,0 +1,26 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +/// Trait for merging two attributes +pub trait Merge { + /// Return true if the value was mutated as a result of the merge + fn merge(&mut self, other: Self) -> bool; +} + +impl Merge for bool { + fn merge(&mut self, other: Self) -> bool { + if *self != other { + *self |= other; + true + } else { + false + } + } +} diff --git a/crates/lib/ty/src/metadata.rs b/crates/lib/ty/src/metadata.rs new file mode 100644 index 0000000000..987c61bda8 --- /dev/null +++ b/crates/lib/ty/src/metadata.rs @@ -0,0 +1,56 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +use prost_dto::{FromProst, IntoProst}; +use serde::{Deserialize, Serialize}; + +use crate::errors::GenericError; + +/// The kind of versioned metadata that can be synchronized across nodes. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + derive_more::Display, + strum::EnumCount, + strum::EnumIter, + enum_map::Enum, + IntoProst, + FromProst, +)] +#[prost(target = "crate::protobuf::MetadataKind")] +pub enum MetadataKind { + NodesConfiguration, + Schema, + PartitionTable, + Logs, +} + +// todo remove once prost_dto supports TryFromProst +impl TryFrom for MetadataKind { + type Error = GenericError; + + fn try_from(value: crate::protobuf::MetadataKind) -> Result { + match value { + crate::protobuf::MetadataKind::Unknown => Err("unknown metadata kind".into()), + crate::protobuf::MetadataKind::NodesConfiguration => { + Ok(MetadataKind::NodesConfiguration) + } + crate::protobuf::MetadataKind::Schema => Ok(MetadataKind::Schema), + crate::protobuf::MetadataKind::PartitionTable => Ok(MetadataKind::PartitionTable), + crate::protobuf::MetadataKind::Logs => Ok(MetadataKind::Logs), + } + } +} diff --git a/crates/lib/ty/src/net.rs b/crates/lib/ty/src/net.rs new file mode 100644 index 0000000000..afbd8581c1 --- /dev/null +++ b/crates/lib/ty/src/net.rs @@ -0,0 +1,188 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +pub use crate::protobuf::{ProtocolVersion, ServiceTag}; + +// #[derive(thiserror::Error, Debug)] +// pub enum EncodeError { +// #[error( +// "message of type {type_tag} requires peer to have minimum version {min_required:?} but actual negotiated version is {actual:?}." +// )] +// IncompatibleVersion { +// type_tag: &'static str, +// min_required: ProtocolVersion, +// actual: ProtocolVersion, +// }, +// } +// +// pub trait Service: Send + Unpin + 'static { +// const TAG: ServiceTag; +// } +// +// pub trait RpcRequest: WireEncode + WireDecode + Send + 'static { +// const TYPE: &str; +// type Service: Service; +// type Response: RpcResponse; +// } +// +// pub trait RpcResponse: WireEncode + WireDecode + Unpin + Send { +// type Service: Service; +// } +// +// pub trait WatchRequest: WireEncode + WireDecode + Send + 'static { +// const TYPE: &str; +// type Service: Service; +// type Response: WatchResponse; +// } +// +// pub trait WatchResponse: WireEncode + WireDecode + Unpin + Send { +// type Service: Service; +// } +// +// pub trait UnaryMessage: WireEncode + WireDecode + Send + 'static { +// const TYPE: &str; +// type Service: Service; +// } +// +// pub trait WireEncode { +// fn encode_to_bytes(&self, protocol_version: ProtocolVersion) -> Result; +// } +// +// pub trait WireDecode { +// type Error: std::fmt::Debug + Into; +// /// Panics if decode failed +// fn decode(buf: impl Buf, protocol_version: ProtocolVersion) -> Self +// where +// Self: Sized, +// { +// Self::try_decode(buf, protocol_version).expect("decode WireDecode message failed") +// } +// +// fn try_decode(buf: impl Buf, protocol_version: ProtocolVersion) -> Result +// where +// Self: Sized; +// } +// +// impl WireEncode for Box +// where +// T: WireEncode, +// { +// fn encode_to_bytes(&self, protocol_version: ProtocolVersion) -> Result { +// self.as_ref().encode_to_bytes(protocol_version) +// } +// } +// +// impl WireDecode for Box +// where +// T: WireDecode, +// { +// type Error = T::Error; +// fn try_decode(buf: impl Buf, protocol_version: ProtocolVersion) -> Result +// where +// Self: Sized, +// { +// Ok(Box::new(T::try_decode(buf, protocol_version)?)) +// } +// } +// +// impl WireDecode for Arc +// where +// T: WireDecode, +// { +// type Error = T::Error; +// +// fn try_decode(buf: impl Buf, protocol_version: ProtocolVersion) -> Result +// where +// Self: Sized, +// { +// Ok(Arc::new(T::try_decode(buf, protocol_version)?)) +// } +// } +// +// /// to define a service we need +// /// - Service type +// /// - Service Tag +// /// +// /// Example: +// /// ```ignore +// /// define_service! { +// /// @service = IngressService, +// /// @tag = ServiceTag::Ingress, +// /// } +// /// ``` +// #[macro_export] +// macro_rules! define_service { +// ( +// @service = $service:ty, +// @tag = $tag:expr, +// ) => { +// impl restate_ty::net::Service for $service { +// const TAG: restate_ty::net::ServiceTag = $tag; +// } +// }; +// } +// +// /// to define a unary message, we need +// /// - Message type +// /// - service type +// /// +// /// Example: +// /// ```ignore +// /// define_unary_message! { +// /// @message = IngressMessage, +// /// @service = IngressService, +// /// } +// /// ``` +// #[macro_export] +// macro_rules! define_unary_message { +// ( +// @message = $message:ty, +// @service = $service:ty, +// ) => { +// impl restate_ty::net::UnaryMessage for $message { +// const TYPE: &str = stringify!($message); +// type Service = $service; +// } +// }; +// } +// +// /// to define an RPC, we need +// /// - Request type +// /// - request service tag +// /// - Service type +// /// +// /// Example: +// /// ```ignore +// /// define_rpc! { +// /// @request = AttachRequest, +// /// @response = AttachResponse, +// /// @service = ClusterControllerService, +// /// } +// /// ``` +// #[macro_export] +// macro_rules! define_rpc { +// ( +// @request = $request:ty, +// @response = $response:ty, +// @service = $service:ty, +// ) => { +// impl restate_ty::net::RpcRequest for $request { +// const TYPE: &str = stringify!($request); +// type Response = $response; +// type Service = $service; +// } +// +// impl restate_ty::net::RpcResponse for $response { +// type Service = $service; +// } +// }; +// } +// +// pub use {define_rpc, define_service, define_unary_message}; diff --git a/crates/lib/ty/src/node_id.rs b/crates/lib/ty/src/node_id.rs new file mode 100644 index 0000000000..5136fd3682 --- /dev/null +++ b/crates/lib/ty/src/node_id.rs @@ -0,0 +1,553 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +use std::num::NonZero; +use std::str::FromStr; + +use bytes::{Buf, BufMut}; + +use restate_encoding::{BilrostNewType, NetSerde}; + +use crate::base62_util::{base62_encode_fixed_width_u64, base62_max_length_for_type}; +use crate::errors::GenericError; + +/// A generational node identifier. Nodes with the same ID but different generations +/// represent the same node across different instances (restarts) of its lifetime. +/// +/// Note about equality checking. When comparing two node ids, we should always compare the same +/// type. For instance, if any side of the comparison is a generational node id, the other side +/// must be also generational and the generations must match. If you are only interested in +/// checking the id part, then compare using `x.id() == y.id()` instead of `x == y`. +#[derive( + PartialEq, + Eq, + Clone, + Copy, + Hash, + derive_more::From, + derive_more::Display, + derive_more::Debug, + derive_more::IsVariant, + serde::Serialize, + serde::Deserialize, +)] +pub enum NodeId { + Plain(PlainNodeId), + Generational(GenerationalNodeId), +} + +#[derive( + PartialEq, + Eq, + PartialOrd, + Ord, + Clone, + Copy, + Hash, + derive_more::From, + derive_more::Display, + derive_more::Debug, + serde::Serialize, + serde::Deserialize, + bilrost::Message, + NetSerde, +)] +#[display("{}:{}", _0, _1)] +#[debug("{}:{}", _0, _1)] +pub struct GenerationalNodeId(PlainNodeId, u32); + +impl From for GenerationalNodeId { + fn from(value: crate::protobuf::GenerationalNodeId) -> Self { + Self(PlainNodeId(value.id), value.generation) + } +} + +impl From<&GenerationalNodeId> for NodeId { + fn from(value: &GenerationalNodeId) -> Self { + NodeId::Generational(*value) + } +} + +impl From<&PlainNodeId> for NodeId { + fn from(value: &PlainNodeId) -> Self { + NodeId::Plain(*value) + } +} + +#[derive(Debug, thiserror::Error)] +#[error("invalid plain node id: {0}")] +pub struct MalformedPlainNodeId(String); + +#[derive(Debug, thiserror::Error)] +#[error("invalid generational node id: {0}")] +pub struct MalformedGenerationalNodeId(String); + +impl FromStr for GenerationalNodeId { + type Err = MalformedGenerationalNodeId; + + fn from_str(s: &str) -> Result { + // generational node id can be in "N:" format or : format. + // parse the id and generation and construct GenerationalNodeId from this string + let (id_part, gen_part) = s + .split_once(':') + .ok_or_else(|| MalformedGenerationalNodeId(s.to_string()))?; + let id = id_part + .trim_start_matches('N') + .parse() + .map_err(|_| MalformedGenerationalNodeId(s.to_string()))?; + + let generation = gen_part + .parse() + .map_err(|_| MalformedGenerationalNodeId(s.to_string()))?; + + Ok(GenerationalNodeId::new(id, generation)) + } +} + +impl GenerationalNodeId { + pub const INVALID: GenerationalNodeId = PlainNodeId::INVALID.with_generation(0); + pub const INITIAL_NODE_ID: GenerationalNodeId = PlainNodeId::MIN.with_generation(1); + + pub fn decode(mut data: B) -> Self { + // generational node id is stored as two u32s next to each other, each in big-endian. + let plain_id = data.get_u32(); + let generation = data.get_u32(); + Self(PlainNodeId(plain_id), generation) + } + + /// Encodes this value into its binary representation and advances the underlying buffer + pub fn encode(&self, buf: &mut B) { + debug_assert!(buf.remaining_mut() >= Self::size()); + buf.put_u32(self.0.0); + buf.put_u32(self.1); + } + + /// Encodes this value into its binary representation on the stack + pub fn to_binary_array(self) -> [u8; Self::size()] { + let mut buf = [0u8; Self::size()]; + self.encode(&mut &mut buf[..]); + buf + } + + /// The number of bytes required for the binary representation of this value + pub const fn size() -> usize { + size_of::() + } + + /// Same plain node-id but not the same generation + #[inline(always)] + pub fn is_same_but_different(&self, other: &GenerationalNodeId) -> bool { + self.0 == other.0 && self.1 != other.1 + } +} + +impl From for u64 { + fn from(value: GenerationalNodeId) -> Self { + (u64::from(value.id()) << 32) | u64::from(value.generation()) + } +} + +impl From for GenerationalNodeId { + fn from(value: u64) -> Self { + GenerationalNodeId::new((value >> 32) as u32, value as u32) + } +} + +#[derive( + Default, + PartialEq, + Eq, + Ord, + PartialOrd, + Clone, + Copy, + Hash, + derive_more::From, + derive_more::Into, + derive_more::Display, + derive_more::Debug, + serde::Serialize, + serde::Deserialize, + BilrostNewType, + NetSerde, +)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[cfg_attr(feature = "schemars", schemars(transparent))] +#[display("N{}", _0)] +#[debug("N{}", _0)] +pub struct PlainNodeId(u32); + +impl FromStr for PlainNodeId { + type Err = MalformedPlainNodeId; + + fn from_str(s: &str) -> Result { + // plain id can be in "N" format or format. + let id = s + .trim_start_matches('N') + .parse() + .map_err(|_| MalformedPlainNodeId(s.to_string()))?; + + Ok(PlainNodeId::new(id)) + } +} + +impl NodeId { + pub fn new(id: u32, generation: Option) -> NodeId { + match generation { + Some(generation) => Self::new_generational(id, generation), + None => Self::new_plain(id), + } + } + + pub fn new_plain(id: u32) -> NodeId { + NodeId::Plain(PlainNodeId(id)) + } + + pub fn new_generational(id: u32, generation: u32) -> NodeId { + NodeId::Generational(GenerationalNodeId(PlainNodeId(id), generation)) + } + + /// Returns the same node ID but replaces the generation. + pub fn with_generation(self, generation: u32) -> NodeId { + Self::Generational(GenerationalNodeId(self.id(), generation)) + } + + /// This node is the same node (same identifier) but has higher generation. + pub fn is_newer_than(self, other: impl Into) -> bool { + let other: NodeId = other.into(); + match (self, other) { + (NodeId::Plain(_), NodeId::Plain(_)) => false, + (NodeId::Plain(_), NodeId::Generational(_)) => false, + (NodeId::Generational(_), NodeId::Plain(_)) => false, + (NodeId::Generational(my_gen), NodeId::Generational(their_gen)) => { + my_gen.is_newer_than(their_gen) + } + } + } + + /// A unique identifier for the node. A stateful node will carry the same node id across + /// restarts. + pub fn id(self) -> PlainNodeId { + match self { + NodeId::Plain(id) => id, + NodeId::Generational(generation) => generation.as_plain(), + } + } + + /// Generation identifies exact instance of the process running this node. + /// In cases where we need to have an exact identity comparison (to fence off old instances), + /// we should use this field to validate. + pub fn as_generational(self) -> Option { + match self { + NodeId::Plain(_) => None, + NodeId::Generational(generational) => Some(generational), + } + } +} + +impl PartialEq for NodeId { + fn eq(&self, other: &GenerationalNodeId) -> bool { + match self { + NodeId::Plain(_) => false, + NodeId::Generational(this) => this == other, + } + } +} + +impl PartialEq for NodeId { + fn eq(&self, other: &PlainNodeId) -> bool { + match self { + NodeId::Plain(this) => this == other, + NodeId::Generational(_) => false, + } + } +} + +impl From for NodeId { + fn from(node_id: crate::protobuf::NodeId) -> Self { + NodeId::new(node_id.id, node_id.generation) + } +} + +impl From for crate::protobuf::NodeId { + fn from(node_id: NodeId) -> Self { + crate::protobuf::NodeId { + id: node_id.id().into(), + generation: node_id.as_generational().map(|g| g.generation()), + } + } +} + +impl From for crate::protobuf::NodeId { + fn from(node_id: PlainNodeId) -> Self { + let id: u32 = node_id.into(); + crate::protobuf::NodeId { + id, + generation: None, + } + } +} + +impl From for crate::protobuf::NodeId { + fn from(node_id: GenerationalNodeId) -> Self { + crate::protobuf::NodeId { + id: node_id.raw_id(), + generation: Some(node_id.generation()), + } + } +} + +impl From for PlainNodeId { + fn from(value: GenerationalNodeId) -> Self { + value.0 + } +} + +impl PlainNodeId { + // Start with 1 as plain node id to leave 0 as a special value in the future + pub const INVALID: PlainNodeId = PlainNodeId::new(0); + pub const MIN: PlainNodeId = PlainNodeId::new(1); + + pub const fn new(id: u32) -> PlainNodeId { + PlainNodeId(id) + } + + pub fn is_valid(self) -> bool { + self.0 != 0 + } + + pub const fn with_generation(self, generation: u32) -> GenerationalNodeId { + GenerationalNodeId(self, generation) + } + + pub fn next(mut self) -> Self { + self.0 += 1; + self + } +} + +impl PartialEq for PlainNodeId { + fn eq(&self, other: &NodeId) -> bool { + match other { + NodeId::Plain(id) => self == id, + NodeId::Generational(_) => false, + } + } +} + +impl GenerationalNodeId { + pub const fn new(id: u32, generation: u32) -> GenerationalNodeId { + Self(PlainNodeId(id), generation) + } + + pub fn raw_id(self) -> u32 { + self.0.0 + } + + pub const fn as_plain(self) -> PlainNodeId { + self.0 + } + + pub fn id(self) -> u32 { + self.0.into() + } + + pub fn is_valid(self) -> bool { + self.0.is_valid() && self.1 != 0 + } + + pub fn generation(self) -> u32 { + self.1 + } + + pub fn bump_generation(&mut self) { + self.1 += 1; + } + + pub fn is_newer_than(self, other: GenerationalNodeId) -> bool { + self.0 == other.0 && self.1 > other.1 + } +} + +impl PartialEq for GenerationalNodeId { + fn eq(&self, other: &NodeId) -> bool { + match other { + NodeId::Plain(_) => false, + NodeId::Generational(other) => self == other, + } + } +} + +#[derive( + Clone, + Copy, + derive_more::Debug, + derive_more::From, + Eq, + PartialEq, + serde::Serialize, + serde::Deserialize, +)] +#[serde(transparent)] +#[debug("ClusterFingerprint({_0})")] +pub struct ClusterFingerprint(NonZero); + +#[derive(Debug, thiserror::Error)] +#[error("invalid fingerprint value: {0}")] +pub struct InvalidFingerprint(u64); + +impl TryFrom for ClusterFingerprint { + type Error = InvalidFingerprint; + + fn try_from(value: u64) -> Result { + Ok(Self(NonZero::new(value).ok_or(InvalidFingerprint(value))?)) + } +} + +impl ClusterFingerprint { + pub fn generate() -> Self { + Self(rand::random()) + } + + pub fn to_u64(self) -> u64 { + self.0.get() + } +} + +impl std::fmt::Display for ClusterFingerprint { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut buf = [b'0'; base62_max_length_for_type::()]; + let written = base62_encode_fixed_width_u64(self.0.get(), &mut buf); + // SAFETY; the array was initialised with valid utf8 and base_encode_fixed_width_u64 only writes utf8 + f.write_str(unsafe { std::str::from_utf8_unchecked(&buf[0..written]) }) + } +} + +impl FromStr for ClusterFingerprint { + type Err = GenericError; + + fn from_str(s: &str) -> Result { + let size_to_read = base62_max_length_for_type::(); + if s.len() < size_to_read { + return Err(format!( + "invalid cluster fingerprint: too short (expected at least {} chars, got {})", + size_to_read, + s.len() + ) + .into()); + } + + let decoded = + base62::decode_alternative(s.trim_start_matches('0')).or_else(|e| match e { + // If we trim all zeros and nothing left, we assume there was a + // single zero value in the original input. + base62::DecodeError::EmptyInput => Ok(0), + _ => Err(format!("malformed cluster fingerprint: {e}")), + })?; + let out = u64::from_be( + decoded + .try_into() + .map_err(|e| format!("malformed cluster fingerprint: {e}"))?, + ); + + Ok(Self( + NonZero::new(out).ok_or("cluster fingerprint must be a non-zero value")?, + )) + } +} + +#[cfg(test)] +mod tests { + use std::num::NonZeroU64; + + use super::*; + + //test display of NodeId and equality + #[test] + fn test_display() { + let plain = NodeId::Plain(PlainNodeId(1)); + let generational = NodeId::Generational(GenerationalNodeId(PlainNodeId(1), 2)); + assert_eq!("N1", plain.to_string()); + assert_eq!("N1:2", generational.to_string()); + assert_eq!("N1", PlainNodeId(1).to_string()); + assert_eq!("N1:2", GenerationalNodeId(PlainNodeId(1), 2).to_string()); + } + + #[test] + fn test_parse_plain_node_id_string() { + let plain = NodeId::Plain(PlainNodeId(25)); + assert_eq!("N25", plain.to_string()); + let parsed_1: PlainNodeId = "N25".parse().unwrap(); + assert_eq!(parsed_1, plain); + let parsed_2: PlainNodeId = "25".parse().unwrap(); + assert_eq!(parsed_2, plain); + // invalid + assert!("25:10".parse::().is_err()); + // invalid + assert!("N25:".parse::().is_err()); + // invalid + assert!("N25:10".parse::().is_err()); + } + + #[test] + fn test_parse_generational_node_id_string() { + let generational = GenerationalNodeId::new(25, 18); + assert_eq!("N25:18", generational.to_string()); + let parsed_1: GenerationalNodeId = "N25:18".parse().unwrap(); + assert_eq!(parsed_1, generational); + let parsed_2: GenerationalNodeId = "25:18".parse().unwrap(); + assert_eq!(parsed_2, generational); + // invalid + assert!("25".parse::().is_err()); + // invalid + assert!("N25".parse::().is_err()); + } + + #[test] + fn test_equality() { + let plain1 = NodeId::Plain(PlainNodeId(1)); + let generational1 = NodeId::Generational(GenerationalNodeId(PlainNodeId(1), 2)); + let generational1_3 = NodeId::Generational(GenerationalNodeId(PlainNodeId(1), 3)); + + assert_eq!(NodeId::new_plain(1), plain1); + + assert_ne!(plain1, generational1); + assert_eq!(plain1.id(), generational1.id()); + assert_eq!(plain1, generational1.id()); + assert_eq!(NodeId::new_generational(1, 2), generational1); + assert_ne!(NodeId::new_generational(1, 3), generational1); + + assert_eq!(generational1, generational1.as_generational().unwrap()); + assert_eq!(generational1.as_generational().unwrap(), generational1); + + // same node, different generations + assert_ne!(generational1, generational1_3); + // Now they are equal + assert_eq!(generational1, generational1_3.with_generation(2)); + + assert!(generational1_3.is_newer_than(generational1)); + } + + #[test] + fn cluster_fingerprint_str_roundtrip() { + let fingerprint = ClusterFingerprint::generate(); + let str = fingerprint.to_string(); + assert_eq!(11, str.len()); + assert_eq!(fingerprint, str.parse().unwrap()); + + assert!(ClusterFingerprint::from_str("").is_err()); + assert!(ClusterFingerprint::from_str("5").is_err()); + assert!(ClusterFingerprint::from_str("00000000000").is_err()); + assert_eq!( + ClusterFingerprint::from_str("01000000000").unwrap(), + ClusterFingerprint(NonZeroU64::new(17700569142996992).unwrap()) + ); + } +} diff --git a/crates/lib/ty/src/partitions.rs b/crates/lib/ty/src/partitions.rs new file mode 100644 index 0000000000..a2524439e8 --- /dev/null +++ b/crates/lib/ty/src/partitions.rs @@ -0,0 +1,156 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +use std::hash::Hash; + +use restate_encoding::{BilrostNewType, NetSerde}; + +/// Identifying the leader epoch of a partition processor +#[derive( + PartialEq, + Eq, + Ord, + PartialOrd, + Clone, + Copy, + Hash, + derive_more::From, + derive_more::Into, + derive_more::Display, + derive_more::Debug, + serde::Serialize, + serde::Deserialize, + BilrostNewType, + NetSerde, +)] +#[display("e{}", _0)] +#[debug("e{}", _0)] +pub struct LeaderEpoch(u64); +impl LeaderEpoch { + pub const INVALID: Self = Self(0); + pub const INITIAL: Self = Self(1); + + pub fn next(self) -> Self { + LeaderEpoch(self.0 + 1) + } +} + +impl Default for LeaderEpoch { + fn default() -> Self { + Self::INITIAL + } +} + +impl From for LeaderEpoch { + fn from(epoch: crate::protobuf::LeaderEpoch) -> Self { + Self::from(epoch.value) + } +} + +impl From for crate::protobuf::LeaderEpoch { + fn from(epoch: LeaderEpoch) -> Self { + let value: u64 = epoch.into(); + Self { value } + } +} + +/// Identifying the partition +#[derive( + Copy, + Clone, + PartialEq, + Eq, + Hash, + PartialOrd, + Ord, + derive_more::Deref, + derive_more::From, + derive_more::Into, + derive_more::Add, + derive_more::Display, + derive_more::Debug, + derive_more::FromStr, + serde::Serialize, + serde::Deserialize, + BilrostNewType, + NetSerde, +)] +#[repr(transparent)] +#[serde(transparent)] +#[debug("{}", _0)] +pub struct PartitionId(u16); + +impl From for u32 { + fn from(value: PartitionId) -> Self { + u32::from(value.0) + } +} + +impl From for u64 { + fn from(value: PartitionId) -> Self { + u64::from(value.0) + } +} + +impl PartitionId { + /// It's your responsibility to ensure the value is within the valid range. + pub const fn new_unchecked(v: u16) -> Self { + Self(v) + } + + pub const MIN: Self = Self(u16::MIN); + // 65535 partitions. + pub const MAX: Self = Self(u16::MAX); + + #[inline] + pub fn next(self) -> Self { + Self(std::cmp::min(*Self::MAX, self.0.saturating_add(1))) + } +} + +/// The leader epoch of a given partition +pub type PartitionLeaderEpoch = (PartitionId, LeaderEpoch); + +/// Identifying to which partition a key belongs. This is unlike the [`PartitionId`] +/// which identifies a consecutive range of partition keys. +pub type PartitionKey = u64; + +/// Returns the partition key computed from either the service_key, or idempotency_key, if possible +pub(crate) fn deterministic_partition_key( + service_key: Option<&str>, + idempotency_key: Option<&str>, +) -> Option { + service_key + .map(partitioner::HashPartitioner::compute_partition_key) + .or_else(|| idempotency_key.map(partitioner::HashPartitioner::compute_partition_key)) +} + +/// Trait for data structures that have a partition key +pub trait WithPartitionKey { + /// Returns the partition key + fn partition_key(&self) -> PartitionKey; +} + +pub mod partitioner { + use super::PartitionKey; + + use std::hash::{Hash, Hasher}; + + /// Computes the [`PartitionKey`] based on xxh3 hashing. + pub struct HashPartitioner; + + impl HashPartitioner { + pub fn compute_partition_key(value: impl Hash) -> PartitionKey { + let mut hasher = xxhash_rust::xxh3::Xxh3::default(); + value.hash(&mut hasher); + hasher.finish() + } + } +} diff --git a/crates/lib/ty/src/protobuf.rs b/crates/lib/ty/src/protobuf.rs new file mode 100644 index 0000000000..d6ff14519c --- /dev/null +++ b/crates/lib/ty/src/protobuf.rs @@ -0,0 +1,83 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. +use crate::Merge; + +pub const FILE_DESCRIPTOR_SET: &[u8] = + include_bytes!(concat!(env!("OUT_DIR"), "/common_descriptor.bin")); + +include!(concat!(env!("OUT_DIR"), "/restate.common.rs")); + +impl ProtocolVersion { + pub const MIN_SUPPORTED_PROTOCOL_VERSION: ProtocolVersion = ProtocolVersion::V2; + pub const CURRENT_PROTOCOL_VERSION: ProtocolVersion = ProtocolVersion::V2; + + pub fn is_supported(&self) -> bool { + *self >= ProtocolVersion::MIN_SUPPORTED_PROTOCOL_VERSION + && *self <= ProtocolVersion::CURRENT_PROTOCOL_VERSION + } +} + +impl From for GenerationalNodeId { + fn from(value: crate::GenerationalNodeId) -> Self { + Self { + id: value.id(), + generation: value.generation(), + } + } +} + +impl std::fmt::Display for GenerationalNodeId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&crate::GenerationalNodeId::from(*self), f) + } +} + +impl std::fmt::Display for NodeId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(generation) = self.generation { + write!(f, "N{}:{}", self.id, generation) + } else { + write!(f, "N{}", self.id) + } + } +} + +impl std::fmt::Display for Lsn { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.value) + } +} + +impl std::fmt::Display for LeaderEpoch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "e{}", self.value) + } +} + +impl MetadataServerStatus { + /// Returns true if the metadata store is running which means that it is either a member or + /// a standby node. + pub fn is_running(&self) -> bool { + matches!( + self, + MetadataServerStatus::Member | MetadataServerStatus::Standby + ) + } +} + +impl Merge for NodeStatus { + fn merge(&mut self, other: Self) -> bool { + if other > *self { + *self = other; + return true; + } + false + } +} diff --git a/crates/lib/ty/src/restate_version.rs b/crates/lib/ty/src/restate_version.rs new file mode 100644 index 0000000000..9576472ca3 --- /dev/null +++ b/crates/lib/ty/src/restate_version.rs @@ -0,0 +1,278 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +use std::borrow::{Borrow, Cow}; +use std::str::FromStr; +use std::sync::LazyLock; + +use serde_with::serde_as; + +use restate_encoding::BilrostNewType; + +#[derive(thiserror::Error, Debug)] +#[error(transparent)] +pub struct RestateVersionError(#[from] semver::Error); + +/// Version of a restate binary +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, BilrostNewType)] +#[serde(transparent)] +pub struct RestateVersion(Cow<'static, str>); + +impl RestateVersion { + pub const UNKNOWN_STR: &str = "0.0.0-unknown"; + + /// The current version of the currently running binary + pub fn current() -> Self { + Self(Cow::Borrowed(env!("CARGO_PKG_VERSION"))) + } + + pub const fn unknown() -> Self { + // We still provide a semver valid version here + Self(Cow::Borrowed(Self::UNKNOWN_STR)) + } + + pub fn new(s: String) -> Self { + Self(Cow::Owned(s)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_string(self) -> String { + self.0.into_owned() + } +} + +impl AsRef for RestateVersion { + fn as_ref(&self) -> &str { + self.0.as_ref() + } +} + +impl Borrow for RestateVersion { + fn borrow(&self) -> &str { + self.0.borrow() + } +} + +/// A parsed semantic version of a restate binary +#[serde_as] +#[derive(Clone, serde::Serialize, serde::Deserialize, Eq, PartialEq, PartialOrd, Ord, Hash)] +pub struct SemanticRestateVersion(#[serde_as(as = "serde_with::DisplayFromStr")] semver::Version); + +restate_encoding::bilrost_as_display_from_str!(SemanticRestateVersion); + +impl Default for SemanticRestateVersion { + fn default() -> Self { + Self::unknown() + } +} + +impl SemanticRestateVersion { + pub fn current() -> &'static Self { + static CURRENT_SEMVER: LazyLock = LazyLock::new(|| { + SemanticRestateVersion(semver::Version::parse(env!("CARGO_PKG_VERSION")).unwrap()) + }); + &CURRENT_SEMVER + } + + pub fn unknown() -> Self { + Self(semver::Version::parse("0.0.0-unknown").unwrap()) + } + + pub const fn new(major: u64, minor: u64, patch: u64) -> Self { + Self(semver::Version::new(major, minor, patch)) + } + + pub fn major(&self) -> u64 { + self.0.major + } + + pub fn minor(&self) -> u64 { + self.0.minor + } + + pub fn patch(&self) -> u64 { + self.0.patch + } + + pub fn is_dev(&self) -> bool { + self.0.pre == semver::Prerelease::new("dev").unwrap() + } + + pub fn parse(s: &str) -> Result { + Self::from_str(s) + } + + /// Compare the major, minor, patch, and pre-release value of two versions, + /// disregarding build metadata. Versions that differ only in build metadata + /// are considered equal. This comparison is what the SemVer spec refers to + /// as "precedence". + /// + /// # Example + /// + /// ``` + /// use restate_ty::SemanticRestateVersion; + /// + /// let mut versions = [ + /// "1.20.0+c144a98".parse::().unwrap(), + /// "1.20.0".parse().unwrap(), + /// "1.0.0".parse().unwrap(), + /// "1.0.0-alpha".parse().unwrap(), + /// "1.20.0+bc17664".parse().unwrap(), + /// ]; + /// + /// // This is a stable sort, so it preserves the relative order of equal + /// // elements. The three 1.20.0 versions differ only in build metadata so + /// // they are not reordered relative to one another. + /// versions.sort_by(SemanticRestateVersion::cmp_precedence); + /// assert_eq!(versions, [ + /// "1.0.0-alpha".parse().unwrap(), + /// "1.0.0".parse().unwrap(), + /// "1.20.0+c144a98".parse().unwrap(), + /// "1.20.0".parse().unwrap(), + /// "1.20.0+bc17664".parse().unwrap(), + /// ]); + /// + /// // Totally order the versions, including comparing the build metadata. + /// versions.sort(); + /// assert_eq!(versions, [ + /// "1.0.0-alpha".parse().unwrap(), + /// "1.0.0".parse().unwrap(), + /// "1.20.0".parse().unwrap(), + /// "1.20.0+bc17664".parse().unwrap(), + /// "1.20.0+c144a98".parse().unwrap(), + /// ]); + /// ``` + pub fn cmp_precedence(&self, other: &Self) -> std::cmp::Ordering { + self.0.cmp_precedence(&other.0) + } + + /// True if this version is equal or newer than the other version + /// + /// note that a released version is considered newer than the equivalent dev version + pub fn is_equal_or_newer_than(&self, other: &Self) -> bool { + self.cmp_precedence(other) != std::cmp::Ordering::Less + } +} + +impl std::fmt::Display for SemanticRestateVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl std::fmt::Debug for SemanticRestateVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl FromStr for SemanticRestateVersion { + type Err = RestateVersionError; + + fn from_str(s: &str) -> Result { + let version = semver::Version::parse(s).map_err(RestateVersionError::from)?; + Ok(Self(version)) + } +} + +impl From for SemanticRestateVersion { + fn from(value: semver::Version) -> Self { + Self(value) + } +} + +impl TryFrom<&RestateVersion> for SemanticRestateVersion { + type Error = RestateVersionError; + + fn try_from(value: &RestateVersion) -> Result { + let version = semver::Version::parse(value.borrow()).map_err(RestateVersionError::from)?; + Ok(Self(version)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use googletest::prelude::*; + + #[test] + fn unknown_version_is_valid_semver() { + semver::Version::parse(RestateVersion::unknown().as_str()).unwrap(); + } + + #[test] + fn restate_semantic_version() { + let cargo_version = env!("CARGO_PKG_VERSION"); + let plain_version = RestateVersion::current(); + assert_that!(cargo_version, eq(plain_version.as_str())); + let parsed = SemanticRestateVersion::from_str(cargo_version).unwrap(); + assert_that!(parsed.to_string(), eq(plain_version.as_str())); + let parsed = SemanticRestateVersion::try_from(&RestateVersion::current()).unwrap(); + assert_that!(parsed.to_string(), eq(plain_version.as_str())); + + let parsed = SemanticRestateVersion::from_str("1.66.0").unwrap(); + assert_that!(parsed.major(), eq(1)); + assert_that!(parsed.minor(), eq(66)); + assert_that!(parsed.patch(), eq(0)); + assert_that!(parsed.is_dev(), eq(false)); + + let parsed = SemanticRestateVersion::from_str("1.66.0-dev").unwrap(); + assert_that!(parsed.major(), eq(1)); + assert_that!(parsed.minor(), eq(66)); + assert_that!(parsed.patch(), eq(0)); + assert_that!(parsed.is_dev(), eq(true)); + } + + #[test] + fn restate_version_serde() { + let version = RestateVersion::new("1.66.2-dev".to_owned()); + let serialized = serde_json::to_string(&version).unwrap(); + assert_that!(serialized, eq(r#""1.66.2-dev""#)); + let deserialized: RestateVersion = serde_json::from_str(&serialized).unwrap(); + assert_that!(version, eq(deserialized)); + + // same for semantic version + let version = SemanticRestateVersion::from_str("1.66.2-dev").unwrap(); + let serialized = serde_json::to_string(&version).unwrap(); + println!("{serialized}"); + assert_that!(serialized, eq(r#""1.66.2-dev""#)); + let deserialized: SemanticRestateVersion = serde_json::from_str(&serialized).unwrap(); + assert_that!(version, eq(deserialized)); + } + + #[test] + fn restate_version_newer_than() { + // same same + let version = SemanticRestateVersion::parse("1.66.2-dev").unwrap(); + assert_that!(version.is_equal_or_newer_than(&version), eq(true)); + + let newer = SemanticRestateVersion::parse("1.66.2").unwrap(); + // both are equal + assert_that!(newer.is_equal_or_newer_than(&version), eq(true)); + // 1.66.2 is newer than 1.66.2-dev + assert_that!(version.is_equal_or_newer_than(&newer), eq(false)); + let even_newer = SemanticRestateVersion::parse("1.66.3").unwrap(); + assert_that!(even_newer.is_equal_or_newer_than(&newer), eq(true)); + assert_that!(newer.is_equal_or_newer_than(&even_newer), eq(false)); + + let newer_dev = SemanticRestateVersion::parse("1.66.3-dev").unwrap(); + // 1.66.3-dev is newer than 1.66.2-dev + assert_that!(newer_dev.is_equal_or_newer_than(&version), eq(true)); + + let version = SemanticRestateVersion::parse("1.66.1").unwrap(); + let newer = SemanticRestateVersion::parse("1.66.2").unwrap(); + // newer is newer + assert_that!(version.is_equal_or_newer_than(&newer), eq(false)); + assert_that!(newer.is_equal_or_newer_than(&version), eq(true)); + } +} diff --git a/crates/lib/ty/src/shared_string.rs b/crates/lib/ty/src/shared_string.rs new file mode 100644 index 0000000000..09953344fb --- /dev/null +++ b/crates/lib/ty/src/shared_string.rs @@ -0,0 +1,765 @@ +// Direct unmodified copy of metrics::Cow and SharedString from: +// https://github.com/metrics-rs/metrics/blob/master/metrics/src/cow.rs +// +// Metrics is licensed under the MIT license, which is reproduced below: +// +// Copyright (c) 2021 Metrics Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +use std::{ + borrow::Borrow, + cmp::Ordering, + fmt, + hash::{Hash, Hasher}, + marker::PhantomData, + mem::ManuallyDrop, + ops::Deref, + ptr::{NonNull, slice_from_raw_parts}, + sync::Arc, +}; + +/// An allocation-optimized string. +/// +/// `SharedString` uses a custom copy-on-write implementation that is optimized for metric keys, +/// providing ergonomic sharing of single instances, or slices, of strings and labels. This +/// copy-on-write implementation is optimized to allow for constant-time construction (using static +/// values), as well as accepting owned values and values shared through [`Arc`](std::sync::Arc). +/// +/// End users generally will not need to interact with this type directly, as the top-level macros +/// (`counter!`, etc), as well as the various conversion implementations +/// ([`From`](std::convert::From)), generally allow users to pass whichever variant of a value +/// (static, owned, shared) is best for them. + +#[derive(Clone, Copy)] +enum Kind { + Owned, + Borrowed, + Shared, +} + +/// A clone-on-write smart pointer with an optimized memory layout, based on `beef`. +/// +/// # Strings, strings everywhere +/// +/// In `metrics`, strings are arguably the most common data type used despite the fact that metrics +/// are measuring numerical values. Both the name of a metric, and its labels, are strings: emitting +/// a metric may involve one string, or 10 strings. Many of these strings tend to be used over and +/// over during the life of the process, as well. +/// +/// In order to achieve and maintain a high level of performance, we use a "clone-on-write" smart +/// pointer to handle passing these strings around. Doing so allows us to potentially avoid having +/// to allocate entire copies of a string, instead using a lightweight smart pointer that can live +/// on the stack. +/// +/// # Why not `std::borrow::Cow`? +/// +/// The standard library already provides a clone-on-write smart pointer, `std::borrow::Cow`, which +/// works well in many cases. However, `metrics` strives to provide minimal overhead where possible, +/// and so `std::borrow::Cow` falls down in one particular way: it uses an enum representation which +/// consumes an additional word of storage. +/// +/// As an example, let's look at strings. A string in `std::borrow::Cow` implies that `T` is `str`, +/// and the owned version of `str` is simply `String`. Thus, for `std::borrow::Cow`, the in-memory +/// layout looks like this: +/// +/// ```text +/// Padding +/// | +/// v +/// +--------------+-------------+--------------+--------------+ +/// stdlib Cow::Borrowed: | Enum Tag | Pointer | Length | XXXXXXXX | +/// +--------------+-------------+--------------+--------------+ +/// +--------------+-------------+--------------+--------------+ +/// stdlib Cow::Owned: | Enum Tag | Pointer | Length | Capacity | +/// +--------------+-------------+--------------+--------------+ +/// ``` +/// +/// As you can see, you pay a memory size penalty to be able to wrap an owned string. This +/// additional word adds minimal overhead, but we can easily avoid it with some clever logic around +/// the values of the length and capacity fields. +/// +/// There is an existing crate that does just that: `beef`. Instead of using an enum, it is simply a +/// struct that encodes the discriminant values in the length and capacity fields directly. If we're +/// wrapping a borrowed value, we can infer that the "capacity" will always be zero, as we only need +/// to track the capacity when we're wrapping an owned value, in order to be able to recreate the +/// underlying storage when consuming the smart pointer, or dropping it. Instead of the above +/// layout, `beef` looks like this: +/// +/// ```text +/// +-------------+--------------+----------------+ +/// `beef` Cow (borrowed): | Pointer | Length (N) | Capacity (0) | +/// +-------------+--------------+----------------+ +/// +-------------+--------------+----------------+ +/// `beef` Cow (owned): | Pointer | Length (N) | Capacity (M) | +/// +-------------+--------------+----------------+ +/// ``` +/// +/// # Why not `beef`? +/// +/// Up until this point, it might not be clear why we didn't just use `beef`. In truth, our design +/// is fundamentally based on `beef`. Crucially, however, `beef` did not/still does not support +/// `const` construction for generic slices. Remember how we mentioned labels? The labels of a +/// metric `are `[Label]` under-the-hood, and so without a way to construct them in a `const` +/// fashion, our previous work to allow entirely static keys would not be possible. +/// +/// Thus, we forked `beef` and copied into directly into `metrics` so that we could write a +/// specialized `const` constructor for `[Label]`. +/// +/// This is why we have our own `Cow` bundled into `metrics` directly, which is based on `beef`. In +/// doing so, we can experiment with more interesting optimizations, and, as mentioned above, we can +/// add const methods to support all of the types involved in statically building metrics keys. +/// +/// # What we do that `beef` doesn't do +/// +/// It was already enough to use our own implementation for the specialized `const` capabilities, +/// but we've taken things even further in a key way: support for `Arc`-wrapped values. +/// +/// ## `Arc`-wrapped values +/// +/// For many strings, there is still a desire to share them cheaply even when they are constructed +/// at run-time. Remember, cloning a `Cow` of an owned value means cloning the value itself, so we +/// need another level of indirection to allow the cheap sharing, which is where `Arc` can +/// provide value. +/// +/// Users can construct a `Arc`, where `T` is lined up with the `T` of `metrics::Cow`, and use +/// that as the initial value instead. When `Cow` is cloned, we end up cloning the underlying +/// `Arc` instead, avoiding a new allocation. `Arc` still handles all of the normal logic +/// necessary to know when the wrapped value must be dropped, and how many live references to the +/// value that there are, and so on. +/// +/// We handle this by relying on an invariant of `Vec`: it never allocates more than `isize::MAX` +/// [1]. This lets us derive the following truth table of the valid combinations of length/capacity: +/// +/// ```text +/// Length (N) Capacity (M) +/// +---------------+----------------+ +/// Borrowed (&T): | N | 0 | +/// +---------------+----------------+ +/// Owned (T::ToOwned): | N | M < usize::MAX | +/// +---------------+----------------+ +/// Shared (Arc): | N | usize::MAX | +/// +---------------+----------------+ +/// ``` +/// +/// As we only implement `Cow` for types where their owned variants are either explicitly or +/// implicitly backed by `Vec<_>`, we know that our capacity will never be `usize::MAX`, as it is +/// limited to `isize::MAX`, and thus we can safely encode our "shared" state within the capacity +/// field. +/// +/// # Notes +/// +/// [1] - technically, `Vec` can have a capacity greater than `isize::MAX` when storing +/// zero-sized types, but we don't do that here, so we always enforce that an owned version's +/// capacity cannot be `usize::MAX` when constructing `Cow`. +pub struct Cow<'a, T: Cowable + ?Sized + 'a> { + ptr: NonNull, + metadata: Metadata, + _lifetime: PhantomData<&'a T>, +} + +impl Cow<'_, T> +where + T: Cowable + ?Sized, +{ + fn from_parts(ptr: NonNull, metadata: Metadata) -> Self { + Self { + ptr, + metadata, + _lifetime: PhantomData, + } + } + + /// Creates a pointer to an owned value, consuming it. + pub fn from_owned(owned: T::Owned) -> Self { + let (ptr, metadata) = T::owned_into_parts(owned); + + // This check is partially to guard against the semantics of `Vec` changing in the + // future, and partially to ensure that we don't somehow implement `Cowable` for a type + // where its owned version is backed by a vector of ZSTs, where the capacity could + // _legitimately_ be `usize::MAX`. + if metadata.capacity() == usize::MAX { + panic!("Invalid capacity of `usize::MAX` for owned value."); + } + + Self::from_parts(ptr, metadata) + } + + /// Creates a pointer to a shared value. + pub fn from_shared(arc: Arc) -> Self { + let (ptr, metadata) = T::shared_into_parts(arc); + Self::from_parts(ptr, metadata) + } + + /// Extracts the owned data. + /// + /// Clones the data if it is not already owned. + pub fn into_owned(self) -> ::Owned { + // We need to ensure that our own `Drop` impl does _not_ run because we're simply + // transferring ownership of the value back to the caller. For borrowed values, this is + // naturally a no-op because there's nothing to drop, but for owned values, like `String` or + // `Arc`, we wouldn't want to double drop. + let cow = ManuallyDrop::new(self); + + T::owned_from_parts(cow.ptr, &cow.metadata) + } +} + +impl<'a, T> Cow<'a, T> +where + T: Cowable + ?Sized, +{ + /// Creates a pointer to a borrowed value. + pub fn from_borrowed(borrowed: &'a T) -> Self { + let (ptr, metadata) = T::borrowed_into_parts(borrowed); + + Self::from_parts(ptr, metadata) + } +} + +impl<'a, T> Cow<'a, [T]> +where + T: Clone, +{ + pub const fn const_slice(val: &'a [T]) -> Cow<'a, [T]> { + // SAFETY: We can never create a null pointer by casting a reference to a pointer. + let ptr = unsafe { NonNull::new_unchecked(val.as_ptr() as *mut _) }; + let metadata = Metadata::borrowed(val.len()); + + Self { + ptr, + metadata, + _lifetime: PhantomData, + } + } +} + +impl<'a> Cow<'a, str> { + pub const fn const_str(val: &'a str) -> Self { + // SAFETY: We can never create a null pointer by casting a reference to a pointer. + let ptr = unsafe { NonNull::new_unchecked(val.as_ptr() as *mut _) }; + let metadata = Metadata::borrowed(val.len()); + + Self { + ptr, + metadata, + _lifetime: PhantomData, + } + } +} + +impl Deref for Cow<'_, T> +where + T: Cowable + ?Sized, +{ + type Target = T; + + fn deref(&self) -> &Self::Target { + let borrowed_ptr = T::borrowed_from_parts(self.ptr, &self.metadata); + + // SAFETY: We only ever hold a pointer to a borrowed value of at least the lifetime of + // `Self`, or an owned value which we have ownership of (albeit indirectly when using + // `Arc`), so our pointer is always valid and live for dereferencing. + unsafe { borrowed_ptr.as_ref().unwrap() } + } +} + +impl Clone for Cow<'_, T> +where + T: Cowable + ?Sized, +{ + fn clone(&self) -> Self { + let (ptr, metadata) = T::clone_from_parts(self.ptr, &self.metadata); + Self { + ptr, + metadata, + _lifetime: PhantomData, + } + } +} + +impl Drop for Cow<'_, T> +where + T: Cowable + ?Sized, +{ + fn drop(&mut self) { + T::drop_from_parts(self.ptr, &self.metadata); + } +} + +impl Hash for Cow<'_, T> +where + T: Hash + Cowable + ?Sized, +{ + #[inline] + fn hash(&self, state: &mut H) { + self.deref().hash(state) + } +} + +impl<'a, T> Default for Cow<'a, T> +where + T: Cowable + ?Sized, + &'a T: Default, +{ + #[inline] + fn default() -> Self { + Cow::from_borrowed(Default::default()) + } +} + +impl Eq for Cow<'_, T> where T: Eq + Cowable + ?Sized {} + +impl PartialOrd> for Cow<'_, A> +where + A: Cowable + ?Sized + PartialOrd, + B: Cowable + ?Sized, +{ + #[inline] + fn partial_cmp(&self, other: &Cow<'_, B>) -> Option { + PartialOrd::partial_cmp(self.deref(), other.deref()) + } +} + +impl Ord for Cow<'_, T> +where + T: Ord + Cowable + ?Sized, +{ + #[inline] + fn cmp(&self, other: &Self) -> Ordering { + Ord::cmp(self.deref(), other.deref()) + } +} + +impl<'a, T> From<&'a T> for Cow<'a, T> +where + T: Cowable + ?Sized, +{ + #[inline] + fn from(val: &'a T) -> Self { + Cow::from_borrowed(val) + } +} + +impl<'a, T> From> for Cow<'a, T> +where + T: Cowable + ?Sized, +{ + #[inline] + fn from(val: Arc) -> Self { + Cow::from_shared(val) + } +} + +impl<'a> From> for Cow<'a, str> { + #[inline] + fn from(s: std::borrow::Cow<'a, str>) -> Self { + match s { + std::borrow::Cow::Borrowed(bs) => Cow::from_borrowed(bs), + std::borrow::Cow::Owned(os) => Cow::from_owned(os), + } + } +} + +impl<'a, T: Cowable> From> for std::borrow::Cow<'a, T> { + #[inline] + fn from(value: Cow<'a, T>) -> Self { + match value.metadata.kind() { + Kind::Owned | Kind::Shared => Self::Owned(value.into_owned()), + Kind::Borrowed => { + // SAFETY: We know the contained data is borrowed from 'a, we're simply + // restoring the original immutable reference and returning a copy of it. + Self::Borrowed(unsafe { &*T::borrowed_from_parts(value.ptr, &value.metadata) }) + } + } + } +} + +impl From for Cow<'_, str> { + #[inline] + fn from(s: String) -> Self { + Cow::from_owned(s) + } +} + +impl From> for Cow<'_, [T]> +where + T: Clone, +{ + #[inline] + fn from(v: Vec) -> Self { + Cow::from_owned(v) + } +} + +impl AsRef for Cow<'_, T> +where + T: Cowable + ?Sized, +{ + #[inline] + fn as_ref(&self) -> &T { + self.borrow() + } +} + +impl Borrow for Cow<'_, T> +where + T: Cowable + ?Sized, +{ + #[inline] + fn borrow(&self) -> &T { + self.deref() + } +} + +impl PartialEq> for Cow<'_, A> +where + A: Cowable + ?Sized, + B: Cowable + ?Sized, + A: PartialEq, +{ + fn eq(&self, other: &Cow) -> bool { + self.deref() == other.deref() + } +} + +impl fmt::Debug for Cow<'_, T> +where + T: Cowable + fmt::Debug + ?Sized, +{ + #[inline] + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + self.deref().fmt(f) + } +} + +impl fmt::Display for Cow<'_, T> +where + T: Cowable + fmt::Display + ?Sized, +{ + #[inline] + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + self.deref().fmt(f) + } +} + +// SAFETY: `NonNull` is not `Send` or `Sync` by default, but we're asserting that `Cow` is so +// long as the underlying `T` is. +unsafe impl Sync for Cow<'_, T> {} +unsafe impl Send for Cow<'_, T> {} + +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Metadata(usize, usize); + +impl Metadata { + #[inline] + const fn len(&self) -> usize { + self.0 + } + + #[inline] + const fn capacity(&self) -> usize { + self.1 + } + + #[inline] + const fn kind(&self) -> Kind { + match (self.0, self.1) { + (_, usize::MAX) => Kind::Shared, + (_, 0) => Kind::Borrowed, + _ => Kind::Owned, + } + } + + #[inline] + const fn shared(len: usize) -> Metadata { + Metadata(len, usize::MAX) + } + + #[inline] + const fn borrowed(len: usize) -> Metadata { + Metadata(len, 0) + } + + #[inline] + const fn owned(len: usize, capacity: usize) -> Metadata { + Metadata(len, capacity) + } +} + +pub trait Cowable: ToOwned { + type Pointer; + + fn borrowed_into_parts(&self) -> (NonNull, Metadata); + fn owned_into_parts(owned: ::Owned) -> (NonNull, Metadata); + fn shared_into_parts(arc: Arc) -> (NonNull, Metadata); + + fn borrowed_from_parts(ptr: NonNull, metadata: &Metadata) -> *const Self; + fn owned_from_parts( + ptr: NonNull, + metadata: &Metadata, + ) -> ::Owned; + fn clone_from_parts( + ptr: NonNull, + metadata: &Metadata, + ) -> (NonNull, Metadata); + fn drop_from_parts(ptr: NonNull, metadata: &Metadata); +} + +impl Cowable for str { + type Pointer = u8; + + #[inline] + fn borrowed_into_parts(&self) -> (NonNull, Metadata) { + // SAFETY: We know that it's safe to take and hold a pointer to a reference to `Self` since + // `Cow` can only live as long as the input reference does, and an invalid pointer cannot + // be taken from a live reference. + let ptr = unsafe { NonNull::new_unchecked(self.as_ptr() as *mut _) }; + let metadata = Metadata::borrowed(self.len()); + (ptr, metadata) + } + + #[inline] + fn owned_into_parts(owned: Self::Owned) -> (NonNull, Metadata) { + // SAFETY: We know that it's safe to take and hold a pointer to a reference to `owned` since + // we own the allocation by virtue of consuming it here without dropping it. + let mut owned = ManuallyDrop::new(owned.into_bytes()); + let ptr = unsafe { NonNull::new_unchecked(owned.as_mut_ptr()) }; + let metadata = Metadata::owned(owned.len(), owned.capacity()); + (ptr, metadata) + } + + #[inline] + fn shared_into_parts(arc: Arc) -> (NonNull, Metadata) { + let metadata = Metadata::shared(arc.len()); + // SAFETY: We know that the pointer given back by `Arc::into_raw` is valid. + let ptr = unsafe { NonNull::new_unchecked(Arc::into_raw(arc) as *mut _) }; + (ptr, metadata) + } + + #[inline] + fn borrowed_from_parts(ptr: NonNull, metadata: &Metadata) -> *const Self { + slice_from_raw_parts(ptr.as_ptr(), metadata.len()) as *const _ + } + + #[inline] + fn owned_from_parts( + ptr: NonNull, + metadata: &Metadata, + ) -> ::Owned { + match metadata.kind() { + Kind::Borrowed => { + // SAFETY: We know that it's safe to take and hold a pointer to a reference to + // `Self` since `Cow` can only live as long as the input reference does, and an + // invalid pointer cannot be taken from a live reference. + let s = unsafe { &*Self::borrowed_from_parts(ptr, metadata) }; + s.to_owned() + } + + // SAFETY: We know that the pointer is valid because it could have only been constructed + // from a valid `String` handed to `Cow::from_owned`, which we assumed ownership of. + Kind::Owned => unsafe { + String::from_raw_parts(ptr.as_ptr(), metadata.len(), metadata.capacity()) + }, + Kind::Shared => { + // SAFETY: We know that the pointer is valid because it could have only been + // constructed from a valid `Arc` handed to `Cow::from_shared`, which we + // assumed ownership of, also ensuring that the strong count is at least one. + let s = unsafe { Arc::from_raw(Self::borrowed_from_parts(ptr, metadata)) }; + s.to_string() + } + } + } + + #[inline] + fn clone_from_parts( + ptr: NonNull, + metadata: &Metadata, + ) -> (NonNull, Metadata) { + match metadata.kind() { + Kind::Borrowed => (ptr, *metadata), + Kind::Owned => { + // SAFETY: We know that the pointer is valid because it could have only been constructed + // from a valid `String` handed to `Cow::from_owned`, which we assumed ownership of. + let s = unsafe { &*Self::borrowed_from_parts(ptr, metadata) }; + + Self::owned_into_parts(s.to_string()) + } + Kind::Shared => clone_shared::(ptr, metadata), + } + } + + #[inline] + fn drop_from_parts(ptr: NonNull, metadata: &Metadata) { + match metadata.kind() { + Kind::Borrowed => {} + + // SAFETY: We know that the pointer is valid because it could have only been constructed + // from a valid `String` handed to `Cow::from_owned`, which we assumed ownership of. + Kind::Owned => unsafe { + drop(Vec::from_raw_parts( + ptr.as_ptr(), + metadata.len(), + metadata.capacity(), + )); + }, + + // SAFETY: We know that the pointer is valid because it could have only been constructed + // from a valid `Arc` handed to `Cow::from_shared`, which we assumed ownership of, + // also ensuring that the strong count is at least one. + Kind::Shared => unsafe { + drop(Arc::from_raw(Self::borrowed_from_parts(ptr, metadata))); + }, + } + } +} + +impl Cowable for [T] +where + T: Clone, +{ + type Pointer = T; + + #[inline] + fn borrowed_into_parts(&self) -> (NonNull, Metadata) { + // SAFETY: We know that it's safe to take and hold a pointer to a reference to `Self` since + // `Cow` can only live as long as the input reference does, and an invalid pointer cannot + // be taken from a live reference. + let ptr = unsafe { NonNull::new_unchecked(self.as_ptr() as *mut _) }; + let metadata = Metadata::borrowed(self.len()); + (ptr, metadata) + } + + #[inline] + fn owned_into_parts(owned: ::Owned) -> (NonNull, Metadata) { + let mut owned = ManuallyDrop::new(owned); + + // SAFETY: We know that it's safe to take and hold a pointer to a reference to `owned` since + // we own the allocation by virtue of consuming it here without dropping it. + let ptr = unsafe { NonNull::new_unchecked(owned.as_mut_ptr()) }; + let metadata = Metadata::owned(owned.len(), owned.capacity()); + (ptr, metadata) + } + + #[inline] + fn shared_into_parts(arc: Arc) -> (NonNull, Metadata) { + let metadata = Metadata::shared(arc.len()); + // SAFETY: We know that the pointer given back by `Arc::into_raw` is valid. + let ptr = unsafe { NonNull::new_unchecked(Arc::into_raw(arc) as *mut _) }; + (ptr, metadata) + } + + #[inline] + fn borrowed_from_parts(ptr: NonNull, metadata: &Metadata) -> *const Self { + slice_from_raw_parts(ptr.as_ptr(), metadata.len()) as *const _ + } + + #[inline] + fn owned_from_parts( + ptr: NonNull, + metadata: &Metadata, + ) -> ::Owned { + match metadata.kind() { + Kind::Borrowed => { + // SAFETY: We know that it's safe to take and hold a pointer to a reference to + // `Self` since `Cow` can only live as long as the input reference does, and an + // invalid pointer cannot be taken from a live reference. + let data = unsafe { &*Self::borrowed_from_parts(ptr, metadata) }; + data.to_vec() + } + + // SAFETY: We know that the pointer is valid because it could have only been + // constructed from a valid `Vec` handed to `Cow::from_owned`, which we + // assumed ownership of. + Kind::Owned => unsafe { + Vec::from_raw_parts(ptr.as_ptr(), metadata.len(), metadata.capacity()) + }, + + Kind::Shared => { + // SAFETY: We know that the pointer is valid because it could have only been + // constructed from a valid `Arc<[T]>` handed to `Cow::from_shared`, which we + // assumed ownership of, also ensuring that the strong count is at least one. + let arc = unsafe { Arc::from_raw(Self::borrowed_from_parts(ptr, metadata)) }; + arc.to_vec() + } + } + } + + #[inline] + fn clone_from_parts( + ptr: NonNull, + metadata: &Metadata, + ) -> (NonNull, Metadata) { + match metadata.kind() { + Kind::Borrowed => (ptr, *metadata), + Kind::Owned => { + let vec_ptr = Self::borrowed_from_parts(ptr, metadata); + + // SAFETY: We know that the pointer is valid because it could have only been + // constructed from a valid `Vec` handed to `Cow::from_owned`, which we assumed + // ownership of. + let new_vec = unsafe { vec_ptr.as_ref().unwrap().to_vec() }; + + Self::owned_into_parts(new_vec) + } + Kind::Shared => clone_shared::(ptr, metadata), + } + } + + #[inline] + fn drop_from_parts(ptr: NonNull, metadata: &Metadata) { + match metadata.kind() { + Kind::Borrowed => {} + + // SAFETY: We know that the pointer is valid because it could have only been constructed + // from a valid `Vec` handed to `Cow::from_owned`, which we assumed ownership of. + Kind::Owned => unsafe { + drop(Vec::from_raw_parts( + ptr.as_ptr(), + metadata.len(), + metadata.capacity(), + )); + }, + + // SAFETY: We know that the pointer is valid because it could have only been constructed + // from a valid `Arc<[T]>` handed to `Cow::from_shared`, which we assumed ownership of, + // also ensuring that the strong count is at least one. + Kind::Shared => unsafe { + drop(Arc::from_raw(Self::borrowed_from_parts(ptr, metadata))); + }, + } + } +} + +fn clone_shared( + ptr: NonNull, + metadata: &Metadata, +) -> (NonNull, Metadata) { + let arc_ptr = T::borrowed_from_parts(ptr, metadata); + + // SAFETY: We know that the pointer is valid because it could have only been + // constructed from a valid `Arc` handed to `Cow::from_shared`, which we assumed + // ownership of, also ensuring that the strong count is at least one. + unsafe { + Arc::increment_strong_count(arc_ptr); + } + + (ptr, *metadata) +} diff --git a/crates/lib/ty/src/storage.rs b/crates/lib/ty/src/storage.rs new file mode 100644 index 0000000000..2902835da8 --- /dev/null +++ b/crates/lib/ty/src/storage.rs @@ -0,0 +1,400 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +use std::mem; +use std::sync::Arc; + +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use downcast_rs::{DowncastSync, impl_downcast}; +use serde::Deserialize; + +use restate_encoding::{BilrostAs, NetSerde}; + +use crate::errors::GenericError; + +#[derive(Debug, thiserror::Error)] +pub enum StorageEncodeError { + #[error("encoding failed: {0}")] + EncodeValue(GenericError), +} + +#[derive(Debug, thiserror::Error)] +pub enum StorageDecodeError { + #[error("failed reading codec: {0}")] + ReadingCodec(String), + #[error("decoding failed: {0}")] + DecodeValue(GenericError), + #[error("unsupported codec kind: {0}")] + UnsupportedCodecKind(StorageCodecKind), +} + +#[derive( + Debug, Copy, Clone, strum::FromRepr, derive_more::Display, PartialEq, Eq, bilrost::Enumeration, +)] +#[repr(u8)] +pub enum StorageCodecKind { + /// plain old protobuf + Protobuf = 1, + /// flexbuffers + serde (length-prefixed) + FlexbuffersSerde = 2, + /// length-prefixed raw-bytes. length is u32 + LengthPrefixedRawBytes = 3, + /// bincode (with serde compatibility mode, no length prefix) + BincodeSerde = 4, + /// Json (no length prefix) + Json = 5, + /// Bilrost (no length-prefixed) + Bilrost = 6, + /// A custom encoding that does not rely on any of the standard encoding formats + /// supported by the [`encode`] and [`decode`] modules. + /// + /// When using this variant, the encoding and decoding logic is entirely defined + /// by the implementation of the [`StorageEncode`] and [`StorageDecode`] traits. + /// + /// While you may still use utility functions from the [`encode`] and [`decode`] modules, + /// it is up to your implementation to decide how (or if) to use them, and how the final + /// byte representation is constructed. + Custom = 7, +} + +impl From for u8 { + fn from(value: StorageCodecKind) -> Self { + value as u8 + } +} + +impl TryFrom for StorageCodecKind { + type Error = StorageDecodeError; + + fn try_from(value: u8) -> Result { + StorageCodecKind::from_repr(value).ok_or_else(|| { + StorageDecodeError::ReadingCodec(format!("unknown discriminant '{value}'")) + }) + } +} + +/// Codec which encodes [`StorageEncode`] implementations by first writing the +/// [`StorageEncode::default_codec`] byte and then encoding the value part via +/// [`StorageEncode::encode`]. +/// +/// To decode a value, the codec first reads the codec bytes and then calls +/// [`StorageDecode::decode`] providing the read codec. +pub struct StorageCodec; + +impl StorageCodec { + pub fn encode( + value: &T, + buf: &mut BytesMut, + ) -> Result<(), StorageEncodeError> { + // write codec + buf.put_u8(value.default_codec().into()); + // encode value + value.encode(buf) + } + + pub fn encode_and_split( + value: &T, + buf: &mut BytesMut, + ) -> Result { + Self::encode(value, buf)?; + Ok(buf.split()) + } + + pub fn decode(buf: &mut B) -> Result { + if buf.remaining() < mem::size_of::() { + return Err(StorageDecodeError::ReadingCodec(format!( + "remaining bytes in buf '{}' < version bytes '{}'", + buf.remaining(), + mem::size_of::() + ))); + } + + // read version + let codec = StorageCodecKind::try_from(buf.get_u8())?; + + // decode value + T::decode(buf, codec) + } +} + +/// Trait to encode a value using the specified [`Self::default_codec`]. The trait is used by the +/// [`StorageCodec`] to first write the codec byte and then the serialized value via +/// [`Self::encode`]. +/// +/// # Important +/// The [`Self::encode`] implementation should use the codec specified by [`Self::default_codec`]. +pub trait StorageEncode: DowncastSync { + fn encode(&self, buf: &mut BytesMut) -> Result<(), StorageEncodeError>; + + /// Codec which is used when encode new values. + fn default_codec(&self) -> StorageCodecKind; +} +impl_downcast!(sync StorageEncode); + +static_assertions::assert_obj_safe!(StorageEncode); + +/// Trait to decode a value given the [`StorageCodecKind`]. This trait is used by the +/// [`StorageCodec`] to decode a value after reading the used storage codec. +/// +/// # Important +/// To support codec evolution, this trait implementation needs to be able to decode values encoded +/// with any previously used codec. +pub trait StorageDecode { + fn decode(buf: &mut B, kind: StorageCodecKind) -> Result + where + Self: Sized; +} + +/// A polymorphic container of a buffer or a cached storage-encodeable object +#[derive(Clone, derive_more::Debug, BilrostAs)] +#[bilrost_as(dto::PolyBytes)] +pub enum PolyBytes { + /// Raw bytes backed by (Bytes), so it's cheap to clone + #[debug("Bytes({} bytes)", _0.len())] + Bytes(bytes::Bytes), + /// A cached deserialized value that can be downcasted to the original type + #[debug("Typed")] + Typed(Arc), +} + +/// Required by the BilrostAs type for +/// the `empty state` +impl Default for PolyBytes { + fn default() -> Self { + Self::Bytes(bytes::Bytes::default()) + } +} +// implement NetSerde for PolyBytes manually +impl NetSerde for PolyBytes {} + +impl PolyBytes { + /// Returns true if we are holding raw encoded bytes + pub fn is_encoded(&self) -> bool { + matches!(self, PolyBytes::Bytes(_)) + } + + pub fn estimated_encode_size(&self) -> usize { + match self { + PolyBytes::Bytes(bytes) => bytes.len(), + PolyBytes::Typed(_) => { + // constant, assumption based on base envelope size of ~600 bytes. + // todo: use StorageEncode trait to get an actual estimate based + // on the underlying type + 2_048 // 2KiB + } + } + } + + pub fn encode_to_bytes(&self, scratch: &mut BytesMut) -> Result { + match self { + PolyBytes::Bytes(bytes) => Ok(bytes.clone()), + PolyBytes::Typed(typed) => { + // note: this currently doesn't do a good job of estimating the size but it's better than + // nothing. + scratch.reserve(self.estimated_encode_size()); + StorageCodec::encode(&**typed, scratch)?; + Ok(scratch.split().freeze()) + } + } + } +} + +impl StorageEncode for PolyBytes { + fn encode(&self, buf: &mut BytesMut) -> Result<(), StorageEncodeError> { + match self { + PolyBytes::Bytes(bytes) => buf.put_slice(bytes.as_ref()), + PolyBytes::Typed(typed) => { + StorageCodec::encode(&**typed, buf)?; + } + }; + Ok(()) + } + + fn default_codec(&self) -> StorageCodecKind { + StorageCodecKind::FlexbuffersSerde + } +} + +/// SerializeAs/DeserializeAs to implement ser/de trait for [`PolyBytes`] +/// Use it with `#[serde(with = "serde_with::As::")]`. +pub struct EncodedPolyBytes {} + +impl serde_with::SerializeAs for EncodedPolyBytes { + fn serialize_as(source: &PolyBytes, serializer: S) -> Result + where + S: serde::Serializer, + { + match source { + PolyBytes::Bytes(bytes) => serializer.serialize_bytes(bytes.as_ref()), + PolyBytes::Typed(typed) => { + // todo: estimate size to avoid re allocations + let mut buf = BytesMut::new(); + StorageCodec::encode(&**typed, &mut buf).expect("record serde is infallible"); + serializer.serialize_bytes(buf.as_ref()) + } + } + } +} + +impl<'de> serde_with::DeserializeAs<'de, PolyBytes> for EncodedPolyBytes { + fn deserialize_as(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let buf = Bytes::deserialize(deserializer)?; + Ok(PolyBytes::Bytes(buf)) + } +} + +static_assertions::assert_impl_all!(PolyBytes: Send, Sync); + +/// Enable simple serialization of String types as length-prefixed byte slice +impl StorageEncode for String { + fn default_codec(&self) -> StorageCodecKind { + StorageCodecKind::LengthPrefixedRawBytes + } + + fn encode(&self, buf: &mut BytesMut) -> Result<(), StorageEncodeError> { + let my_bytes = self.as_bytes(); + buf.put_u32_le(u32::try_from(my_bytes.len()).map_err(|_| { + StorageEncodeError::EncodeValue("only support serializing types of size <= 4GB".into()) + })?); + if buf.remaining_mut() < my_bytes.len() { + return Err(StorageEncodeError::EncodeValue( + format!( + "not enough buffer space to serialize value;\ + required {} bytes but free capacity was {}", + my_bytes.len(), + buf.remaining_mut() + ) + .into(), + )); + } + buf.put_slice(my_bytes); + Ok(()) + } +} + +impl StorageDecode for String { + fn decode( + buf: &mut B, + kind: StorageCodecKind, + ) -> Result + where + Self: Sized, + { + match kind { + StorageCodecKind::LengthPrefixedRawBytes => { + if buf.remaining() < mem::size_of::() { + return Err(StorageDecodeError::DecodeValue( + format!( + "insufficient data: expecting {} bytes for length", + mem::size_of::() + ) + .into(), + )); + } + let length = usize::try_from(buf.get_u32_le()).expect("u32 to fit into usize"); + + if buf.remaining() < length { + return Err(StorageDecodeError::DecodeValue( + format!( + "insufficient data: expecting {} bytes for flexbuffers", + length + ) + .into(), + )); + } + + let bytes = buf.take(length); + Ok(String::from_utf8_lossy(bytes.chunk()).to_string()) + } + codec => Err(StorageDecodeError::UnsupportedCodecKind(codec)), + } + } +} + +// Enable simple serialization of Bytes types as length-prefixed byte slice +impl StorageEncode for bytes::Bytes { + fn default_codec(&self) -> StorageCodecKind { + StorageCodecKind::LengthPrefixedRawBytes + } + + fn encode(&self, buf: &mut BytesMut) -> Result<(), StorageEncodeError> { + buf.put_u32_le(u32::try_from(self.len()).map_err(|_| { + StorageEncodeError::EncodeValue("only support serializing types of size <= 4GB".into()) + })?); + if buf.remaining_mut() < self.len() { + return Err(StorageEncodeError::EncodeValue( + format!( + "not enough buffer space to serialize value;\ + required {} bytes but free capacity was {}", + self.len(), + buf.remaining_mut() + ) + .into(), + )); + } + buf.put_slice(&self[..]); + Ok(()) + } +} + +mod dto { + use bytes::{Bytes, BytesMut}; + + use super::StorageCodec; + + #[derive(bilrost::Message)] + pub struct PolyBytes { + #[bilrost(1)] + inner: Bytes, + } + + impl From<&super::PolyBytes> for PolyBytes { + fn from(value: &super::PolyBytes) -> Self { + let inner = match value { + super::PolyBytes::Bytes(bytes) => bytes.clone(), + super::PolyBytes::Typed(typed) => { + let mut buf = BytesMut::new(); + StorageCodec::encode(&**typed, &mut buf).expect("PolyBytes to serialize"); + buf.freeze() + } + }; + + Self { inner } + } + } + + impl From for super::PolyBytes { + fn from(value: PolyBytes) -> Self { + Self::Bytes(value.inner) + } + } +} + +#[cfg(test)] +mod tests { + use bytes::Bytes; + + use super::*; + use std::sync::Arc; + + #[test] + fn test_polybytes() { + let bytes = PolyBytes::Bytes(Bytes::from_static(b"hello")); + assert_eq!(format!("{bytes:?}"), "Bytes(5 bytes)"); + let typed = PolyBytes::Typed(Arc::new("hello".to_string())); + assert_eq!(format!("{typed:?}"), "Typed"); + // can be downcasted. + let a: Arc = Arc::new("hello".to_string()); + assert!(a.is::()); + } +} diff --git a/crates/lib/ty/src/version.rs b/crates/lib/ty/src/version.rs new file mode 100644 index 0000000000..583a204018 --- /dev/null +++ b/crates/lib/ty/src/version.rs @@ -0,0 +1,94 @@ +// Copyright (c) 2023 - 2025 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +use restate_encoding::NetSerde; + +/// A type used for versioned metadata. +#[derive( + Clone, + Copy, + PartialEq, + Eq, + Ord, + PartialOrd, + derive_more::Display, + derive_more::From, + derive_more::Into, + derive_more::Debug, + derive_more::AddAssign, + serde::Serialize, + serde::Deserialize, + restate_encoding::BilrostNewType, + NetSerde, +)] +#[display("v{}", _0)] +#[debug("v{}", _0)] +pub struct Version(u32); + +impl Version { + pub const INVALID: Version = Version(0); + pub const MIN: Version = Version(1); + + pub fn next(self) -> Self { + Version(self.0 + 1) + } + + #[cfg(feature = "test-util")] + pub fn prev(self) -> Self { + Version(self.0.saturating_sub(1)) + } + + pub fn invalid() -> Self { + Version::INVALID + } +} + +impl From for Version { + fn from(version: crate::protobuf::Version) -> Self { + crate::Version::from(version.value) + } +} + +impl From for crate::protobuf::Version { + fn from(version: Version) -> Self { + crate::protobuf::Version { + value: version.into(), + } + } +} + +/// A trait for all metadata types that have a version. +pub trait Versioned { + /// Returns the version of the versioned value + fn version(&self) -> Version; + + /// Is this a valid version? + fn valid(&self) -> bool { + self.version() >= Version::MIN + } +} + +impl Versioned for &T { + fn version(&self) -> Version { + (**self).version() + } +} + +impl Versioned for &mut T { + fn version(&self) -> Version { + (**self).version() + } +} + +impl Versioned for Box { + fn version(&self) -> Version { + (**self).version() + } +} diff --git a/crates/types/Cargo.toml b/crates/types/Cargo.toml index 8def740306..9cf7f237be 100644 --- a/crates/types/Cargo.toml +++ b/crates/types/Cargo.toml @@ -21,6 +21,7 @@ multi-db = [] [dependencies] restate-workspace-hack = { workspace = true } +restate-ty = { path = "../lib/ty" } restate-base64-util = { workspace = true } restate-encoding = { workspace = true } restate-errors = { workspace = true } diff --git a/server/Cargo.toml b/server/Cargo.toml index db431829ec..fa503bb351 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -74,6 +74,7 @@ restate-metadata-providers = { workspace = true, features = ["replicated"] } restate-node = { workspace = true, features = ["memory-loglet"] } restate-test-util = { workspace = true } restate-types = { workspace = true, features = ["test-util"] } +restate-object-store-util = { workspace = true, features = ["test-util"] } mock-service-endpoint = { workspace = true } anyhow = { workspace = true } diff --git a/workspace-hack/Cargo.toml b/workspace-hack/Cargo.toml index 308643a974..4056749f29 100644 --- a/workspace-hack/Cargo.toml +++ b/workspace-hack/Cargo.toml @@ -72,7 +72,7 @@ hyper-util = { version = "0.1", features = ["full"] } idna = { version = "1" } indexmap-dff4ba8e3ae991db = { package = "indexmap", version = "1", default-features = false, features = ["serde-1"] } indexmap-f595c2ba2a3f28df = { package = "indexmap", version = "2", features = ["serde"] } -itertools-582f2526e08bb6a0 = { package = "itertools", version = "0.14" } +itertools = { version = "0.14" } lexical-parse-float = { version = "1", features = ["format"] } lexical-parse-integer = { version = "1", default-features = false, features = ["format", "std"] } lexical-util = { version = "1", default-features = false, features = ["format", "parse-floats", "parse-integers", "std", "write-floats", "write-integers"] } @@ -114,6 +114,7 @@ serde_core = { version = "1", default-features = false, features = ["alloc", "rc serde_json = { version = "1", features = ["alloc", "raw_value", "unbounded_depth"] } serde_with = { version = "3", features = ["hex", "json"] } smallvec = { version = "1", default-features = false, features = ["const_new", "serde", "union"] } +smartstring = { version = "1", features = ["serde"] } sqlparser = { version = "0.58", default-features = false, features = ["recursive-protection", "visitor"] } stable_deref_trait = { version = "1" } syn = { version = "2", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] } @@ -171,6 +172,8 @@ clap_builder = { version = "4", default-features = false, features = ["color", " comfy-table = { version = "7" } criterion = { version = "0.5", features = ["async_tokio"] } crossbeam-epoch = { version = "0.9" } +darling = { version = "0.20" } +darling_core = { version = "0.20", default-features = false, features = ["suggestions"] } datafusion-common = { version = "50", default-features = false, features = ["object_store", "recursive_protection"] } datafusion-expr = { version = "50", default-features = false, features = ["recursive_protection"] } digest = { version = "0.10", features = ["mac", "std"] } @@ -200,8 +203,7 @@ hyper-util = { version = "0.1", features = ["full"] } idna = { version = "1" } indexmap-dff4ba8e3ae991db = { package = "indexmap", version = "1", default-features = false, features = ["serde-1"] } indexmap-f595c2ba2a3f28df = { package = "indexmap", version = "2", features = ["serde"] } -itertools-582f2526e08bb6a0 = { package = "itertools", version = "0.14" } -itertools-594e8ee84c453af0 = { package = "itertools", version = "0.13" } +itertools = { version = "0.14" } lexical-parse-float = { version = "1", features = ["format"] } lexical-parse-integer = { version = "1", default-features = false, features = ["format", "std"] } lexical-util = { version = "1", default-features = false, features = ["format", "parse-floats", "parse-integers", "std", "write-floats", "write-integers"] } @@ -244,6 +246,7 @@ serde_core = { version = "1", default-features = false, features = ["alloc", "rc serde_json = { version = "1", features = ["alloc", "raw_value", "unbounded_depth"] } serde_with = { version = "3", features = ["hex", "json"] } smallvec = { version = "1", default-features = false, features = ["const_new", "serde", "union"] } +smartstring = { version = "1", features = ["serde"] } sqlparser = { version = "0.58", default-features = false, features = ["recursive-protection", "visitor"] } stable_deref_trait = { version = "1" } syn = { version = "2", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] }