Add local in-memory pubsub

This commit is contained in:
Eric Wendland 2026-05-17 18:23:51 +02:00
commit 6c85a341b8
13 changed files with 246 additions and 14 deletions

View file

@ -454,9 +454,9 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
KvCommand::Set { name, key, value } => ControlRequest::KvSet { name, key, value },
KvCommand::Get { name, key } => ControlRequest::KvGet { name, key },
},
Command::Pubsub { command } => ControlRequest::ModuleStub {
module: "pubsub".to_owned(),
command: format!("{command:?}"),
Command::Pubsub { command } => match command {
PubsubCommand::Pub { topic, message } => ControlRequest::PubsubPub { topic, message },
PubsubCommand::Sub { topic } => ControlRequest::PubsubSub { topic },
},
Command::Pipe { command } => ControlRequest::ModuleStub {
module: "pipe".to_owned(),
@ -893,6 +893,22 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
println!("sync_status: {}", document.sync_status);
println!("state_bytes: {}", document.state_bytes);
}
ControlResponse::PubsubPublished { message } => {
println!("published: {}", message.topic);
println!("published_at_ms: {}", message.published_at.0);
}
ControlResponse::PubsubMessages {
topic,
messages,
note,
} => {
println!("topic: {topic}");
println!("messages: {}", messages.len());
for message in messages {
println!("{}\t{}", message.published_at.0, message.message);
}
println!("note: {note}");
}
ControlResponse::NotImplemented { module, command } => {
println!("{module} {command}: not implemented yet");
}

View file

@ -14,6 +14,7 @@ geth-db = { path = "../geth-db" }
geth-document = { path = "../geth-document" }
geth-keychain = { path = "../geth-keychain" }
geth-kv = { path = "../geth-kv" }
geth-pubsub = { path = "../geth-pubsub" }
geth-resource = { path = "../geth-resource" }
geth-secrets = { path = "../geth-secrets" }
geth-ssh-identity = { path = "../geth-ssh-identity" }

View file

@ -3,6 +3,7 @@ use geth_db::DbResource;
use geth_document::DocumentResource;
use geth_keychain::KeychainOp;
use geth_kv::{KvEntry, KvResource};
use geth_pubsub::PubsubMessage;
use geth_resource::ResourceDescriptor;
use geth_secrets::{BearerAccess, ResourceMasterSecret};
use geth_ssh_identity::{
@ -136,6 +137,13 @@ pub enum ControlRequest {
DocumentStatus {
name: String,
},
PubsubPub {
topic: String,
message: String,
},
PubsubSub {
topic: String,
},
ModuleStub {
module: String,
command: String,
@ -252,6 +260,14 @@ pub enum ControlResponse {
DocumentStatus {
document: DocumentResource,
},
PubsubPublished {
message: PubsubMessage,
},
PubsubMessages {
topic: String,
messages: Vec<PubsubMessage>,
note: String,
},
NotImplemented {
module: String,
command: String,

View file

@ -20,6 +20,7 @@ geth-document = { path = "../geth-document" }
geth-iroh = { path = "../geth-iroh" }
geth-keychain = { path = "../geth-keychain" }
geth-kv = { path = "../geth-kv" }
geth-pubsub = { path = "../geth-pubsub" }
geth-resource = { path = "../geth-resource" }
geth-secrets = { path = "../geth-secrets" }
geth-ssh-identity = { path = "../geth-ssh-identity" }

View file

@ -13,6 +13,7 @@ use geth_document::DocumentResource;
use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode};
use geth_keychain::{KeychainOp, KeychainOpKind};
use geth_kv::{KvEntry, KvResource};
use geth_pubsub::PubsubMessage;
use geth_resource::ResourceDescriptor;
use geth_secrets::{BearerAccess, ResourceMasterSecret};
use geth_ssh_identity::{
@ -29,7 +30,9 @@ use geth_types::{
AuthOpId, Capability, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind, ResourceName,
SshCertId, SshCertRequestId, UnixMillis,
};
use std::collections::VecDeque;
use std::path::Path;
use std::sync::{Arc, Mutex};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream};
@ -73,6 +76,10 @@ pub enum NodeError {
ResourceNotFound(String),
#[error("secrets error: {0}")]
Secrets(#[from] geth_secrets::SecretsError),
#[error("pubsub error: {0}")]
Pubsub(#[from] geth_pubsub::PubsubError),
#[error("runtime state lock poisoned")]
RuntimeLockPoisoned,
#[error("invalid ssh certificate kind: {0}")]
InvalidSshCertKind(String),
#[error("invalid ssh certificate request status: {0}")]
@ -93,8 +100,21 @@ pub struct LocalNode {
pub agent_id: String,
pub node_id: String,
pub iroh_status: EndpointStatus,
runtime: Arc<NodeRuntime>,
}
#[derive(Debug)]
struct NodeRuntime {
pubsub: Mutex<PubsubRuntime>,
}
#[derive(Debug, Default)]
struct PubsubRuntime {
messages: VecDeque<PubsubMessage>,
}
const PUBSUB_RING_LIMIT: usize = 256;
pub fn init_node(paths: &GethPaths) -> Result<LocalNode, NodeError> {
paths.ensure_base_dirs()?;
if !paths.config_file().exists() {
@ -117,6 +137,9 @@ pub fn init_node(paths: &GethPaths) -> Result<LocalNode, NodeError> {
agent_id,
node_id,
iroh_status: EndpointStatus::scaffolded(),
runtime: Arc::new(NodeRuntime {
pubsub: Mutex::new(PubsubRuntime::default()),
}),
})
}
@ -784,6 +807,44 @@ pub fn handle_request(
document: document_resource_from_stored(&stored),
})
}
ControlRequest::PubsubPub { topic, message } => {
geth_pubsub::validate_topic(&topic)?;
geth_pubsub::validate_message(&message)?;
let message = PubsubMessage {
topic: topic.clone().into(),
message,
published_at: UnixMillis(geth_store::now_ms()),
};
let mut runtime = node
.runtime
.pubsub
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)?;
runtime.messages.push_back(message.clone());
while runtime.messages.len() > PUBSUB_RING_LIMIT {
runtime.messages.pop_front();
}
Ok(ControlResponse::PubsubPublished { message })
}
ControlRequest::PubsubSub { topic } => {
geth_pubsub::validate_topic(&topic)?;
let runtime = node
.runtime
.pubsub
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)?;
let messages = runtime
.messages
.iter()
.filter(|message| message.topic.as_str() == topic)
.cloned()
.collect();
Ok(ControlResponse::PubsubMessages {
topic,
messages,
note: geth_pubsub::pubsub_storage_warning().to_owned(),
})
}
ControlRequest::ModuleStub { module, command } => {
Ok(ControlResponse::NotImplemented { module, command })
}

View file

@ -7,4 +7,5 @@ license.workspace = true
[dependencies]
serde.workspace = true
thiserror.workspace = true
geth-types = { path = "../geth-types" }

View file

@ -1,4 +1,4 @@
use geth_types::{ResourceId, TopicId};
use geth_types::{ResourceId, TopicId, UnixMillis};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@ -8,7 +8,54 @@ pub struct PubsubTopic {
pub name: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PubsubMessage {
pub topic: TopicId,
pub message: String,
pub published_at: UnixMillis,
}
#[derive(Debug, thiserror::Error)]
pub enum PubsubError {
#[error("invalid pubsub topic: {0}")]
InvalidTopic(String),
#[error("pubsub message must not be empty")]
EmptyMessage,
}
pub fn validate_topic(topic: &str) -> Result<(), PubsubError> {
if topic.is_empty()
|| topic
.bytes()
.any(|byte| byte == 0 || byte == b'\n' || byte == b'\r')
{
return Err(PubsubError::InvalidTopic(topic.to_owned()));
}
Ok(())
}
pub fn validate_message(message: &str) -> Result<(), PubsubError> {
if message.is_empty() {
return Err(PubsubError::EmptyMessage);
}
Ok(())
}
#[must_use]
pub fn pubsub_storage_warning() -> &'static str {
"pubsub is lossy notification transport, not authoritative storage"
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pubsub_validation_rejects_invalid_topics_and_empty_messages() {
assert!(validate_topic("presence/laptop").is_ok());
assert!(validate_topic("").is_err());
assert!(validate_topic("presence\nlaptop").is_err());
assert!(validate_message("online").is_ok());
assert!(validate_message("").is_err());
}
}

View file

@ -734,6 +734,76 @@ fn bearer_access_create_list_revoke_uses_resource_scoped_auth_ops() {
);
}
#[test]
fn pubsub_pub_sub_uses_lossy_in_memory_runtime() {
let home = tempfile::tempdir().expect("tempdir");
let paths = geth_config::GethPaths::from_home(home.path());
let node = geth_node::init_node(&paths).expect("init node");
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::PubsubPub {
topic: "presence/laptop".to_owned(),
message: "online".to_owned(),
},
)
.expect("publish");
match response {
geth_control::ControlResponse::PubsubPublished { message } => {
assert_eq!(message.topic.to_string(), "presence/laptop");
assert_eq!(message.message, "online");
}
other => panic!("unexpected response: {other:?}"),
}
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::PubsubSub {
topic: "presence/laptop".to_owned(),
},
)
.expect("subscribe snapshot");
match response {
geth_control::ControlResponse::PubsubMessages {
topic,
messages,
note,
} => {
assert_eq!(topic, "presence/laptop");
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].message, "online");
assert!(note.contains("not authoritative storage"));
}
other => panic!("unexpected response: {other:?}"),
}
let reopened = geth_node::open_node(&paths).expect("reopen node");
let response = geth_node::handle_request(
&reopened,
geth_control::ControlRequest::PubsubSub {
topic: "presence/laptop".to_owned(),
},
)
.expect("subscribe reopened snapshot");
match response {
geth_control::ControlResponse::PubsubMessages { messages, .. } => {
assert!(messages.is_empty());
}
other => panic!("unexpected response: {other:?}"),
}
assert!(
geth_node::handle_request(
&node,
geth_control::ControlRequest::PubsubPub {
topic: "presence/laptop".to_owned(),
message: String::new(),
},
)
.is_err()
);
}
#[test]
fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
let home = tempfile::tempdir().expect("tempdir");