Skip to content

feat: real-time messaging — fan-out, attachments, reactions, read state & server control#8

Merged
RagingRedRiot merged 1 commit into
mainfrom
feat/messaging-fanout-attachments-reactions-server-control
Jun 2, 2026
Merged

feat: real-time messaging — fan-out, attachments, reactions, read state & server control#8
RagingRedRiot merged 1 commit into
mainfrom
feat/messaging-fanout-attachments-reactions-server-control

Conversation

@RagingRedRiot

Copy link
Copy Markdown
Owner

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_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.
  • partial index on incomplete attachments for the reaper sweep.

Commands / events

  • Messaging: SendMessage, GetMessages (keyset pagination by uuidv7 cursor, newest first), MarkRead (forward-only), GetUnreadSummary, AddReaction / RemoveReaction (idempotent).
  • Attachments: DownloadAttachment, GetMaxChunkSize; chunk bytes travel as binary frames, never JSON.
  • Server control (admin): RestartServer, ShutdownServer.
  • Events: MessageCreated, NewMessage, MessageHistory, UnreadSummary, Resync, AttachmentChunk / AttachmentEnd / AttachmentComplete, MaxChunkSize. MessageHistoryItem is the single shape for both backlog and live push, so a client renders both through one path and dedups its own echo by message_id.

Architecture

  • Message actor (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.
  • Fan-out hub (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 gets Resync and re-fetches from history, since the DB + watermark make a dropped live event recoverable.
  • Attachments (src/attachment.rs): per-upload actor 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 commands.
  • Session rewrite (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.
  • Reaper (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 --locked174 passing (31 suites), via #[sqlx::test] per-test databases.
  • cargo fmt --all --check and cargo clippy --all-targets --locked -- -D warnings clean.
  • New test files: send_message, get_messages, reactions, read_state, fan_out, download_attachment, max_chunk_size, server_control; reaper.rs gains the incomplete-attachment sweep.
  • The CI test-modification detector will flag tests/common/mod.rs and tests/reaper.rs — both additive (new seeders/helpers and the sweep case).

Notes

  • Deps: sha2 (digest verification), tokio-stream (sync feature, hub fan-out).

…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.
@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown

⚠️ Existing tests were modified

The following test files have removed or changed lines compared to main:

  • tests/common/mod.rs (1 line(s) removed or changed)

Adding new tests is fine and does not trigger this notice. This appears when existing test code (or helpers under tests/common/) is edited or removed. That can be legitimate — but every change should be deliberate and visible to reviewers.

@RagingRedRiot RagingRedRiot merged commit e6acc6d into main Jun 2, 2026
4 checks passed
@RagingRedRiot RagingRedRiot deleted the feat/messaging-fanout-attachments-reactions-server-control branch June 2, 2026 22:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant