This commit is contained in:
2026-09-22 16:38:08 +07:00
parent 6fac58d494
commit 1c5ef58049
11 changed files with 808 additions and 181 deletions
-1
View File
@@ -18,7 +18,6 @@ rand.workspace = true
serde.workspace = true
serde_json.workspace = true
anyhow.workspace = true
log.workspace = true
[dev-dependencies]
smol.workspace = true
+37
View File
@@ -534,6 +534,43 @@ pub fn plan_refounding(epoch: Epoch) -> Result<Refounding> {
})
}
/// The material one rotation delivers, by scope.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RotationPlan {
/// A refounding: a fresh root, with the root that reads and signs under it.
Base(Refounding),
/// A channel: a fresh channel key, and nothing beside it.
Channel { epoch: Epoch, new_key: [u8; 32] },
}
impl RotationPlan {
pub fn epoch(&self) -> Epoch {
match self {
Self::Base(refounding) => refounding.epoch,
Self::Channel { epoch, .. } => *epoch,
}
}
/// The secret the rotation delivers, which becomes the scope's new read key.
pub fn new_key(&self) -> [u8; 32] {
match self {
Self::Base(refounding) => refounding.new_root,
Self::Channel { new_key, .. } => *new_key,
}
}
}
/// Mint what a rotation of `scope` delivers at `epoch`.
pub fn plan_rotation(scope: RekeyScope, epoch: Epoch) -> Result<RotationPlan> {
match scope {
RekeyScope::Base => Ok(RotationPlan::Base(plan_refounding(epoch)?)),
RekeyScope::Channel(_) => Ok(RotationPlan::Channel {
epoch,
new_key: random_32()?,
}),
}
}
/// Carries the settled heads across a refounding.
pub fn compact(
seals: &[Event],
+97 -22
View File
@@ -22,8 +22,10 @@ pub const STATE_PREFIX: &str = "concord/";
pub struct HeldKey {
pub epoch: Epoch,
pub key: [u8; 32],
/// The publish time of the rotation that superseded this key: a wrap
/// sealed under it later than this does not read.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retired_at: Option<u64>,
pub retired_at: Option<Timestamp>,
}
/// A community root epoch the client still holds, retained for the same reason.
@@ -34,8 +36,9 @@ pub struct HeldRoot {
/// The epoch's Control Plane signer, when the rotation delivered one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub control_pk: Option<PublicKey>,
/// The publish time of the rotation that superseded this root.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retired_at: Option<u64>,
pub retired_at: Option<Timestamp>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -59,15 +62,15 @@ impl ChannelKeyRef {
}
}
/// How far a channel's history sync has reached, in epoch milliseconds.
/// How far a channel's history sync has reached, in wrap times.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChannelCursor {
/// The newest wrap ingested, so a live subscription knows where to resume.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub newest_ms: Option<u64>,
pub newest: Option<Timestamp>,
/// The oldest wrap paged back to, so the next round resumes below it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oldest_ms: Option<u64>,
pub oldest: Option<Timestamp>,
/// History verifiably swept to the bottom.
#[serde(default)]
pub exhausted: bool,
@@ -76,14 +79,14 @@ pub struct ChannelCursor {
impl ChannelCursor {
pub fn merge(self, round: Self) -> Self {
Self {
newest_ms: later(self.newest_ms, round.newest_ms),
oldest_ms: earlier(self.oldest_ms, round.oldest_ms),
newest: later(self.newest, round.newest),
oldest: earlier(self.oldest, round.oldest),
exhausted: self.exhausted || round.exhausted,
}
}
}
fn later(held: Option<u64>, round: Option<u64>) -> Option<u64> {
fn later(held: Option<Timestamp>, round: Option<Timestamp>) -> Option<Timestamp> {
match (held, round) {
(Some(held), Some(round)) => Some(held.max(round)),
(held, None) => held,
@@ -91,7 +94,7 @@ fn later(held: Option<u64>, round: Option<u64>) -> Option<u64> {
}
}
fn earlier(held: Option<u64>, round: Option<u64>) -> Option<u64> {
fn earlier(held: Option<Timestamp>, round: Option<Timestamp>) -> Option<Timestamp> {
match (held, round) {
(Some(held), Some(round)) => Some(held.min(round)),
(held, None) => held,
@@ -121,23 +124,27 @@ pub struct CommunityState {
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub banned: BTreeSet<PublicKey>,
/// Where each channel's history sync has reached.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
#[serde(
default,
rename = "channel_cursors",
skip_serializing_if = "BTreeMap::is_empty"
)]
pub cursors: BTreeMap<ChannelId, ChannelCursor>,
/// Root epochs superseded by a rotation we adopted, newest first.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub held_roots: Vec<HeldRoot>,
/// The epoch a channel rotation removed us at.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub channel_cuts: BTreeMap<ChannelId, u64>,
pub channel_cuts: BTreeMap<ChannelId, Epoch>,
/// The base epoch we were excluded at.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub removed_at: Option<Epoch>,
/// A complete rotation ahead of our epoch predates our join and carries no
/// blob for us: a stale invite landed us on a superseded epoch.
/// A complete rotation ahead of our epoch predates our join and carries no blob.
#[serde(default)]
pub stranded: bool,
#[serde(default)]
pub dissolved: bool,
/// When this community joined the member's list, in milliseconds.
pub added_at_ms: u64,
}
@@ -326,7 +333,7 @@ impl CommunityState {
pub fn channel_cut(&self, channel: &ChannelId, epoch: Epoch) -> bool {
self.channel_cuts
.get(channel)
.is_some_and(|cut| epoch.0 <= *cut)
.is_some_and(|cut| epoch <= *cut)
}
pub fn floors(&self) -> Floors {
@@ -431,8 +438,8 @@ mod tests {
#[test]
fn a_cursor_merge_only_moves_forward_and_never_seals() {
let held = ChannelCursor {
newest_ms: Some(1_000),
oldest_ms: Some(5_000),
newest: Some(Timestamp::from_secs(1_000)),
oldest: Some(Timestamp::from_secs(5_000)),
exhausted: false,
};
@@ -440,15 +447,15 @@ mod tests {
assert_eq!(held.merge(ChannelCursor::default()), held);
let merged = held.merge(ChannelCursor {
newest_ms: Some(2_000),
oldest_ms: Some(3_000),
newest: Some(Timestamp::from_secs(2_000)),
oldest: Some(Timestamp::from_secs(3_000)),
exhausted: true,
});
assert_eq!(
merged,
ChannelCursor {
newest_ms: Some(2_000),
oldest_ms: Some(3_000),
newest: Some(Timestamp::from_secs(2_000)),
oldest: Some(Timestamp::from_secs(3_000)),
exhausted: true,
}
);
@@ -456,14 +463,82 @@ mod tests {
// A later round that learned less cannot walk either bound back.
assert_eq!(
merged.merge(ChannelCursor {
newest_ms: Some(1_500),
oldest_ms: Some(4_000),
newest: Some(Timestamp::from_secs(1_500)),
oldest: Some(Timestamp::from_secs(4_000)),
exhausted: false,
}),
merged
);
}
/// A cursor stored when the boundaries were milliseconds must not be read as
/// seconds. The key it was stored under is gone, so the document's counters
/// are ignored and the channel re-syncs rather than being sealed off by an
/// `exhausted` that outlived the bounds it was earned against.
#[test]
fn a_cursor_stored_in_the_old_unit_is_dropped_rather_than_reinterpreted() {
let channel = ChannelId::from_bytes([0x9c; 32]);
let cursor = ChannelCursor {
newest: Some(Timestamp::from_secs(1_700_000_000)),
exhausted: true,
..ChannelCursor::default()
};
let mut state = CommunityState {
id: CommunityId::from_bytes([0x42; 32]),
name: None,
owner: Keys::generate().public_key(),
owner_salt: [0x01; 32],
community_root: [0x02; 32],
root_epoch: Epoch(0),
control_root: None,
control_pks: BTreeMap::new(),
channels: Vec::new(),
relays: Vec::new(),
heads: Vec::new(),
banned: BTreeSet::new(),
cursors: BTreeMap::new(),
held_roots: Vec::new(),
channel_cuts: BTreeMap::new(),
removed_at: None,
stranded: false,
dissolved: false,
added_at_ms: 7,
};
// The document a version that stored milliseconds wrote: its own key,
// and boundaries padded by a thousand.
let mut stored = serde_json::Map::new();
stored.insert(
channel.to_hex(),
serde_json::json!({
"newest_ms": 1_700_000_000_000u64,
"oldest_ms": 1_699_999_000_000u64,
"exhausted": true
}),
);
let mut legacy = serde_json::to_value(&state).expect("serializes");
legacy
.as_object_mut()
.expect("a document")
.insert("cursors".to_owned(), serde_json::Value::Object(stored));
let read: CommunityState = serde_json::from_value(legacy).expect("deserializes");
assert!(
read.cursors.is_empty(),
"a millisecond cursor is not a seconds cursor"
);
// A typed cursor still round-trips under the key it is written with.
state.cursors.insert(channel, cursor);
let document = serde_json::to_value(&state).expect("serializes");
let read: CommunityState = serde_json::from_value(document).expect("deserializes");
assert_eq!(read.cursors.get(&channel), Some(&cursor));
}
#[test]
fn from_join_material_materializes_a_subscribable_state_with_or_without_the_control_root() {
let owner = Keys::generate().public_key();