Skip to content

Calculate createdLocallyAt using server clock offset#6199

Draft
VelikovPetar wants to merge 4 commits intodevelopfrom
bug/AND-1069_calculate_created_locally_at_using_server_clock_offset_calculation
Draft

Calculate createdLocallyAt using server clock offset#6199
VelikovPetar wants to merge 4 commits intodevelopfrom
bug/AND-1069_calculate_created_locally_at_using_server_clock_offset_calculation

Conversation

@VelikovPetar
Copy link
Contributor

@VelikovPetar VelikovPetar commented Mar 2, 2026

Goal

When a device clock is skewed relative to the server, createdLocallyAt timestamps 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 the ConnectedEvent timestamp 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 each HealthEvent, accepting only the sample with the lowest RTT (min-RTT selection minimises network-asymmetry error).
  • estimatedServerTime() — returns Date(localTime - offset), used in ChatClient.ensureCreatedLocallyAt() instead of the raw local clock.

ServerClockOffset is constructed once per ChatClient instance, wired through ChatModule into ChatSocket (for calibration) and ChatClient (for consumption). All mutable state uses @Volatile for thread safety.

UI Changes

Before After
clock-before.mov
clock-after.mov

Testing

  • New unit test class ServerClockOffsetTest covers:
    • Initial state (zero offset, returns local time)
    • onConnected with and without a paired onConnectionStarted
    • onHealthCheck min-RTT selection and stale-sample rejection
    • maxRttMs boundary conditions
    • Thread-safety via concurrent coroutines
  • Existing tests updated to supply ServerClockOffset via FakeChatSocket and MockClientBuilder.

Summary by CodeRabbit

  • Improvements
    • Messages now use server-estimated time for timestamps instead of local device time
    • System automatically calibrates clock offset during WebSocket connections and periodic health checks to ensure consistent message ordering across devices

Co-Authored-By: Claude <noreply@anthropic.com>
@VelikovPetar VelikovPetar added the pr:bug Bug fix label Mar 2, 2026
@github-actions
Copy link
Contributor

github-actions bot commented Mar 2, 2026

PR checklist ✅

All required conditions are satisfied:

  • Title length is OK (or ignored by label).
  • At least one pr: label exists.
  • Sections ### Goal, ### Implementation, and ### Testing are filled.

🎉 Great job! This PR is ready for review.

@VelikovPetar VelikovPetar changed the title Fix: Calculate createdLocallyAt using server clock offset (AND-1069) Fix: Calculate createdLocallyAt using server clock offset Mar 2, 2026
@VelikovPetar VelikovPetar changed the title Fix: Calculate createdLocallyAt using server clock offset Calculate createdLocallyAt using server clock offset Mar 2, 2026
@github-actions
Copy link
Contributor

github-actions bot commented Mar 2, 2026

SDK Size Comparison 📏

SDK Before After Difference Status
stream-chat-android-client 5.26 MB 5.26 MB 0.00 MB 🟢
stream-chat-android-offline 5.48 MB 5.49 MB 0.00 MB 🟢
stream-chat-android-ui-components 10.63 MB 10.63 MB 0.00 MB 🟢
stream-chat-android-compose 12.85 MB 12.86 MB 0.00 MB 🟢

@VelikovPetar VelikovPetar marked this pull request as ready for review March 3, 2026 17:44
@VelikovPetar VelikovPetar requested a review from a team as a code owner March 3, 2026 17:44
@coderabbitai
Copy link

coderabbitai bot commented Mar 3, 2026

Walkthrough

A 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

Cohort / File(s) Summary
Clock Offset Implementation
stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/internal/ServerClockOffset.kt
New internal utility class managing server/local clock synchronization with NTP-style offset calculation, calibration on connection, health check refinement, and estimated server time derivation via volatile state and RTT tracking.
Client Integration
stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt
Added serverClockOffset dependency to ChatClient constructor; replaced local now() with serverClockOffset.estimatedServerTime() when computing Message.createdLocallyAt to reduce cross-client clock skew.
Dependency Injection
stream-chat-android-client/src/main/java/io/getstream/chat/android/client/di/ChatModule.kt
Wired ServerClockOffset through ChatModule constructor and propagated to ChatSocket instantiation, threading the clock offset from module initialization to socket layer.
Socket Lifecycle Integration
stream-chat-android-client/src/main/java/io/getstream/chat/android/client/socket/ChatSocket.kt
Integrated serverClockOffset callbacks at connection lifecycle milestones: onConnectionStarted() at WebSocket init, onHealthCheckSent() before health callback, onConnected() at connection established, and onHealthCheck() on health event receipt.
Test Fixtures & Builders
stream-chat-android-client/src/test/java/io/getstream/chat/android/client/.../BaseChatClientTest.kt, ChatClientConnectionTests.kt, ChatClientTest.kt, DependencyResolverTest.kt, MockClientBuilder.kt, FakeChatSocket.kt, ChatClientDebuggerTest.kt
Updated all test file ChatClient and FakeChatSocket constructors to instantiate and wire ServerClockOffset as a required parameter in test fixtures and mock builders.
Test Coverage
stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/internal/ServerClockOffsetTest.kt
Comprehensive test suite covering baseline behavior, onConnected calibration under forward/backward clock skew, health check convergence via NTP midpoint, RTT selection and maxRttMs filtering, reconnect resets, edge cases, and validation of estimatedServerTime consistency across scenarios.

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()
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 Tick-tock, the bunny hops through time,
Syncing clocks in perfect rhyme,
No more skew, no more delay,
Server time saves the day! ⏰
Hop on westbound, hop on east,
All messages ordered, watch them feast!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 34.21% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: using server clock offset to calculate createdLocallyAt timestamps for messages.
Description check ✅ Passed The PR description includes Goal, Implementation, UI Changes with before/after videos, Testing with detailed test coverage, and addresses all critical sections of the template.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch bug/AND-1069_calculate_created_locally_at_using_server_clock_offset_calculation

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

📥 Commits

Reviewing files that changed from the base of the PR and between 29b2d88 and f455b35.

📒 Files selected for processing (12)
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/di/ChatModule.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/socket/ChatSocket.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/internal/ServerClockOffset.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientConnectionTests.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientTest.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/DependencyResolverTest.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/MockClientBuilder.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/chatclient/BaseChatClientTest.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/debugger/ChatClientDebuggerTest.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/socket/FakeChatSocket.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/internal/ServerClockOffsetTest.kt

Comment on lines +66 to +70
checkCallback = {
(chatSocketStateService.currentState as? State.Connected)?.event?.let {
serverClockOffset.onHealthCheckSent()
sendEvent(it)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Record health-check send time only when the echo is actually sent.

At Line 68, onHealthCheckSent() runs before checking whether sendEvent(it) succeeded. If sendEvent returns false, the sample can be orphaned and later paired with an unrelated HealthEvent, skewing offset estimation.

Proposed patch
         checkCallback = {
             (chatSocketStateService.currentState as? State.Connected)?.event?.let {
-                serverClockOffset.onHealthCheckSent()
-                sendEvent(it)
+                if (sendEvent(it)) {
+                    serverClockOffset.onHealthCheckSent()
+                }
             }
         },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
checkCallback = {
(chatSocketStateService.currentState as? State.Connected)?.event?.let {
serverClockOffset.onHealthCheckSent()
sendEvent(it)
}
checkCallback = {
(chatSocketStateService.currentState as? State.Connected)?.event?.let {
if (sendEvent(it)) {
serverClockOffset.onHealthCheckSent()
}
}
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@stream-chat-android-client/src/main/java/io/getstream/chat/android/client/socket/ChatSocket.kt`
around lines 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.

Comment on lines +44 to +55
@Volatile
private var offsetMs: Long = 0L

@Volatile
private var bestRttMs: Long = Long.MAX_VALUE

@Volatile
private var healthCheckSentAtMs: Long = 0L

@Volatile
private var connectionStartedAtMs: Long = 0L

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd ServerClockOffset.kt

Repository: GetStream/stream-chat-android

Length of output: 182


🏁 Script executed:

cat -n "stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/internal/ServerClockOffset.kt"

Repository: GetStream/stream-chat-android

Length of output: 6257


@Volatile is insufficient for compound state transitions—race conditions can corrupt RTT selection.

Lines 127–130 in onHealthCheck() perform a check-then-set pattern across two fields without mutual exclusion. A thread can read bestRttMs, be preempted while another thread updates both fields with a better value, then resume and overwrite with a worse RTT using the stale comparison. The same pattern appears in onConnected() (lines 92–98, 95–96).

The KDoc claim at line 32 ("Thread-safe: all mutable state is read/written via [Volatile] fields") is misleading—@volatile guarantees visibility, not atomicity of compound operations.

Apply synchronized locks around all multi-field state transitions:

Suggested fix
 internal class ServerClockOffset(
     private val localTimeMs: () -> Long = { System.currentTimeMillis() },
     private val maxRttMs: Long = DEFAULT_MAX_RTT_MS,
 ) {
+    private val stateLock = Any()

     `@Volatile`
     private var offsetMs: Long = 0L

Then wrap each compound update:

  • onConnectionStarted(): single field, already safe
  • onHealthCheckSent(): single field, already safe
  • onConnected(): wrap lines 84–101 with synchronized(stateLock)
  • onHealthCheck(): wrap lines 118–131 with synchronized(stateLock)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/internal/ServerClockOffset.kt`
around lines 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`.

@VelikovPetar VelikovPetar marked this pull request as draft March 3, 2026 18:58
@sonarqubecloud
Copy link

sonarqubecloud bot commented Mar 6, 2026

Quality Gate Failed Quality Gate failed

Failed conditions
64.2% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr:bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant