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
66 changes: 65 additions & 1 deletion src/core/lookup/array_lookup_table.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::core::lookup::{LookupTable, LookupTableLevel};
use crate::core::lookup::{LinkOutcome, LookupTable, LookupTableLevel};
use crate::core::model;
use crate::core::model::direction::Direction;
use crate::core::model::identity::Identity;
Expand Down Expand Up @@ -166,6 +166,70 @@ impl LookupTable for ArrayLookupTable {
Ok(entry)
}

/// Atomically decides whether `candidate` becomes the neighbor at `(level, direction)`, or
/// whether the existing entry there already sits strictly between this node and `candidate`
/// and the request should be forwarded. Runs entirely under a single `inner.write()` guard —
/// the compare, the decision, and the (conditional) write all happen under one lock
/// acquisition.
///
/// This method never calls `get_entry`/`update_entry`, whose separately-locked critical
/// sections could not be composed into one atomic decision: two concurrent callers linking
/// the same `(level, direction)` slot could both read the same stale entry under their own
/// `get_entry` call, both independently decide to insert, and both call `update_entry` —
/// the second silently clobbers the first, with no forwarding ever evaluated against the
/// true post-first-write state.
fn try_link(
&self,
level: LookupTableLevel,
direction: Direction,
candidate: Identity,
) -> anyhow::Result<LinkOutcome> {
if level >= LOOKUP_TABLE_LEVELS {
return Err(anyhow!(
"position is larger than the max lookup table entry number: {}",
level
));
}

let mut inner = self.inner.write();

let existing = match direction {
Direction::Left => inner.left[level],
Direction::Right => inner.right[level],
};

// an existing entry sits strictly between this node and the candidate when, for
// Direction::Right, existing.id() < candidate.id(); for Direction::Left,
// existing.id() > candidate.id() — it is then closer to the candidate's true position
// than this node is, so the slot is left untouched and the decision is to forward.
let outcome = match (existing, direction) {
(Some(existing), Direction::Right) if existing.id() < candidate.id() => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This accept-vs-forward decision compares only existing.id() vs candidate.id(); it can't check candidate against this node's id, because the lookup table doesn't store it. So it silently trusts the caller to pass a candidate on the correct side of this node for direction (Right ⇒ candidate.id() > self.id(), Left ⇒ <).

If a caller violates that (e.g. a candidate.id() < self.id() passed into a Right slot), this comparison is meaningless and an out-of-order neighbor is installed silently — table corruption rather than an error.

Suggest a /// precondition line on try_link, and optionally a debug_assert! at the call site (where self's id is known) so misuse fails loudly in tests instead of silently corrupting the table. Non-blocking.

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.

Good catch, thanks. Confirmed against docs/protocol/concurrent-insert.md §4.1/§4.3: the spec's own change_neighbor pseudocode has the same shape — it only ever compares the existing entry against the candidate, never the candidate against self. So try_link is deliberately a low-level, trusted primitive; the caller (the not-yet-written GetLinkOp/BuddyOp handler) owns the "candidate is on the correct side" guarantee.

Added a # Preconditions doc section on the trait in 731a077 spelling this out. Held off on the debug_assert! — there's no call site yet (this PR only adds the primitive, nothing wires it in), and it can't live inside try_link itself since ArrayLookupTable has no notion of its own owner's id by design. Makes sense to add the assert at the actual call site once the handler lands.

LinkOutcome::Forward(existing)
}
(Some(existing), Direction::Left) if existing.id() > candidate.id() => {
LinkOutcome::Forward(existing)
}
_ => {
match direction {
Direction::Left => inner.left[level] = Some(candidate),
Direction::Right => inner.right[level] = Some(candidate),
}
LinkOutcome::LinkedDirectly
}
};

// Log the try_link decision
tracing::trace!(
"try_link decision at level {} in direction {}: candidate {}, outcome {:?}",
level,
direction,
candidate.id(),
outcome
);

Ok(outcome)
}

/// Dynamically compares the lookup table with another for equality.
/// This is a deep comparison of the entries in the table.
/// Returns true if the entries are equal, false otherwise.
Expand Down
110 changes: 109 additions & 1 deletion src/core/lookup/array_lookup_table_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ mod tests {
use crate::core::model::direction::Direction;
use crate::core::model::identity::Identity;
use crate::core::testutil::fixtures::*;
use crate::core::{model, ArrayLookupTable, LookupTable, LOOKUP_TABLE_LEVELS};
use crate::core::{model, ArrayLookupTable, LinkOutcome, LookupTable, LOOKUP_TABLE_LEVELS};
use std::collections::HashMap;

#[test]
Expand Down Expand Up @@ -94,6 +94,114 @@ mod tests {
assert_eq!(Some(id2), lt.get_entry(0, Direction::Left).unwrap());
}

/// (a) try_link into an empty slot links directly and inserts the candidate, on both sides.
#[test]
fn test_try_link_empty_slot_links_directly() {
let lt = ArrayLookupTable::new();
let right_candidate = random_identity();
let left_candidate = random_identity();

let outcome = lt.try_link(0, Direction::Right, right_candidate).unwrap();
assert_eq!(outcome, LinkOutcome::LinkedDirectly);
assert_eq!(
lt.get_entry(0, Direction::Right).unwrap(),
Some(right_candidate)
);

let outcome = lt.try_link(0, Direction::Left, left_candidate).unwrap();
assert_eq!(outcome, LinkOutcome::LinkedDirectly);
assert_eq!(
lt.get_entry(0, Direction::Left).unwrap(),
Some(left_candidate)
);
}

/// (b, Right) an existing right neighbor that does NOT sit strictly between self and the
/// candidate (existing.id() > candidate.id()) is overwritten: try_link links directly and
/// get_entry afterward reflects the new candidate, not the old neighbor.
#[test]
fn test_try_link_existing_not_between_overwrites_right() {
let lt = ArrayLookupTable::new();
let candidate_id = random_identifier();
let candidate = Identity::new(candidate_id, random_membership_vector(), random_address());

let existing_id = random_identifier_greater_than(&candidate_id);
let existing = Identity::new(existing_id, random_membership_vector(), random_address());
lt.update_entry(existing, 0, Direction::Right).unwrap();

let outcome = lt.try_link(0, Direction::Right, candidate).unwrap();
assert_eq!(outcome, LinkOutcome::LinkedDirectly);
assert_eq!(lt.get_entry(0, Direction::Right).unwrap(), Some(candidate));
}

/// (b, Left) an existing left neighbor that does NOT sit strictly between self and the
/// candidate (existing.id() < candidate.id()) is overwritten: try_link links directly and
/// get_entry afterward reflects the new candidate, not the old neighbor.
#[test]
fn test_try_link_existing_not_between_overwrites_left() {
let lt = ArrayLookupTable::new();
let candidate_id = random_identifier();
let candidate = Identity::new(candidate_id, random_membership_vector(), random_address());

let existing_id = random_identifier_less_than(&candidate_id);
let existing = Identity::new(existing_id, random_membership_vector(), random_address());
lt.update_entry(existing, 0, Direction::Left).unwrap();

let outcome = lt.try_link(0, Direction::Left, candidate).unwrap();
assert_eq!(outcome, LinkOutcome::LinkedDirectly);
assert_eq!(lt.get_entry(0, Direction::Left).unwrap(), Some(candidate));
}

/// (c, Right) an existing right neighbor that sits strictly between self and the candidate
/// (existing.id() < candidate.id()) causes try_link to forward instead of linking: the table
/// is left unchanged, still holding the existing neighbor.
#[test]
fn test_try_link_existing_between_forwards_right() {
let lt = ArrayLookupTable::new();
let candidate_id = random_identifier();
let candidate = Identity::new(candidate_id, random_membership_vector(), random_address());

let existing_id = random_identifier_less_than(&candidate_id);
let existing = Identity::new(existing_id, random_membership_vector(), random_address());
lt.update_entry(existing, 0, Direction::Right).unwrap();

let outcome = lt.try_link(0, Direction::Right, candidate).unwrap();
assert_eq!(outcome, LinkOutcome::Forward(existing));
assert_eq!(lt.get_entry(0, Direction::Right).unwrap(), Some(existing));
}

/// (c, Left) an existing left neighbor that sits strictly between self and the candidate
/// (existing.id() > candidate.id()) causes try_link to forward instead of linking: the table
/// is left unchanged, still holding the existing neighbor.
#[test]
fn test_try_link_existing_between_forwards_left() {
let lt = ArrayLookupTable::new();
let candidate_id = random_identifier();
let candidate = Identity::new(candidate_id, random_membership_vector(), random_address());

let existing_id = random_identifier_greater_than(&candidate_id);
let existing = Identity::new(existing_id, random_membership_vector(), random_address());
lt.update_entry(existing, 0, Direction::Left).unwrap();

let outcome = lt.try_link(0, Direction::Left, candidate).unwrap();
assert_eq!(outcome, LinkOutcome::Forward(existing));
assert_eq!(lt.get_entry(0, Direction::Left).unwrap(), Some(existing));
}

/// (d) try_link at an out-of-range level returns an error, matching the other lookup-table
/// accessors' bounds-checking behavior.
#[test]
fn test_try_link_out_of_bound_level_errors() {
let lt = ArrayLookupTable::new();
let candidate = random_identity();

let result = lt.try_link(LOOKUP_TABLE_LEVELS, Direction::Right, candidate);
assert!(result.is_err());

let result = lt.try_link(LOOKUP_TABLE_LEVELS, Direction::Left, candidate);
assert!(result.is_err());
}

#[test]
/// Test equality of lookup tables.
/// The test will create two identical lookup tables and check if they are equal.
Expand Down
54 changes: 54 additions & 0 deletions src/core/lookup/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ mod array_lookup_table_test;
/// LookupTableLevel represents level of a lookup table. entry in the table.
pub type LookupTableLevel = usize;

/// Outcome of a [`LookupTable::try_link`] compare-then-act decision.
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum LinkOutcome {
/// The candidate was inserted at the requested `(level, direction)` slot: the slot was
/// empty, or its previous occupant did not sit strictly between this node and the
/// candidate on that side, so the candidate is now the entry there.
LinkedDirectly,
/// The table was left untouched: the carried identity is the existing entry at
/// `(level, direction)`, and it sits strictly between this node and the candidate on that
/// side — it is closer to the candidate's true position, so the link request belongs there
/// instead.
Forward(Identity),
}

/// LookupTable is the core view of Skip Graph node towards the network.
pub trait LookupTable: Send + Sync {
/// Update the entry at the given level and direction.
Expand All @@ -29,6 +43,46 @@ pub trait LookupTable: Send + Sync {
direction: Direction,
) -> anyhow::Result<Option<Identity>>;

/// Atomically decides whether `candidate` becomes the neighbor at `(level, direction)`, or
/// whether an existing neighbor there already sits strictly between this node and
/// `candidate` on that side and the request should be forwarded to it instead.
///
/// The decision is atomic: inspecting the current entry and, when accepting, inserting
/// `candidate` happen as one indivisible step with respect to any other concurrent call on
/// the same `(level, direction)` slot — no caller can observe or race a partial decision.
///
/// The `direction` parameter is receiver-owned, never re-interpreted hop-to-hop: it always names this
/// node's own slot (`Direction::Right` is this node's own right slot, holding neighbors with
/// larger identifiers; `Direction::Left` is its own left slot), never something relative to a
/// caller or hop. An existing entry sits strictly between this node and `candidate` when,
/// for `Direction::Right`, `existing.id() < candidate.id()`; for `Direction::Left`,
/// `existing.id() > candidate.id()`:
///
/// - if it does: this node's own entry at `(level, direction)` is left unchanged, and
/// [`LinkOutcome::Forward`] carrying that existing neighbor is returned — the caller
/// should retry the link request against that neighbor instead.
/// - otherwise (the slot is empty, or the existing entry does not sit strictly between):
/// `candidate` is inserted into this node's own entry at `(level, direction)`, and
/// [`LinkOutcome::LinkedDirectly`] is returned.
///
/// # Preconditions
///
/// The lookup table has no notion of this node's own identifier, so it cannot verify that
/// `candidate` actually belongs on the `direction` side of this node — callers must ensure
/// that before calling. The comparison inside `try_link` only ever weighs the existing entry
/// against `candidate`, never against this node itself, so a violated precondition installs
/// an out-of-order neighbor silently rather than returning an error.
///
/// # Errors
///
/// returns an error when `level` is out of bounds.
fn try_link(
&self,
level: LookupTableLevel,
direction: Direction,
candidate: Identity,
) -> anyhow::Result<LinkOutcome>;

/// Dynamically compares the lookup table with another for equality.
fn equal(&self, other: &dyn LookupTable) -> bool;

Expand Down
1 change: 1 addition & 0 deletions src/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pub mod testutil;
pub use crate::core::context::IrrevocableContext;
pub use crate::core::lookup::array_lookup_table::ArrayLookupTable;
pub use crate::core::lookup::array_lookup_table::LOOKUP_TABLE_LEVELS;
pub use crate::core::lookup::LinkOutcome;
pub use crate::core::lookup::LookupTable;
pub use crate::core::lookup::LookupTableLevel;
pub use crate::core::model::address::Address;
Expand Down
2 changes: 1 addition & 1 deletion src/core/model/address.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use fixedstr::{str128, str8};
use std::fmt::Debug;

/// Represents a networking address; composed of host + port
#[derive(Copy, Clone, PartialEq)]
#[derive(Copy, Clone, PartialEq, Eq)]
pub struct Address {
host: str128, // up to 128 bytes (on stack)
port: str8, // up to 8 bytes (on stack)
Expand Down
2 changes: 1 addition & 1 deletion src/core/model/identity.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::core::{Address, Identifier, MembershipVector};

/// Identity is an immutable struct that represents a node's identity in the network (ID, MembershipVector, Address).
#[derive(Copy, Clone, Debug, PartialEq)]
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct Identity {
id: Identifier,
mem_vec: MembershipVector,
Expand Down
12 changes: 11 additions & 1 deletion src/node/core_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ use crate::core::testutil::fixtures::{
span_fixture,
};
use crate::core::{
ArrayLookupTable, IdSearchReq, Identifier, LookupTable, LookupTableLevel, LOOKUP_TABLE_LEVELS,
ArrayLookupTable, IdSearchReq, Identifier, LinkOutcome, LookupTable, LookupTableLevel,
LOOKUP_TABLE_LEVELS,
};
use crate::node::core::{BaseCore, Core};
use anyhow::anyhow;
Expand Down Expand Up @@ -359,6 +360,15 @@ fn test_search_by_id_error_propagation() {
Err(anyhow!("simulated lookup table error"))
}

fn try_link(
&self,
_: LookupTableLevel,
_: Direction,
_: Identity,
) -> anyhow::Result<LinkOutcome> {
todo!()
}

fn equal(&self, _: &dyn LookupTable) -> bool {
todo!()
}
Expand Down
Loading