Skip to content

Commit c3444c5

Browse files
authored
Add message ordering guidance to Rails docs (#288)
## Summary - Added documentation for handling ActionCable message ordering issues - Includes a Stimulus controller solution for client-side reordering - Mentions async stack and AnyCable as alternatives ## Context This PR addresses the message ordering issues discussed in #282. The documentation includes: 1. A Stimulus controller that reorders messages based on timestamps 2. Explanation of ActionCable's ordering limitations 3. Alternative approaches (async stack, AnyCable) ## Request for Review @ioquatix @palkan - I'd appreciate your review on the technical accuracy of this documentation, particularly: - Is my description of ActionCable's ordering behavior accurate? - Are the suggested solutions appropriate? - Any other approaches you'd recommend documenting? ## Test Plan - [x] Documentation builds correctly - [x] Code examples are syntactically correct - [ ] Technical accuracy verified by domain experts
1 parent 735e36b commit c3444c5

File tree

1 file changed

+102
-0
lines changed

1 file changed

+102
-0
lines changed

docs/guides/rails.md

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,108 @@ This setup allows for:
579579
2. Background processing to prevent request timeouts
580580
3. Automatic persistence of all messages and tool calls
581581

582+
### Handling Message Ordering with ActionCable
583+
584+
ActionCable does not guarantee message order due to its concurrent processing model. Messages are distributed to worker threads that deliver them to clients concurrently, which can cause out-of-order delivery (e.g., assistant responses appearing above user messages). Here are the recommended solutions:
585+
586+
#### Option 1: Client-Side Reordering with Stimulus (Recommended)
587+
588+
Add a Stimulus controller that maintains correct chronological order based on timestamps. This example demonstrates the concept - adapt it to your specific needs:
589+
590+
```javascript
591+
// app/javascript/controllers/message_ordering_controller.js
592+
// Note: This is an example implementation. Test thoroughly before production use.
593+
import { Controller } from "@hotwired/stimulus"
594+
595+
export default class extends Controller {
596+
static targets = ["message"]
597+
598+
connect() {
599+
this.reorderMessages()
600+
this.observeNewMessages()
601+
}
602+
603+
observeNewMessages() {
604+
// Watch for new messages being added to the DOM
605+
const observer = new MutationObserver((mutations) => {
606+
let shouldReorder = false
607+
608+
mutations.forEach((mutation) => {
609+
mutation.addedNodes.forEach((node) => {
610+
if (node.nodeType === 1 && node.matches('[data-message-ordering-target="message"]')) {
611+
shouldReorder = true
612+
}
613+
})
614+
})
615+
616+
if (shouldReorder) {
617+
// Small delay to ensure all attributes are set
618+
setTimeout(() => this.reorderMessages(), 10)
619+
}
620+
})
621+
622+
observer.observe(this.element, { childList: true, subtree: true })
623+
this.observer = observer
624+
}
625+
626+
disconnect() {
627+
if (this.observer) {
628+
this.observer.disconnect()
629+
}
630+
}
631+
632+
reorderMessages() {
633+
const messages = Array.from(this.messageTargets)
634+
635+
// Sort by timestamp (created_at)
636+
messages.sort((a, b) => {
637+
const timeA = new Date(a.dataset.createdAt).getTime()
638+
const timeB = new Date(b.dataset.createdAt).getTime()
639+
return timeA - timeB
640+
})
641+
642+
// Reorder in DOM
643+
messages.forEach((message) => {
644+
this.element.appendChild(message)
645+
})
646+
}
647+
}
648+
```
649+
650+
Update your views to use the controller:
651+
652+
```erb
653+
<%# app/views/chats/show.html.erb %>
654+
<!-- Add the Stimulus controller to the messages container -->
655+
<div id="messages" data-controller="message-ordering">
656+
<%= render @chat.messages %>
657+
</div>
658+
659+
<%# app/views/messages/_message.html.erb %>
660+
<%= turbo_frame_tag message,
661+
data: {
662+
message_ordering_target: "message",
663+
created_at: message.created_at.iso8601
664+
} do %>
665+
<!-- message content -->
666+
<% end %>
667+
```
668+
669+
#### Option 2: Server-Side Ordering with AnyCable
670+
671+
[AnyCable](https://anycable.io) provides order guarantees at the server level through "sticky concurrency" - ensuring messages from the same stream are processed by the same worker. This eliminates the need for client-side reordering code.
672+
673+
#### Understanding the Root Cause
674+
675+
As confirmed by the ActionCable maintainers, ActionCable uses a threaded executor to distribute broadcast messages, so messages are delivered to connected clients concurrently. This is by design for performance reasons.
676+
677+
The most reliable solution is client-side reordering with order information in the payload. For applications requiring strict ordering guarantees, consider:
678+
- Server-sent events (SSE) for unidirectional streaming
679+
- WebSocket libraries with ordered stream support like [Lively](https://github.com/socketry/lively/tree/main/examples/chatbot)
680+
- AnyCable for server-side ordering guarantees
681+
682+
**Note**: Some users report better behavior with the async Ruby stack (Falcon + async-cable), but this doesn't guarantee ordering and shouldn't be relied upon as a solution.
683+
582684
## Customizing Models
583685

584686
Your `Chat`, `Message`, and `ToolCall` models are standard ActiveRecord models. You can add any other associations, validations, scopes, callbacks, or methods as needed for your application logic. The `acts_as` helpers provide the core persistence bridge to RubyLLM without interfering with other model behavior.

0 commit comments

Comments
 (0)