use std::collections::{BTreeMap, HashMap};
use std::sync::{Arc, Mutex, Weak};
use serde::{Deserialize, Serialize};
use crate::channels::{Channels, Subscriber};
use crate::presence::{Presence, PresenceHandle};
use super::text::{
CollabEditError, CollabElement, CollabOp, CollabText, MAX_ACTOR_LEN, MAX_WIRE_ELEMENTS,
MAX_WIRE_PENDING, OpId,
};
#[must_use]
pub fn topic_for(key: &str) -> String {
format!("collab:{key}")
}
#[must_use]
pub fn doc_key(table: &str, pk: impl std::fmt::Display, column: &str) -> String {
format!("{table}:{pk}:{column}")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CollabLimits {
pub max_insert_chars: usize,
pub max_document_chars: usize,
pub max_delete_ids: usize,
pub max_documents: usize,
}
impl CollabLimits {
#[must_use]
pub fn clamped(self) -> Self {
Self {
max_document_chars: self.max_document_chars.min(MAX_WIRE_ELEMENTS),
..self
}
}
}
impl Default for CollabLimits {
fn default() -> Self {
Self {
max_insert_chars: 10_000,
max_document_chars: MAX_WIRE_ELEMENTS,
max_delete_ids: 10_000,
max_documents: 10_000,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum CollabError {
#[error("insert of {got} characters exceeds the limit of {limit}")]
InsertTooLarge {
got: usize,
limit: usize,
},
#[error("document holds {got} characters, at the limit of {limit}")]
DocumentFull {
got: usize,
limit: usize,
},
#[error("delete of {got} ids exceeds the limit of {limit}")]
DeleteTooLarge {
got: usize,
limit: usize,
},
#[error("the document registry holds {open} documents, at the limit of {limit}")]
RegistryFull {
open: usize,
limit: usize,
},
#[error("the causal buffer holds {got} operations, at the limit of {limit}")]
CausalBufferFull {
got: usize,
limit: usize,
},
#[error("document {key} has been released; re-open it to edit")]
DocumentReleased {
key: String,
},
#[error(transparent)]
Edit(#[from] CollabEditError),
#[error("unknown character id {id}")]
UnknownCharacter {
id: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CollabClientMessage {
Insert {
#[serde(default)]
after: Option<OpId>,
text: String,
},
Delete {
ids: Vec<OpId>,
},
Replace {
ids: Vec<OpId>,
#[serde(default)]
after: Option<OpId>,
text: String,
},
Cursor {
index: usize,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CollabServerMessage {
Snapshot {
elems: Vec<CollabElement>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pending: Vec<CollabOp>,
participants: Vec<CollabParticipant>,
#[serde(default, skip_serializing_if = "Option::is_none")]
actor: Option<String>,
},
Ops {
ops: Vec<CollabOp>,
},
Presence {
participants: Vec<CollabParticipant>,
},
Error {
message: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CollabParticipant {
pub actor: String,
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<usize>,
}
struct DocState {
doc: CollabText,
cursors: BTreeMap<String, usize>,
sessions: usize,
close_claim: Weak<()>,
released: bool,
revision: u64,
}
impl DocState {
const fn new(doc: CollabText) -> Self {
Self {
doc,
cursors: BTreeMap::new(),
sessions: 0,
close_claim: Weak::new(),
released: false,
revision: 0,
}
}
fn edit<T>(&mut self, change: impl FnOnce(&mut CollabText) -> T) -> T {
let out = change(&mut self.doc);
self.revision = self.revision.wrapping_add(1);
out
}
fn try_edit<T, E>(
&mut self,
change: impl FnOnce(&mut CollabText) -> Result<T, E>,
) -> Result<T, E> {
let out = change(&mut self.doc)?;
self.revision = self.revision.wrapping_add(1);
Ok(out)
}
}
static NEXT_SESSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
fn seated_actor(name: &str, seat: u64) -> String {
let suffix = format!("#{seat}");
let budget = MAX_ACTOR_LEN.saturating_sub(suffix.len());
let mut cut = name.len().min(budget);
while cut > 0 && !name.is_char_boundary(cut) {
cut -= 1;
}
format!("{}{suffix}", &name[..cut])
}
const PRESENCE_REFRESH: std::time::Duration = std::time::Duration::from_secs(10);
const MAX_LAG_RESENDS: u32 = 3;
#[derive(Clone)]
pub struct CollabHub {
inner: Arc<HubInner>,
}
struct HubInner {
docs: Arc<Mutex<HashMap<String, Weak<Mutex<DocState>>>>>,
channels: Channels,
presence: Presence,
limits: CollabLimits,
}
impl CollabHub {
#[must_use]
pub fn new(channels: Channels, presence: Presence) -> Self {
Self {
inner: Arc::new(HubInner {
docs: Arc::new(Mutex::new(HashMap::new())),
channels,
presence,
limits: CollabLimits::default(),
}),
}
}
#[must_use]
pub fn with_limits(self, limits: CollabLimits) -> Self {
Self {
inner: Arc::new(HubInner {
docs: Arc::clone(&self.inner.docs),
channels: self.inner.channels.clone(),
presence: self.inner.presence.clone(),
limits: limits.clamped(),
}),
}
}
#[must_use]
pub fn limits(&self) -> CollabLimits {
self.inner.limits
}
pub fn document(&self, key: &str) -> Result<CollabDoc, CollabError> {
self.open_with(key, CollabText::new)
}
pub fn open_with(
&self,
key: &str,
seed: impl FnOnce() -> CollabText,
) -> Result<CollabDoc, CollabError> {
if let Some(state) = self.live(key) {
return Ok(self.handle_for(key, state));
}
let seeded = seed();
let mut docs = self
.inner
.docs
.lock()
.expect("collab registry lock poisoned");
if let Some(state) = docs.get(key).and_then(Weak::upgrade) {
drop(docs);
return Ok(self.handle_for(key, state));
}
docs.retain(|_, weak| weak.strong_count() > 0);
if docs.len() >= self.inner.limits.max_documents {
let open = docs.len();
drop(docs);
drop(seeded);
tracing::warn!(
key,
open,
limit = self.inner.limits.max_documents,
"collab: document registry is full; refusing to open"
);
return Err(CollabError::RegistryFull {
open,
limit: self.inner.limits.max_documents,
});
}
let held = seeded.element_count() + seeded.pending_len();
if held > self.inner.limits.max_document_chars {
drop(docs);
tracing::warn!(
key,
held,
limit = self.inner.limits.max_document_chars,
"collab: seed is past the document limit; refusing to open"
);
return Err(CollabError::DocumentFull {
got: held,
limit: self.inner.limits.max_document_chars,
});
}
if seeded.pending_len() > MAX_WIRE_PENDING {
drop(docs);
tracing::warn!(
key,
pending = seeded.pending_len(),
limit = MAX_WIRE_PENDING,
"collab: seed is past the causal buffer limit; refusing to open"
);
return Err(CollabError::CausalBufferFull {
got: seeded.pending_len(),
limit: MAX_WIRE_PENDING,
});
}
let state = Arc::new(Mutex::new(DocState::new(seeded)));
docs.insert(key.to_owned(), Arc::downgrade(&state));
drop(docs);
Ok(self.handle_for(key, state))
}
fn live(&self, key: &str) -> Option<Arc<Mutex<DocState>>> {
self.inner
.docs
.lock()
.expect("collab registry lock poisoned")
.get(key)
.and_then(Weak::upgrade)
}
fn handle_for(&self, key: &str, state: Arc<Mutex<DocState>>) -> CollabDoc {
CollabDoc {
key: key.to_owned(),
state,
channels: self.inner.channels.clone(),
presence: self.inner.presence.clone(),
limits: self.inner.limits,
}
}
pub fn close(&self, key: &str) -> Option<CollabClose> {
let docs = self
.inner
.docs
.lock()
.expect("collab registry lock poisoned");
let state = docs.get(key).and_then(Weak::upgrade)?;
let mut guard = state.lock().expect("collab document lock poisoned");
if guard.sessions > 0 {
return None;
}
if guard.close_claim.upgrade().is_some() {
return None;
}
let claim = Arc::new(());
guard.close_claim = Arc::downgrade(&claim);
let text = guard.doc.clone();
let revision = guard.revision;
drop(guard);
drop(docs);
Some(CollabClose {
hub: self.clone(),
key: key.to_owned(),
state,
text,
revision,
claim,
})
}
#[must_use]
pub fn open_keys(&self) -> Vec<String> {
let mut docs = self
.inner
.docs
.lock()
.expect("collab registry lock poisoned");
docs.retain(|_, weak| weak.strong_count() > 0);
let mut keys: Vec<String> = docs.keys().cloned().collect();
drop(docs);
keys.sort();
keys
}
}
#[must_use = "the document stays open until this guard is finalized or dropped"]
pub struct CollabClose {
hub: CollabHub,
key: String,
state: Arc<Mutex<DocState>>,
text: CollabText,
revision: u64,
claim: Arc<()>,
}
impl CollabClose {
#[must_use]
pub const fn text(&self) -> &CollabText {
&self.text
}
#[must_use]
pub fn key(&self) -> &str {
&self.key
}
#[must_use = "a returned guard means the document moved on and still needs persisting"]
pub fn finalize(self) -> Option<Self> {
let mut docs = self
.hub
.inner
.docs
.lock()
.expect("collab registry lock poisoned");
let live = docs.get(&self.key).and_then(Weak::upgrade)?;
if !Arc::ptr_eq(&live, &self.state) {
return None;
}
let mut state = live.lock().expect("collab document lock poisoned");
if state.sessions > 0 {
return None;
}
if state.revision != self.revision {
let text = state.doc.clone();
let revision = state.revision;
drop(state);
drop(docs);
return Some(Self {
hub: self.hub,
key: self.key,
state: self.state,
text,
revision,
claim: self.claim,
});
}
state.released = true;
drop(state);
docs.remove(&self.key);
None
}
}
impl std::fmt::Debug for CollabClose {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CollabClose")
.field("key", &self.key)
.field("chars", &self.text.len())
.finish_non_exhaustive()
}
}
impl std::fmt::Debug for CollabHub {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CollabHub")
.field("open", &self.open_keys().len())
.field("limits", &self.inner.limits)
.finish_non_exhaustive()
}
}
impl axum::extract::FromRequestParts<crate::state::AppState> for CollabHub {
type Rejection = std::convert::Infallible;
async fn from_request_parts(
_parts: &mut http::request::Parts,
state: &crate::state::AppState,
) -> Result<Self, Self::Rejection> {
Ok(state.collab().clone())
}
}
#[derive(Clone)]
pub struct CollabDoc {
key: String,
state: Arc<Mutex<DocState>>,
channels: Channels,
presence: Presence,
limits: CollabLimits,
}
impl CollabDoc {
fn editable(&self) -> Result<std::sync::MutexGuard<'_, DocState>, CollabError> {
let state = self.state.lock().expect("collab document lock poisoned");
if state.released {
return Err(CollabError::DocumentReleased {
key: self.key.clone(),
});
}
Ok(state)
}
#[must_use]
pub const fn key(&self) -> &str {
self.key.as_str()
}
#[must_use]
pub fn topic(&self) -> String {
topic_for(&self.key)
}
#[must_use]
pub fn text(&self) -> String {
self.with_doc(CollabText::text)
}
#[must_use]
pub fn document(&self) -> CollabText {
self.with_doc(Clone::clone)
}
#[must_use]
pub fn subscribe(&self) -> Subscriber {
self.channels.subscribe(&self.topic())
}
#[must_use]
pub fn snapshot(&self) -> CollabServerMessage {
let (elems, pending) = self.with_doc(|doc| (doc.elements(), doc.pending_ops().to_vec()));
CollabServerMessage::Snapshot {
elems,
pending,
participants: self.participants(),
actor: None,
}
}
#[must_use = "dropping the session immediately ends the editor's presence lease"]
pub fn join(&self, actor: impl Into<String>, label: impl Into<String>) -> CollabSession {
let seat = NEXT_SESSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let actor = seated_actor(&actor.into(), seat);
let label = label.into();
{
let mut state = self.state.lock().expect("collab document lock poisoned");
state.sessions += 1;
}
let presence = self.presence.track(
self.topic(),
actor.clone(),
serde_json::json!({ "label": label }),
);
self.broadcast_presence();
CollabSession {
doc: self.clone(),
actor,
presence: Some(presence),
}
}
#[must_use]
pub fn participants(&self) -> Vec<CollabParticipant> {
let cursors = {
let state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.cursors.clone()
};
self.presence
.list(&self.topic())
.into_iter()
.map(|entry| {
let label = entry
.metas
.first()
.and_then(|m| m.get("label"))
.and_then(serde_json::Value::as_str)
.unwrap_or(entry.key.as_str())
.to_owned();
CollabParticipant {
cursor: cursors.get(&entry.key).copied(),
actor: entry.key,
label,
}
})
.collect()
}
pub fn handle(
&self,
actor: &str,
message: CollabClientMessage,
) -> Result<Vec<CollabOp>, CollabError> {
match message {
CollabClientMessage::Insert { after, text } => {
let added = text.chars().count();
if added > self.limits.max_insert_chars {
return Err(CollabError::InsertTooLarge {
got: added,
limit: self.limits.max_insert_chars,
});
}
let ops = {
let mut state = self.editable()?;
if let Some(anchor) = after.as_ref()
&& !state.doc.knows(anchor)
{
return Err(CollabError::UnknownCharacter {
id: anchor.to_string(),
});
}
let held = state.doc.element_count() + state.doc.pending_len();
if held + added > self.limits.max_document_chars {
return Err(CollabError::DocumentFull {
got: held,
limit: self.limits.max_document_chars,
});
}
state.try_edit(|doc| doc.insert_after(actor, after.as_ref(), &text))?
};
self.broadcast_ops(&ops);
Ok(ops)
}
CollabClientMessage::Delete { ids } => {
if ids.len() > self.limits.max_delete_ids {
return Err(CollabError::DeleteTooLarge {
got: ids.len(),
limit: self.limits.max_delete_ids,
});
}
let ops = {
let mut state = self.editable()?;
state.edit(|doc| doc.remove_known(&ids))
};
self.broadcast_ops(&ops);
Ok(ops)
}
CollabClientMessage::Replace { ids, after, text } => {
let added = text.chars().count();
if added > self.limits.max_insert_chars {
return Err(CollabError::InsertTooLarge {
got: added,
limit: self.limits.max_insert_chars,
});
}
if ids.len() > self.limits.max_delete_ids {
return Err(CollabError::DeleteTooLarge {
got: ids.len(),
limit: self.limits.max_delete_ids,
});
}
let ops = {
let mut state = self.editable()?;
if let Some(anchor) = after.as_ref()
&& !state.doc.knows(anchor)
{
return Err(CollabError::UnknownCharacter {
id: anchor.to_string(),
});
}
let held = state.doc.element_count() + state.doc.pending_len();
if held + added > self.limits.max_document_chars {
return Err(CollabError::DocumentFull {
got: held,
limit: self.limits.max_document_chars,
});
}
CollabText::preflight_insert(actor, state.doc.clock(), added)?;
let mut ops = state.edit(|doc| doc.remove_known(&ids));
ops.extend(
state.try_edit(|doc| doc.insert_after(actor, after.as_ref(), &text))?,
);
ops
};
self.broadcast_ops(&ops);
Ok(ops)
}
CollabClientMessage::Cursor { index } => {
{
let mut state = self.state.lock().expect("collab document lock poisoned");
state.cursors.insert(actor.to_owned(), index);
}
self.broadcast_presence();
Ok(Vec::new())
}
}
}
pub fn apply_remote(&self, ops: &[CollabOp]) -> Result<usize, CollabError> {
let (accepted, waiting) = {
let mut state = self.editable()?;
let held = state.doc.element_count() + state.doc.pending_len();
let novel = state.doc.novel_count(ops.iter());
if held + novel > self.limits.max_document_chars {
return Err(CollabError::DocumentFull {
got: held,
limit: self.limits.max_document_chars,
});
}
Self::check_pending(&state.doc, ops, novel)?;
let accepted: Vec<CollabOp> = ops
.iter()
.filter(|op| {
if state.doc.already_applied(op) || state.doc.holds_pending(op) {
return false;
}
let integrated = state.edit(|doc| doc.apply((*op).clone()));
integrated || state.doc.holds_pending(op)
})
.cloned()
.collect();
let waiting = state.doc.pending_len();
drop(state);
(accepted, waiting)
};
self.broadcast_ops(&accepted);
Ok(waiting)
}
pub fn merge_delivered(&self, ops: &[CollabOp]) -> Result<(), CollabError> {
let mut state = self.editable()?;
let held = state.doc.element_count() + state.doc.pending_len();
let novel = state.doc.novel_count(ops.iter());
if held + novel > self.limits.max_document_chars {
return Err(CollabError::DocumentFull {
got: held,
limit: self.limits.max_document_chars,
});
}
Self::check_pending(&state.doc, ops, novel)?;
state.edit(|doc| doc.apply_all(ops.iter().cloned()));
drop(state);
Ok(())
}
fn check_pending(doc: &CollabText, ops: &[CollabOp], novel: usize) -> Result<(), CollabError> {
if doc.pending_len() + novel <= MAX_WIRE_PENDING {
return Ok(());
}
let mut trial = doc.clone();
trial.apply_all(ops.iter().cloned());
let got = trial.pending_len();
if got > MAX_WIRE_PENDING {
return Err(CollabError::CausalBufferFull {
got,
limit: MAX_WIRE_PENDING,
});
}
Ok(())
}
fn with_doc<T>(&self, f: impl FnOnce(&CollabText) -> T) -> T {
let state = self.state.lock().expect("collab document lock poisoned");
f(&state.doc)
}
fn broadcast_ops(&self, ops: &[CollabOp]) {
if ops.is_empty() {
return;
}
self.publish(&CollabServerMessage::Ops { ops: ops.to_vec() });
}
fn broadcast_presence(&self) {
self.publish(&CollabServerMessage::Presence {
participants: self.participants(),
});
}
fn publish(&self, message: &CollabServerMessage) {
let Ok(json) = serde_json::to_string(message) else {
tracing::warn!(key = %self.key, "collab: failed to encode server message");
return;
};
let topic = self.topic();
if let Err(error) = self.channels.publish(&topic, json.clone()) {
let delivered = self
.channels
.backend()
.ensure_topic(&topic)
.send(json.into())
.unwrap_or(0);
tracing::warn!(
key = %self.key,
?error,
delivered,
"collab: publish failed; delivered to this node's editors only"
);
}
}
}
impl std::fmt::Debug for CollabDoc {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CollabDoc")
.field("key", &self.key)
.field("text", &self.text())
.finish_non_exhaustive()
}
}
pub struct CollabSession {
doc: CollabDoc,
actor: String,
presence: Option<PresenceHandle>,
}
impl CollabSession {
#[must_use]
pub const fn actor(&self) -> &str {
self.actor.as_str()
}
#[must_use]
pub const fn document(&self) -> &CollabDoc {
&self.doc
}
pub fn handle(&self, message: CollabClientMessage) -> Result<Vec<CollabOp>, CollabError> {
self.doc.handle(&self.actor, message)
}
#[must_use]
pub fn snapshot(&self) -> CollabServerMessage {
match self.doc.snapshot() {
CollabServerMessage::Snapshot {
elems,
pending,
participants,
..
} => CollabServerMessage::Snapshot {
elems,
pending,
participants,
actor: Some(self.actor.clone()),
},
other => other,
}
}
pub fn refresh(&self) {
if let Some(presence) = &self.presence {
presence.refresh();
}
}
}
impl Drop for CollabSession {
fn drop(&mut self) {
{
let mut state = self
.doc
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.cursors.remove(&self.actor);
state.sessions = state.sessions.saturating_sub(1);
}
drop(self.presence.take());
self.doc.broadcast_presence();
}
}
pub async fn serve_socket(
doc: &CollabDoc,
actor: impl Into<String>,
label: impl Into<String>,
mut socket: crate::ws::WebSocket,
) {
use crate::ws::Message;
let mut updates = doc.subscribe();
let session = doc.join(actor, label);
if send_json(&mut socket, &session.snapshot()).await.is_err() {
return;
}
let mut heartbeat = tokio::time::interval(PRESENCE_REFRESH);
heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut lags = 0u32;
loop {
tokio::select! {
_ = heartbeat.tick() => {
session.refresh();
}
incoming = socket.recv() => {
let Some(Ok(message)) = incoming else { break };
let Message::Text(text) = message else {
if matches!(message, Message::Close(_)) {
break;
}
continue;
};
match serde_json::from_str::<CollabClientMessage>(&text) {
Ok(client_message) => {
if let Err(error) = session.handle(client_message) {
let refusal = CollabServerMessage::Error {
message: error.to_string(),
};
if send_json(&mut socket, &refusal).await.is_err() {
break;
}
}
}
Err(error) => {
let refusal = CollabServerMessage::Error {
message: format!("unreadable message: {error}"),
};
if send_json(&mut socket, &refusal).await.is_err() {
break;
}
}
}
}
update = updates.recv() => {
match update {
Ok(update) => {
let text = update.into_string();
let refused = if let Ok(CollabServerMessage::Ops { ops }) =
serde_json::from_str::<CollabServerMessage>(&text)
{
doc.merge_delivered(&ops)
.inspect_err(|error| {
tracing::warn!(
key = %doc.key(),
?error,
"collab: refused a delivered batch; \
this replica is behind"
);
})
.is_err()
} else {
false
};
if refused {
if send_json(&mut socket, &session.snapshot()).await.is_err() {
break;
}
continue;
}
if socket.send(Message::Text(text.into())).await.is_err() {
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
lags += 1;
if lags > MAX_LAG_RESENDS {
tracing::warn!(
key = %doc.key(),
"collab: editor cannot keep up; closing the socket"
);
break;
}
if send_json(&mut socket, &session.snapshot()).await.is_err() {
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
}
}
drop(session);
}
async fn send_json(
socket: &mut crate::ws::WebSocket,
message: &CollabServerMessage,
) -> Result<(), ()> {
let json = serde_json::to_string(message).map_err(|_| ())?;
socket
.send(crate::ws::Message::Text(json.into()))
.await
.map_err(|_| ())
}