pub struct SsaReconstructor<S: PixSpec> {
commitment_builder: Cache<SsaId<S::Pseudonym>, Arc<Mutex<SsaCommitmentBuilder<S>>>>,
ssa_cycles: Cache<SsaId<S::Pseudonym>, Arc<SsaCycle<S>>>,
awaiting_acks: Cache<OffchainPublicKey, Cache<HalfKeyChallenge, TaggedEncryptedPartialSsaShare<S, <S as PixSpec>::Pseudonym, PixScalar<S>>>>,
pending_acks: Cache<SsaId<S::Pseudonym>, Arc<Mutex<DeferredAcks>>>,
ready_resolutions: Mutex<Vec<ShareResolution<S::Pseudonym, S::AddressPrivateKey>>>,
ready_resolutions_len: AtomicUsize,
retired_ssas: Cache<SsaId<S::Pseudonym>, ()>,
ack_buffer_entries: Arc<AtomicUsize>,
max_ack_buffer_entries: usize,
ack_buffer_resync: Mutex<Option<Instant>>,
cfg: SsaReconstructorConfig,
}Expand description
Allows server-side reconstruction of SSAs.
There are 3 inputs that reconstructor is dependent on (in order):
- SSA commitments from the Client (delivered via
insert_coefficient_commitments) - Extraction of pending encrypted shares (added via
insert_encrypted_share - Decryption of pending encrypted shares via [
Acknowledgement]s (viaacknowledge_shares)
It is able to track SSA for multiple different pseudonyms (Sessions).
Fields§
§commitment_builder: Cache<SsaId<S::Pseudonym>, Arc<Mutex<SsaCommitmentBuilder<S>>>>§ssa_cycles: Cache<SsaId<S::Pseudonym>, Arc<SsaCycle<S>>>Post-commitment state of every live cycle: the part accumulator and all part builders,
published and reclaimed as one unit. See SsaCycle.
awaiting_acks: Cache<OffchainPublicKey, Cache<HalfKeyChallenge, TaggedEncryptedPartialSsaShare<S, <S as PixSpec>::Pseudonym, PixScalar<S>>>>§pending_acks: Cache<SsaId<S::Pseudonym>, Arc<Mutex<DeferredAcks>>>Acknowledgements that arrived before their cycle’s part builders were installed, bucketed by cycle and then by polynomial.
§Why bucketed at all
The bucket key is exactly the thing whose arrival unblocks the entries inside it, so a
bucket is drained once, by the installation of its own cycle, and never scanned
speculatively. That is what keeps Self::acknowledge_shares free of retry work: it only
ever appends to a bucket.
The original per-peer stash had to be re-scanned in full on every acknowledge_shares call,
because a per-peer key says nothing about which entries have become viable. That is
quadratic in the number of acks received while a cycle’s commitments are in flight, and the
per-peer key aggregates across every Session sharing a first-relayer.
§Why keyed by cycle, sub-bucketed by polynomial
Part builders are installed for a whole cycle at once, so a per-polynomial key no longer
buys selective draining — the drain would just walk every polynomial of the cycle. Keying by
cycle makes it one lookup. The per-polynomial sub-bucket is kept because its cap is what
bounds a misbehaving peer (see MAX_DEFERRED_ACKS_PER_POLYNOMIAL).
The capacity unit is cycles, not polynomials. Keyed per polynomial it was 2 * MAX_POLYS_PER_SSA entries — which one cycle can exhaust on its own, so past roughly four
concurrent cycles node-wide, moka began LRU-evicting buckets and silently dropping real
shares. A size eviction here is share loss, so the headroom is deliberate and the
max_ack_await_time TTL is the operative bound.
ready_resolutions: Mutex<Vec<ShareResolution<S::Pseudonym, S::AddressPrivateKey>>>Resolutions produced by draining deferred-ack buckets at verifier-installation time, waiting
to be picked up by the next Self::acknowledge_shares call.
Draining happens on the commitment path (insert_coefficient_commitments), which is where
the verifier that unblocks the acks is installed. That deliberately keeps the share
verification off the acknowledgement hot path, but it also means the resolutions surface
somewhere that has no route to the upper layer — hence this hand-off. Acks flow continuously
while a Session is live, so pickup latency is one ack batch.
ready_resolutions_len: AtomicUsizeLength of ready_resolutions, so the common case (nothing to pick
up) costs one relaxed load instead of a mutex acquisition on every ack batch.
retired_ssas: Cache<SsaId<S::Pseudonym>, ()>Tombstone set: SsaIds that have been retired. The commitment completion path checks this
after publishing the cycle, preventing resurrection when retire_ssa runs concurrently.
ack_buffer_entries: Arc<AtomicUsize>Running estimate of the entries live in awaiting_acks, summed over
every peer, so the global budget costs one relaxed load per insertion.
An estimate, and knowingly so — see Self::resync_ack_buffer for the one drift source
that cannot be listened for and what keeps it from accumulating.
Behind an Arc because each peer’s inner cache decrements it from an eviction listener, and
moka requires those to be 'static — they cannot borrow the reconstructor that owns them.
max_ack_buffer_entries: usizemax_ack_buffer_bytes in entries, divided
once here rather than on every insertion.
ack_buffer_resync: Mutex<Option<Instant>>When resync_ack_buffer last ran, and the lock that keeps two
from running at once.
None until the first run, so a buffer that saturates immediately is not made to wait out
an interval before its first ground-truth reading.
cfg: SsaReconstructorConfigImplementations§
Source§impl<S: PixSpec + Clone> SsaReconstructor<S>
impl<S: PixSpec + Clone> SsaReconstructor<S>
Sourcepub fn try_new(
cfg: SsaReconstructorConfig,
) -> Result<Self, PixError<S::Pseudonym>>
pub fn try_new( cfg: SsaReconstructorConfig, ) -> Result<Self, PixError<S::Pseudonym>>
Creates a new SSA reconstructor from the given configuration.
Fails if the configuration does not validate. Prefer this over Self::new anywhere the
configuration is assembled at runtime — a config built programmatically or read from a file
is input, not a constant, and turning it into a panic makes it un-handleable by the caller.
Sourcepub fn new(cfg: SsaReconstructorConfig) -> Self
pub fn new(cfg: SsaReconstructorConfig) -> Self
Creates a new SSA reconstructor from the given configuration.
§Panics
Panics if the configuration fails validation. Use Self::try_new to handle that case
instead.
Sourcepub fn config(&self) -> &SsaReconstructorConfig
pub fn config(&self) -> &SsaReconstructorConfig
Returns the configuration of the reconstructor.
Sourcepub fn contains_builder(&self, ssa_id: &SsaId<S::Pseudonym>) -> bool
pub fn contains_builder(&self, ssa_id: &SsaId<S::Pseudonym>) -> bool
Returns true if the reconstructor still holds a builder (SSA-part
builder or commitment builder) for the given cycle. Used by tests to
verify that retire_ssa
cleaned up the expected state.
Sourcefn remove_cycle(&self, ssa_id: SsaId<S::Pseudonym>)
fn remove_cycle(&self, ssa_id: SsaId<S::Pseudonym>)
Removes all reconstructor state for a single SSA cycle.
Idempotent: invalidating an absent key is a no-op.
fn process_verified_ack( &self, ack: HalfKey, ack_challenge: HalfKeyChallenge, awaiting_ack_from_peer: &Cache<HalfKeyChallenge, TaggedEncryptedPartialSsaShare<S>>, ) -> Result<ProcessedAckResult<S>, PixError<S::Pseudonym>>
Sourcefn defer_ack(
&self,
spi: SsaPolynomialId<S::Pseudonym>,
deferred: (OffchainPublicKey, HalfKeyChallenge, HalfKey),
)
fn defer_ack( &self, spi: SsaPolynomialId<S::Pseudonym>, deferred: (OffchainPublicKey, HalfKeyChallenge, HalfKey), )
Buckets an acknowledgement whose cycle’s part builders have not been installed yet.
O(1) — this is the entire cost the acknowledgement path pays for a deferral.
Sourcefn defer_ack_into(
&self,
bucket: &Arc<Mutex<DeferredAcks>>,
spi: SsaPolynomialId<S::Pseudonym>,
deferred: (OffchainPublicKey, HalfKeyChallenge, HalfKey),
)
fn defer_ack_into( &self, bucket: &Arc<Mutex<DeferredAcks>>, spi: SsaPolynomialId<S::Pseudonym>, deferred: (OffchainPublicKey, HalfKeyChallenge, HalfKey), )
The bucket half of defer_ack, taking the bucket rather than looking it
up.
Split out so a test can hold a handle across the drain that invalidates the cache key,
which is the interleaving this guards against and the one thing a single thread cannot
otherwise produce — after the invalidate, get_with hands out a fresh bucket.
Sourcefn drain_deferred_acks(&self, ssa_id: &SsaId<S::Pseudonym>)
fn drain_deferred_acks(&self, ssa_id: &SsaId<S::Pseudonym>)
Redeems the acknowledgements that were waiting for this cycle’s part builders.
Called from the commitment path immediately after the cycle is installed, so each bucket is
processed exactly once and never speculatively re-scanned. Resolutions are parked in
ready_resolutions for the next acknowledge_shares call, since
the commitment path has no route to the upper layer.
Sourcefn redeem_deferred_acks(
&self,
ssa_id: &SsaId<S::Pseudonym>,
deferred: impl IntoIterator<Item = (OffchainPublicKey, HalfKeyChallenge, HalfKey)>,
)
fn redeem_deferred_acks( &self, ssa_id: &SsaId<S::Pseudonym>, deferred: impl IntoIterator<Item = (OffchainPublicKey, HalfKeyChallenge, HalfKey)>, )
Processes acknowledgements whose verifier has since been installed, parking whatever they resolve to.
Shared by the two routes that can redeem a deferral — the drain on the commitment path and
an orphaned append — so both produce the same resolutions in the
same order.
Sourcefn take_ready_resolutions(
&self,
) -> Vec<ShareResolution<S::Pseudonym, S::AddressPrivateKey>>
fn take_ready_resolutions( &self, ) -> Vec<ShareResolution<S::Pseudonym, S::AddressPrivateKey>>
Takes any resolutions parked by drain_deferred_acks.
One relaxed load in the common case — the buckets are empty whenever the Entry finishes the constant-term pass before the shares that reference it arrive.
Sourcefn ack_buffer_resync_interval(&self) -> Duration
fn ack_buffer_resync_interval(&self) -> Duration
Minimum wall time between two resync_ack_buffer passes.
A saturated buffer would otherwise turn every rejected insertion into an
O(max_tracked_peers) scan — the overload path amplifying its own cost, which is the shape
of bug this budget exists to prevent.
Derived from max_ack_await_time rather than
fixed, because what a resync reclaims is entries that have aged out of that window. A
constant would be wrong at both ends: too slow for a short window, so a drained buffer keeps
refusing shares long after it emptied, and needlessly eager for a long one.
The resulting staleness — up to ~1.9 s at the 30 s default — only bites when the counter has drifted high and nothing is touching the caches, since redemption and any cache access drive moka’s expiry maintenance and fire the listener directly. That is the traffic-stopped case, where refusing a share costs nothing.
Sourcefn resync_ack_buffer(&self)
fn resync_ack_buffer(&self)
Recomputes ack_buffer_entries from what the caches actually
hold.
§Why a counter needs a backstop at all
Entries leave the buffer four ways: redeemed by their acknowledgement, expired, size-evicted
from their peer’s cache, or dropped wholesale when the peer’s entry leaves awaiting_acks.
The inner eviction listener catches the first three exactly. The fourth cannot be caught:
dropping a moka handle does not run its eviction listener, so the outer listener falls back
to invalidate_all, which is best-effort and races an insertion landing on the very cache
being discarded.
Left alone, that residue only ever accumulates upward, and an over-count is far worse than
an under-count: it would eventually refuse every share while the buffer sat empty, turning a
memory ceiling into a permanent outage of the acknowledgement path. (The sibling
HoprUnacknowledgedTicketProcessor in hopr-protocol-hopr has the same nesting and the same
residue; there it only skews metrics.)
So the counter is treated as a hint that is allowed to be wrong, and ground truth is
consulted at the one moment being wrong would cost something — when it says the buffer is
full. try_lock rather than lock: a caller that finds a resync already running should
proceed on the current estimate, not queue up behind it.
Sourcefn count_ack_buffer_entries(&self) -> usize
fn count_ack_buffer_entries(&self) -> usize
Ground truth: the entries actually held across every peer.
Never reads ack_buffer_entries, so a test asserting the two
agree is testing the counter rather than agreeing with it — the same reason
deferred_ack_count recomputes from by_poly instead of reading DeferredAcks::total.
O(max_tracked_peers), and each run_pending_tasks is bounded by that cache’s pending write
queue rather than its size. Both callers keep it off the steady-state path.
Source§impl<S: PixSpec + Clone> SsaReconstructor<S>
impl<S: PixSpec + Clone> SsaReconstructor<S>
Sourcefn release_abandoned_commitment(&self, ssa_id: SsaId<S::Pseudonym>)
fn release_abandoned_commitment(&self, ssa_id: SsaId<S::Pseudonym>)
Releases a commitment that was registered but never taken over by an owner.
Deliberately not retire_ssa, which
additionally writes the resurrection tombstone. The tombstone is permanent for that SsaId
for as long as it is retained, and it takes effect at the moment a cycle is published — so a
retry at the same index would re-register, accept the peer’s commitments, publish a deposit
address, and then be silently undone at completion. The peer funds an SSA that can never be
reconstructed, and nothing on either side reports a failure.
Same-index retry is not a corner case: the SSA index is advanced only after every fallible step of a request has succeeded, so a request that failed keeps its index by design and the next attempt reuses it.
Escalates to a full retirement if a cycle did go live, which means the peer was asked and
answered — and therefore that ownership should already have been transferred with
disarm. That branch is a caller error, and retiring is the
safe response to it, because a live cycle is exactly what the tombstone exists to protect.
Sourcepub fn new_guarded_exit_commitment(
self: &Arc<Self>,
id: SsaId<S::Pseudonym>,
polys_per_ssa: usize,
shares_per_poly: usize,
) -> Result<(PixGroup<S>, SsaCommitmentGuard<S>), PixError<S::Pseudonym>>
pub fn new_guarded_exit_commitment( self: &Arc<Self>, id: SsaId<S::Pseudonym>, polys_per_ssa: usize, shares_per_poly: usize, ) -> Result<(PixGroup<S>, SsaCommitmentGuard<S>), PixError<S::Pseudonym>>
new_exit_commitment, with the
registration owned by an SsaCommitmentGuard.
No guard is produced on failure, so a rejected duplicate never retires the registration that caused the rejection.
Trait Implementations§
Source§impl<S: PixSpec> Drop for SsaReconstructor<S>
impl<S: PixSpec> Drop for SsaReconstructor<S>
Source§fn drop(&mut self)
fn drop(&mut self)
Reports terminal resolutions that were never collected.
ready_resolutions is a hand-off the commitment path fills and
only acknowledge_shares empties, so delivery waits on the next acknowledgement batch from
any peer. That is the common case and not the guaranteed one: a Session whose final cycle
recovers through the deferred-ack drain, and which then stops sending because the cycle it
was funding is complete, leaves the last resolution sitting here.
Retirement is not the deadline — a retired cycle’s resolution stays collectable, since the
buffer is global and its entries name their own SsaId. This is, and nothing here can
deliver: the commitment path has no route to the upper layer, which is why these were parked
rather than returned. So the most that can be done is to refuse to lose them quietly. A
RecoveredSsa reported here is a deposit key the Exit held and never handed on.
The real fix is for the reconstructor to push rather than be pulled, which needs a sink on
its constructor; that is bundled with threading a real SsaReconstructorConfig through the
three sites in hopr-transport that hard-code ::default().
Buffers an encrypted share until its acknowledgement arrives, subject to the global byte budget.
§The bound is global, and it is not the product of the two caps
max_tracked_peers and
max_awaiting_acks bound one dimension each,
and their product — 2 000 × 1 000 000 by default, some 800 GB — is neither reachable nor the
right thing to bound. Not reachable, because an entry exists only for a share this node has
already sent, so filling it would take 66 M packets/s of egress inside the default 30 s
window. Not right, because the two guard mutually exclusive concentrations:
max_awaiting_acks sizes one cache per peer and has to cover every Session returning through
a single first-relayer, while max_tracked_peers covers traffic spread thin. Squeezing the
product would push one of them below what its own case needs, and a max_awaiting_acks set
too low does not save memory — it size-evicts shares before their acknowledgements arrive.
So the real bound is max_ack_buffer_bytes,
counted here across all peers at once. Validating a workload model instead would not do:
a model has to assume a Session count and a packet rate, and this node enforces neither.
§Behaviour at the ceiling
The newest share is refused, rather than the oldest evicted: there is no cheap global
“oldest” across per-peer caches, and the oldest is nearest its TTL anyway. Either way a full
buffer means share loss — the packet is already on the wire and its acknowledgement will find
nothing — which is the honest cost of a hard ceiling. PixError::AckBufferFull is
deliberately not an expected error so the caller logs it.
The check and the insertion are not atomic. Concurrent inserters can overshoot the ceiling by their own number, which is the right trade: a lock on this path would cost more than the few hundred kilobytes of overshoot it would prevent.
type Error = PixError<<S as PixSpec>::Pseudonym>
true if the peer has pending encrypted shares awaiting an acknowledgement. Read moreSource§fn is_expected_error(&self, error: &Self::Error) -> bool
fn is_expected_error(&self, error: &Self::Error) -> bool
true if the given error is an expected “not for us” skip (e.g. no
acknowledgements from the peer were expected), so the caller can log it at a
lower severity. Read moreSource§fn retire_ssa(&self, ssa_id: SsaId<S::Pseudonym>)
fn retire_ssa(&self, ssa_id: SsaId<S::Pseudonym>)
Source§fn new_exit_commitment(
&self,
id: SsaId<S::Pseudonym>,
polys_per_ssa: usize,
shares_per_poly: usize,
) -> Result<PixGroup<S>, Self::Error>
fn new_exit_commitment( &self, id: SsaId<S::Pseudonym>, polys_per_ssa: usize, shares_per_poly: usize, ) -> Result<PixGroup<S>, Self::Error>
id.Source§fn insert_coefficient_commitments(
&self,
ssa_id: SsaId<S::Pseudonym>,
index: CoefficientIndex,
proof: Option<SsaCommitmentProof<S>>,
commitments: impl Iterator<Item = (PolynomialIndex, PixGroupRepr<S>)>,
) -> Result<SsaCommitmentState<S::Pseudonym, S::DepositAddress>, Self::Error>
fn insert_coefficient_commitments( &self, ssa_id: SsaId<S::Pseudonym>, index: CoefficientIndex, proof: Option<SsaCommitmentProof<S>>, commitments: impl Iterator<Item = (PolynomialIndex, PixGroupRepr<S>)>, ) -> Result<SsaCommitmentState<S::Pseudonym, S::DepositAddress>, Self::Error>
Auto Trait Implementations§
impl<S> !Freeze for SsaReconstructor<S>
impl<S> !RefUnwindSafe for SsaReconstructor<S>
impl<S> !UnwindSafe for SsaReconstructor<S>
impl<S> Send for SsaReconstructor<S>
impl<S> Sync for SsaReconstructor<S>
impl<S> Unpin for SsaReconstructor<S>
impl<S> UnsafeUnpin for SsaReconstructor<S>
Blanket Implementations§
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
impl<ST, DT> CastableFrom<ST, Initialized, Initialized> for DT
impl<ST, DT> CastableFrom<ST, Uninit, Uninit> for DT
§impl<T> FutureExt for T
impl<T> FutureExt for T
§fn with_context(self, otel_cx: Context) -> WithContext<Self>
fn with_context(self, otel_cx: Context) -> WithContext<Self>
§fn with_current_context(self) -> WithContext<Self>
fn with_current_context(self) -> WithContext<Self>
§impl<T> Instrument for T
impl<T> Instrument for T
§fn instrument(self, span: Span) -> Instrumented<Self>
fn instrument(self, span: Span) -> Instrumented<Self>
§fn in_current_span(self) -> Instrumented<Self>
fn in_current_span(self) -> Instrumented<Self>
Source§impl<T> IntoEither for T
impl<T> IntoEither for T
Source§fn into_either(self, into_left: bool) -> Either<Self, Self>
fn into_either(self, into_left: bool) -> Either<Self, Self>
self into a Left variant of Either<Self, Self>
if into_left is true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read moreSource§fn into_either_with<F>(self, into_left: F) -> Either<Self, Self>
fn into_either_with<F>(self, into_left: F) -> Either<Self, Self>
self into a Left variant of Either<Self, Self>
if into_left(&self) returns true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read more