Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -179,22 +179,22 @@ path = "keetanetwork-ledger"
default-features = false

[workspace.dependencies.keetanetwork-client]
version = "0.5.0"
version = "0.5.1"
path = "keetanetwork-client"
default-features = false

[workspace.dependencies.keetanetwork-bindings]
version = "0.4.3"
version = "0.4.4"
path = "keetanetwork-bindings"
default-features = false

[workspace.dependencies.keetanetwork-client-wasi]
version = "0.6.0"
version = "0.6.1"
path = "keetanetwork-client-wasi"
default-features = false

[workspace.dependencies.keetanetwork-client-wasm]
version = "0.5.0"
version = "0.5.1"
path = "keetanetwork-client-wasm"
default-features = false

Expand Down
2 changes: 1 addition & 1 deletion keetanetwork-bindings/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "keetanetwork-bindings"
version = "0.4.3"
version = "0.4.4"
edition.workspace = true
authors.workspace = true
license.workspace = true
Expand Down
2 changes: 1 addition & 1 deletion keetanetwork-client-wasi/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "keetanetwork-client-wasi"
version = "0.6.0"
version = "0.6.1"
edition.workspace = true
authors.workspace = true
license.workspace = true
Expand Down
2 changes: 1 addition & 1 deletion keetanetwork-client-wasm/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "keetanetwork-client-wasm"
version = "0.5.0"
version = "0.5.1"
edition.workspace = true
authors.workspace = true
license.workspace = true
Expand Down
3 changes: 1 addition & 2 deletions keetanetwork-client/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "keetanetwork-client"
version = "0.5.0"
version = "0.5.1"
edition.workspace = true
authors.workspace = true
license.workspace = true
Expand Down Expand Up @@ -46,7 +46,6 @@ keetanetwork-account = { workspace = true, features = ["alloc", "rasn"] }
chrono = { workspace = true }
progenitor-client = { workspace = true, optional = true }
num-bigint = { workspace = true }
rand_core = { workspace = true }
spin = { workspace = true, features = ["mutex", "spin_mutex", "rwlock", "once"] }
async-trait = { workspace = true }
futures = { workspace = true, features = ["alloc"] }
Expand Down
23 changes: 7 additions & 16 deletions keetanetwork-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ use alloc::sync::Arc;
use alloc::vec::Vec;
use core::future::Future;
use core::str::FromStr;
use core::sync::atomic::{AtomicU64, Ordering};
use core::time::Duration;

use futures::future::{select, Either};
Expand All @@ -31,7 +30,7 @@ use crate::model::{
AccountState, Acl, Certificate, ChainPage, ChainQuery, HistoryEntry, HistoryPage, HistoryQuery, LedgerChecksum,
Representative, TokenBalance, TransmitOptions,
};
use crate::rep::{RepBook, RepPart, RepRecord, RepRef, SmallRng};
use crate::rep::{RepBook, RepPart, RepRecord, RepRef};
use crate::runtime::{Runtime, TaskHandle};
use crate::sync::{Mutex, RwLock};
use crate::transport::{LedgerSide, NodeTransport, TransportFactory};
Expand Down Expand Up @@ -115,9 +114,6 @@ struct Inner {
/// discovery transport-agnostic.
factory: Arc<dyn TransportFactory>,
runtime: Arc<dyn Runtime>,
/// Per-pick counter mixed with the runtime clock to seed selection's
/// [`SmallRng`] so successive picks differ.
rng_counter: AtomicU64,
network: RwLock<Option<BigInt>>,
subnet: RwLock<Option<BigInt>>,
/// `true` for the single anonymous-rep client built by [`KeetaClient::new`];
Expand All @@ -137,9 +133,8 @@ impl Drop for Inner {

/// Async, durable client for a KeetaNet network.
///
/// Talks to a set of representatives: reads pick one rep (power-of-two
/// choices, weighted by reliability) with retry, backoff, and timeout; votes,
/// quotes, and publishes fan out to every rep and aggregate by quorum weight.
/// Talks to a set of representatives: reads go to the rep with the highest
/// effective score with retry, backoff, and timeout.
///
/// See the [crate-level example](crate) for building and transmitting a block.
#[derive(Clone, Debug)]
Expand Down Expand Up @@ -206,8 +201,8 @@ impl KeetaClient {
}

/// Create a multi-representative client over `reps`, fanning votes and
/// publishes across them and selecting reps for reads by weighted
/// reliability.
/// publishes across them and routing reads to the rep with the highest
/// effective score (voting weight scaled by reliability).
#[cfg(feature = "http")]
pub fn with_representatives(reps: impl IntoIterator<Item = RepEndpoint>, config: ClientConfig) -> Self {
let http = reqwest::Client::new();
Expand Down Expand Up @@ -256,7 +251,6 @@ impl KeetaClient {
config,
factory,
runtime,
rng_counter: AtomicU64::new(0),
network: RwLock::new(None),
subnet: RwLock::new(None),
single_rep,
Expand Down Expand Up @@ -285,12 +279,9 @@ impl KeetaClient {
self.bind_transports(self.inner.reps.snapshot())
}

/// Select one representative bound to its transport.
/// Select the current best-scored representative bound to its transport.
fn pick_target(&self) -> Option<RepPick> {
let now = self.inner.runtime.now_millis();
let counter = self.inner.rng_counter.fetch_add(1, Ordering::Relaxed);
let mut rng = SmallRng::seed_from_u64(now ^ counter);
let chosen = self.inner.reps.pick(&mut rng)?;
let chosen = self.inner.reps.pick()?;
let transports = self.inner.transports.read();
transports.get(&chosen.key).map(|transport| RepPick {
key: chosen.key,
Expand Down
4 changes: 2 additions & 2 deletions keetanetwork-client/src/math.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ pub fn weight_fraction(weight: &BigInt, total: &BigInt, count: usize) -> f64 {
bigint_ratio(weight, total)
}

/// The Power-of-Two-Choices effective score: weight fraction scaled by
/// reliability.
/// The selection effective score: weight fraction scaled by reliability.
/// The highest-scored representative serves reads.
#[must_use]
pub fn selection_score(weight_fraction: f64, reliability: f64) -> f64 {
weight_fraction * reliability
Expand Down
124 changes: 55 additions & 69 deletions keetanetwork-client/src/rep.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ use alloc::string::String;
use alloc::vec::Vec;

use num_bigint::BigInt;
use rand_core::RngCore;

use crate::math::{reliability_after_failure, reliability_after_success, selection_score, weight_fraction};
use crate::sync::RwLock;
Expand Down Expand Up @@ -130,30 +129,21 @@ impl RepState {
self.reps.iter().map(|rep| rep.weight.clone()).sum()
}

/// Select one representative using Power of Two Choices: pick two random
/// indices and return the one with the higher effective score
/// (`weight_fraction * reliability`). Equal indices return that rep
/// directly, guaranteeing every rep a `1/n^2` baseline.
fn pick(&self, rng: &mut impl RngCore) -> Option<RepRef> {
let count = self.reps.len();
if count == 0 {
return None;
}

let chosen = if count == 1 {
&self.reps[0]
} else {
let total = self.total_weight();
let index_a = (rng.next_u32() as usize) % count;
let index_b = (rng.next_u32() as usize) % count;
if self.effective_score(&self.reps[index_a], &total) >= self.effective_score(&self.reps[index_b], &total) {
&self.reps[index_a]
} else {
&self.reps[index_b]
}
};
/// Select the representative with the highest effective score.
fn pick(&self) -> Option<RepRef> {
let total = self.total_weight();
let leader = self
.reps
.iter()
.fold(None::<(&RepRecord, f64)>, |best, rep| {
let score = self.effective_score(rep, &total);
match best {
Some((_, top)) if top >= score => best,
_ => Some((rep, score)),
}
});

Some(RepRef { key: chosen.key.clone(), weight: chosen.weight.clone() })
leader.map(|(chosen, _)| RepRef { key: chosen.key.clone(), weight: chosen.weight.clone() })
}

fn effective_score(&self, rep: &RepRecord, total: &BigInt) -> f64 {
Expand Down Expand Up @@ -224,56 +214,15 @@ impl RepBook {
(state.snapshot(), state.total_weight())
}

pub(crate) fn pick(&self, rng: &mut impl RngCore) -> Option<RepRef> {
self.state.read().pick(rng)
pub(crate) fn pick(&self) -> Option<RepRef> {
self.state.read().pick()
}

pub(crate) fn update_weights(&self, fetched: &[(String, BigInt)]) {
self.state.write().update_weights(fetched);
}
}

/// A minimal `no_std` PRNG (`splitmix64`) for Power-of-Two-Choices selection.
///
/// Selection needs only spread, not cryptographic randomness, so a tiny
/// seed-able generator replaces the std `rand` thread RNG and keeps rep
/// selection `no_std`. Seed it per pick from a monotonic clock mixed with a
/// counter so successive picks differ.
pub(crate) struct SmallRng(u64);

impl SmallRng {
/// A generator seeded from `seed`.
pub(crate) fn seed_from_u64(seed: u64) -> Self {
Self(seed)
}
}

impl RngCore for SmallRng {
fn next_u64(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}

fn next_u32(&mut self) -> u32 {
(self.next_u64() >> 32) as u32
}

fn fill_bytes(&mut self, dest: &mut [u8]) {
let mut chunks = dest.chunks_exact_mut(8);
for chunk in &mut chunks {
chunk.copy_from_slice(&self.next_u64().to_le_bytes());
}
let remainder = chunks.into_remainder();
if !remainder.is_empty() {
let bytes = self.next_u64().to_le_bytes();
remainder.copy_from_slice(&bytes[..remainder.len()]);
}
}
}

/// A representative the client can talk to: its API endpoint, account, and
/// voting weight.
#[cfg(feature = "http")]
Expand Down Expand Up @@ -346,14 +295,51 @@ mod tests {
#[test]
fn pick_returns_the_only_rep() {
let state = RepState::new(vec![record("solo", 1)]);
let pick = state.pick(&mut SmallRng::seed_from_u64(1));
let pick = state.pick();
assert!(matches!(pick, Some(chosen) if chosen.key == "solo"));
}

#[test]
fn pick_on_empty_state_is_none() {
let state = RepState::new(Vec::new());
assert!(state.pick(&mut SmallRng::seed_from_u64(1)).is_none());
assert!(state.pick().is_none());
}

#[test]
fn pick_always_returns_the_highest_weight_rep() {
let state = RepState::new(vec![record("light", 1), record("heavy", 99)]);

let heavy_picks = (0..100)
.filter(|_| matches!(state.pick(), Some(chosen) if chosen.key == "heavy"))
.count();

assert_eq!(heavy_picks, 100);
}

#[test]
fn pick_fails_over_when_the_leader_reliability_decays() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this necessary for now? If a highest weight rep is unreliable doesn't seem like the network will be able to accomplish much.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if in the future highest weight is ~20% and second is ~19%? I think we can leave it for now and figure out something better later.

let mut state = RepState::new(vec![record("heavy", 99), record("light", 1)]);

// 0.99 weight * 0.01 reliability < 0.01 weight * 1.0 reliability.
state.decay("heavy", 0.01, 0.001);

assert!(matches!(state.pick(), Some(chosen) if chosen.key == "light"));
}

#[test]
fn pick_restores_the_leader_once_its_reliability_recovers() {
let mut state = RepState::new(vec![record("heavy", 99), record("light", 1)]);
state.decay("heavy", 0.01, 0.001);

state.boost("heavy", 1.0);

assert!(matches!(state.pick(), Some(chosen) if chosen.key == "heavy"));
}

#[test]
fn pick_keeps_the_earlier_rep_on_a_score_tie() {
let state = RepState::new(vec![record("first", 5), record("second", 5)]);
assert!(matches!(state.pick(), Some(chosen) if chosen.key == "first"));
}

#[test]
Expand Down
Loading
Loading