Improve Message.createdLocallyAt creation logic using estimated server time#6199
Conversation
Co-Authored-By: Claude <noreply@anthropic.com>
PR checklist ✅All required conditions are satisfied:
🎉 Great job! This PR is ready for review. |
SDK Size Comparison 📏
|
WalkthroughA new ServerClockOffset utility class is introduced to estimate server time via NTP-style synchronization using WebSocket health checks. The dependency is integrated through ChatClient, ChatModule, and ChatSocket to synchronize local timestamps with server time during message creation and connection lifecycle events. Changes
Sequence Diagram(s)sequenceDiagram
participant Client as Client
participant Socket as ChatSocket
participant Offset as ServerClockOffset
participant State as State Service
Client->>Socket: initiateConnection()
activate Socket
Socket->>Offset: onConnectionStarted()
activate Offset
Offset->>Offset: recordConnectionStart(localTime)
deactivate Offset
Socket->>Socket: createWebSocket()
Note over Socket: Connection Established
Socket->>Offset: onConnected(serverTime)
activate Offset
Offset->>Offset: calibrate(serverTime, localTime)
Offset->>Offset: calculateOffset via NTP midpoint
deactivate Offset
Socket->>State: notifyConnected()
rect rgba(100, 200, 150, 0.5)
Note over Socket: Health Check Loop
Socket->>Offset: onHealthCheckSent()
activate Offset
Offset->>Offset: recordHealthCheckSent(localTime)
deactivate Offset
Socket->>Socket: sendHealthMessage()
Socket->>Offset: onHealthCheck(serverTime)
activate Offset
Offset->>Offset: refineOffset via RTT & NTP
deactivate Offset
end
deactivate Socket
Client->>Client: createMessage()
Client->>Offset: estimatedServerTime()
activate Offset
Offset->>Offset: return localTime + offset
deactivate Offset
Client->>Client: setMessageCreatedLocallyAt()
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@stream-chat-android-client/src/main/java/io/getstream/chat/android/client/socket/ChatSocket.kt`:
- Around line 66-70: The health-check timestamp is recorded before confirming
the echo was actually sent; update the checkCallback so that when
chatSocketStateService.currentState is a State.Connected and you obtain the
event, you call sendEvent(it) first and only invoke
serverClockOffset.onHealthCheckSent() if sendEvent(it) returns true (i.e., the
echo was successfully sent). Ensure you reference checkCallback,
State.Connected, sendEvent(it), and serverClockOffset.onHealthCheckSent() when
making the change.
In
`@stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/internal/ServerClockOffset.kt`:
- Around line 44-55: The compound read/modify/write on volatile fields
(offsetMs, bestRttMs, healthCheckSentAtMs, connectionStartedAtMs) in
onConnected() and onHealthCheck() is racy; wrap those multi-field state
transitions in a mutual-exclusion block using a dedicated lock (e.g., private
val stateLock = Any()) and replace the check-then-set sequences with
synchronized(stateLock) { ... } blocks around the code that updates bestRttMs
and offsetMs so stale comparisons cannot overwrite better values; keep
single-field setters (onConnectionStarted(), onHealthCheckSent()) unchanged and
update the KDoc to state that compound operations are synchronized via stateLock
rather than relying solely on `@Volatile`.
ℹ️ Review info
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Disabled knowledge base sources:
- Linear integration is disabled
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (12)
stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.ktstream-chat-android-client/src/main/java/io/getstream/chat/android/client/di/ChatModule.ktstream-chat-android-client/src/main/java/io/getstream/chat/android/client/socket/ChatSocket.ktstream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/internal/ServerClockOffset.ktstream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientConnectionTests.ktstream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientTest.ktstream-chat-android-client/src/test/java/io/getstream/chat/android/client/DependencyResolverTest.ktstream-chat-android-client/src/test/java/io/getstream/chat/android/client/MockClientBuilder.ktstream-chat-android-client/src/test/java/io/getstream/chat/android/client/chatclient/BaseChatClientTest.ktstream-chat-android-client/src/test/java/io/getstream/chat/android/client/debugger/ChatClientDebuggerTest.ktstream-chat-android-client/src/test/java/io/getstream/chat/android/client/socket/FakeChatSocket.ktstream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/internal/ServerClockOffsetTest.kt
…_using_server_clock_offset_calculation
…_using_server_clock_offset_calculation
…_using_server_clock_offset_calculation
…_using_server_clock_offset_calculation
… (where applicable).
|
andremion
left a comment
There was a problem hiding this comment.
Good job!
I think the approach is clear and it helps for createdLocallyAt and sync checks.
I have some small questions, not blockers:
Reactions: Messages use estimatedServerTime() but reactions still use now() for createdLocallyAt. Should we align reactions to estimatedServerTime() also?
Integrators: ServerClockOffset has parameters like maxOffsetMs / maxRttMs. Should we let integrators adjust these (e.g. on ChatClient builder), or we keep it internal only? Exposing could help special environments, but also more risk if someone sets bad values.
What do you think?
Concerning the customisation, I would personally not allow it (at least not initially). I would say that this is internal logic, and we shouldn't allow customisation, unless explicitly requested. Plus, it would mean another (tricky) public API to maintain.
I would say that messages is the most important thing to cover here - as messages appearing out-of-order is a big issue, but I don't think it is that important for reactions. I am bit in favour of keeping it simple (keeping the logic as-is), but if you think we should align the reactions as well, I would be happy to do it. |
Message.createdLocallyAt creation logic using estimated server time
|
🚀 Available in v6.35.1 |
* Improve `Message.createdLocallyAt` creation logic using estimated server time (#6199) * Fix createdLocallyAt using NTP-style server clock offset estimation Co-Authored-By: Claude <noreply@anthropic.com> * Pr remarks * Adjust thread message createdLocallyAt. * Ensure exceedsSyncThreshold is compared against estimated server time (where applicable). * Add max allowed offset. --------- Co-authored-by: Claude <noreply@anthropic.com> * [skip ci] Update SDK sizes * Update README cover image (#6282) * Fix XML image flicker caused by `interceptorCoroutineContext(Dispatchers.IO)` (#6284) Co-authored-by: Claude <noreply@anthropic.com> * [skip ci] Update SDK sizes * AUTOMATION: Version Bump * Fix race condition in plugin resolution during disconnect (#6269) * Update `DependencyResolverTest` to verify error handling when dependency resolution races with disconnection. * Prevent race conditions during disconnects in `ChatClient`. * Handle unresolvable attachments in picker (#6285) - Update `StorageHelper` and `AttachmentMetaDataMapper` to safely handle cases where content URIs (e.g. cloud-backed files) cannot be opened. - Introduce `hasUnresolvedAttachments` state in `AttachmentsPickerViewModel` to track failed attachment resolutions. - Show a toast message in both View-based and Compose attachment pickers when files are unavailable and need to be downloaded to the device. - Add `clearUnresolvedAttachments` to reset the error state after it has been consumed by the UI. - Add unit tests for unresolved attachment scenarios in `AttachmentsPickerViewModelTest`. * [skip ci] Update SDK sizes * Fix wrong message selected on quoted message long click (#6292) * Use type-specific attachment URL fields and deprecate `imagePreviewUrl` (#6280) * Deprecate imagePreviewUrl and use type-specific attachment URL fields Co-Authored-By: Claude <noreply@anthropic.com> * Extract common extensions. --------- Co-authored-by: Claude <noreply@anthropic.com> * Expose optional completion callback for audio recording (#6290) Co-authored-by: Claude <noreply@anthropic.com> * AUTOMATION: Version Bump * AUTOMATION: Clean Detekt Baseline Files (#6299) Co-authored-by: adasiewiczr <17440581+adasiewiczr@users.noreply.github.com> * Add support for intercepting CDN file requests (#6295) * Add new CDN contract. * Add CDN for document files. * Add CDN support for downloading attachments. * Deprecate current CDN methods. * Add progress indicator snackbar. * Add useDocumentGView config flag. * Add file sharing cache handling. * Add file sharing cache handling. * Remove CDNResponse.kt * Add tests * PR remarks * [skip ci] Update SDK sizes * Post-merge clean-up. * Post-merge clean-up. * ApiDump. * Improve attachment URI resolution and error handling in `AttachmentsPickerViewModel` and `AttachmentStorageHelper`. - Add `isUriResolvable` to `StorageHelper` to verify if a content URI can be opened for reading. - Implement `partitionResolvable` in `AttachmentStorageHelper` to separate metadata based on URI accessibility. - Update `AttachmentsPickerViewModel.resolveAndSubmitUris` to exclude inaccessible URIs (e.g., undownloaded cloud files) from the submission. - Ensure `hasUnresolvedAttachments` is correctly set when URIs are inaccessible, independent of file type support. - Add unit tests in `AttachmentStorageHelperTest` and `AttachmentsPickerViewModelTest` to verify partitioning logic and view model state updates. * Handle unresolvable attachments in XML * apiDump. --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: André Mion <andremion@gmail.com> Co-authored-by: Gianmarco <47775302+gpunto@users.noreply.github.com> Co-authored-by: stream-pr-merger[bot] <117762243+stream-pr-merger[bot]@users.noreply.github.com> Co-authored-by: adasiewiczr <17440581+adasiewiczr@users.noreply.github.com>


Goal
When a device clock is skewed relative to the server,
createdLocallyAttimestamps assigned to locally-sent messages can be incorrect. This causes cross-user ordering issues — messages from a user with a fast clock appear in the future relative to messages from other users.Implementation
Introduced
ServerClockOffset, an NTP-style estimator that tracks the offset between the local device clock and the server clock using WebSocket round-trips:onConnectionStarted()— records local time before opening the WebSocket.onConnected(serverTime)— computes initial offset from theConnectedEventtimestamp using the NTP midpoint formula:offset = (sentAt + receivedAt) / 2 - serverTime.onHealthCheckSent()— records local time before each health check echo.onHealthCheck(serverTime)— refines the offset from eachHealthEvent, accepting only the sample with the lowest RTT (min-RTT selection minimises network-asymmetry error).estimatedServerTime()— returnsDate(localTime - offset), used inChatClient.ensureCreatedLocallyAt()instead of the raw local clock.ServerClockOffsetis constructed once perChatClientinstance, wired throughChatModuleintoChatSocket(for calibration) andChatClient(for consumption). All mutable state uses@Volatilefor thread safety.Defensive max-offset cap
A
maxOffsetMsparameter (default 1 hour) guards against unreliable server timestamps — for example, a test environment mock server that returns a static, stalehealth.checktimestamp. Without this cap, a large artificial offset would causeestimatedServerTime()to return a date far in the past, breakingexceedsSyncThresholdcomparisons and suppressing the message delivery status icon.When the computed offset exceeds
maxOffsetMs, the sample is silently rejected:onConnected: resetsoffsetMs = 0before attempting to apply the new offset, so a rejection always falls back to local time (safe pre-PR behaviour).onHealthCheck: leavesoffsetMsunchanged on rejection, preserving the last known good value fromonConnectedor a previous health check.UI Changes
clock-before.mov
clock-after.mov
Testing
ServerClockOffsetTestcovers:onConnectedwith and without a pairedonConnectionStartedonHealthCheckmin-RTT selection and stale-sample rejectionmaxRttMsboundary conditionsmaxOffsetMsboundary conditions — implausibly large offsets rejected on bothonConnectedandonHealthCheck;onHealthCheckpreserves prior valid offset on rejectionServerClockOffsetviaFakeChatSocketandMockClientBuilder.