feat: real-time messaging — fan-out, attachments, reactions, read state & server control#8
Merged
RagingRedRiot merged 1 commit intoJun 2, 2026
Conversation
…te & server control With accounts and rooms in place, this builds the messaging layer that makes a room useful: posting and reading messages, live delivery to other members, file attachments, reactions, and per-room unread tracking. It also adds an admin lifecycle control so the process can be restarted or shut down from inside the app. These pieces ship together because they're woven through one data model -- a message carries its attachments and reactions, and the read watermark and live fan-out are how that message reaches members -- so splitting them would leave commits whose own tests can't pass. Schema (migrations/): - message_attachments: chunked files declared up front (is_complete = false), bytes living in message_attachment_chunks keyed by (attachment_id, seq) so a re-sent chunk is an idempotent upsert and a stalled upload resumes by filling only the gaps. content_sha256 is re-derived by streaming seqs on completion. - reactions: a (message, user, emoji) triple, one of each emoji per user. - memberships.last_read_message_id: a bare uuidv7 cursor (no FK, so reaping a message never resets it); "unread" is just message_id > watermark. - a partial index on incomplete attachments for the reaper sweep. Protocol (src/model.rs): - SendMessage (sender taken server-side; declares attachments), GetMessages (keyset pagination by uuidv7 cursor, newest first), MarkRead (forward-only), GetUnreadSummary, AddReaction/RemoveReaction (idempotent), DownloadAttachment, GetMaxChunkSize, RestartServer/ShutdownServer. - MessageCreated, NewMessage, MessageHistory, UnreadSummary, Resync, AttachmentChunk/AttachmentEnd/AttachmentComplete, MaxChunkSize. - the placeholder Message command/event is replaced; MessageHistoryItem is the single shape used for both backlog and live push, so a client renders both through one path and dedups its own echo by message_id. Message actor (src/message.rs): - send (+ publish to the hub), paginated history, reactions, read state. - membership-gated reads/writes resolve room + caller membership in one query; a non-member and a missing room both yield the same generic failure, so neither leaks existence. Fan-out hub (src/hub.rs): - per-room broadcast bus + a session presence map. Not an actor -- publishing is a non-blocking thread-safe send, so it stays a shared registry rather than a serial task. Lossy by design: a lagging session gets Resync and re-fetches from history, since the DB + watermark make a dropped live event recoverable. Attachments (src/attachment.rs): - per-upload actor (spawned on first chunk) persists chunks idempotently, then streams them through a SHA-256 hasher to verify size + digest before flipping is_complete; a disposable task streams downloads back as binary frames after a membership check. Bounded by write/read semaphores so an upload burst can't drain the pool. - frame caps pinned to max_chunk_bytes + header (src/handler.rs) so the limit a client learns from GetMaxChunkSize is the real one. Server control (src/control.rs, src/main.rs): - a supervisor loop over a held listen socket can drain a pass and stand up a fresh one (hot restart) or exit, driven by a ControlSignal from the OS-signal task or the admin RestartServer/ShutdownServer commands. Session rewrite (src/server.rs): - per connection splits into receiver (socket -> actors) and sender (everything -> socket) tasks, so a slow inbound DB round-trip never stalls live delivery. Dynamic room subscription wired over sub_tx; binary frames routed to upload actors. Reaper (src/reaper.rs): - sweep attachments still incomplete past a 24h grace -- abandoned uploads whose young parent message the existing rules won't catch; their chunks cascade. Docs (docs/): architecture, client contract, and reference catalogs (wire-protocol, actors-and-channels, data-model). Deps: sha2 (digest verification), tokio-stream (sync feature, hub fan-out). Tests: - send_message, get_messages (pagination + gating), reactions, read_state, fan_out (live delivery + dedup), download_attachment, max_chunk_size, server_control; reaper.rs gains the incomplete-attachment sweep.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
With accounts and rooms already in place, this adds the messaging layer that makes a room useful: posting and reading messages, live fan-out to other members, chunked file attachments, reactions, and per-room unread tracking — plus an admin server-control command set (restart/shutdown). Everything is wired end to end (client → session dispatch → actor → Postgres), with live delivery kept on a separate path from inbound commands.
It lands as one commit because the pieces share one data model — a message carries its attachments and reactions, and the read watermark + live fan-out are how that message reaches members — so splitting them would leave commits whose own tests can't pass.
Schema
(init migration edited in place — no release exists yet)
message_attachments+message_attachment_chunks: files declared up front (is_complete = false), bytes keyed by(attachment_id, seq)so a re-sent chunk is an idempotent upsert and a stalled upload resumes by filling only the gaps;content_sha256is re-derived by streaming seqs on completion.reactions: a(message, user, emoji)triple, one of each emoji per user.memberships.last_read_message_id: a bare uuidv7 cursor (no FK, so reaping a message never resets it); "unread" is justmessage_id > watermark.Commands / events
SendMessage,GetMessages(keyset pagination by uuidv7 cursor, newest first),MarkRead(forward-only),GetUnreadSummary,AddReaction/RemoveReaction(idempotent).DownloadAttachment,GetMaxChunkSize; chunk bytes travel as binary frames, never JSON.RestartServer,ShutdownServer.MessageCreated,NewMessage,MessageHistory,UnreadSummary,Resync,AttachmentChunk/AttachmentEnd/AttachmentComplete,MaxChunkSize.MessageHistoryItemis the single shape for both backlog and live push, so a client renders both through one path and dedups its own echo bymessage_id.Architecture
src/message.rs): send (+ publish), paginated history, reactions, read state; membership-gated reads/writes resolve room + caller membership in one query, so a non-member and a missing room yield the same generic failure — no existence leak.src/hub.rs): per-room broadcast bus + session presence map. Not an actor — publishing is a non-blocking thread-safe send. Lossy by design: a lagging session getsResyncand re-fetches from history, since the DB + watermark make a dropped live event recoverable.src/attachment.rs): per-upload actor persists chunks idempotently, then streams them through a SHA-256 hasher to verify size + digest before flippingis_complete; a disposable task streams downloads back as binary frames after a membership check. Bounded by write/read semaphores so an upload burst can't drain the pool. Frame caps pinned tomax_chunk_bytes+ header (src/handler.rs) so the limit a client learns fromGetMaxChunkSizeis the real one.src/control.rs,src/main.rs): a supervisor loop over a held listen socket can drain a pass and stand up a fresh one (hot restart) or exit, driven by aControlSignalfrom the OS-signal task or the admin commands.src/server.rs): each connection splits into a receiver (socket → actors) and a sender (everything → socket) task, so a slow inbound DB round-trip never stalls live delivery.src/reaper.rs): sweeps attachments still incomplete past a 24h grace — abandoned uploads whose young parent message the existing rules won't catch; their chunks cascade.Docs
New
docs/: architecture, client contract, and reference catalogs (wire-protocol, actors-and-channels, data-model).Test plan
cargo test --locked— 174 passing (31 suites), via#[sqlx::test]per-test databases.cargo fmt --all --checkandcargo clippy --all-targets --locked -- -D warningsclean.send_message,get_messages,reactions,read_state,fan_out,download_attachment,max_chunk_size,server_control;reaper.rsgains the incomplete-attachment sweep.tests/common/mod.rsandtests/reaper.rs— both additive (new seeders/helpers and the sweep case).Notes
sha2(digest verification),tokio-stream(syncfeature, hub fan-out).