-
Notifications
You must be signed in to change notification settings - Fork 386
feat(llc, persistence): add offline support for reactions #2847
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
16594c3
0e6c28b
91251ab
ff7a4b1
2314f94
e816523
b9c7c39
e2c2e2b
37f972b
5e99c43
5e1becb
fc92d9b
e3abbfa
b7eae45
09c99b8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ import 'dart:math' as math; | |
|
|
||
| import 'package:collection/collection.dart'; | ||
| import 'package:rxdart/rxdart.dart'; | ||
| import 'package:stream_chat/src/client/reaction_pending_operation.dart'; | ||
| import 'package:stream_chat/src/client/retry_queue.dart'; | ||
| import 'package:stream_chat/src/core/util/utils.dart'; | ||
| import 'package:stream_chat/stream_chat.dart'; | ||
|
|
@@ -1616,19 +1617,30 @@ class Channel { | |
| state?.updateMessage(updatedMessage); | ||
|
|
||
| try { | ||
| final reactionResp = await _client.sendReaction( | ||
| return await _client.sendReaction( | ||
| messageId, | ||
| reaction, | ||
| skipPush: skipPush, | ||
| enforceUnique: enforceUnique, | ||
| ); | ||
| return reactionResp; | ||
| } catch (_) { | ||
| // Reset the message if the update fails. Use replace (not merge) | ||
| // so the rollback wins over the optimistic local state — otherwise | ||
| // `Message.updateWith`'s enrichment preservation would keep the | ||
| // optimistic `ownReactions` for messages that previously had none. | ||
| state?.replaceMessage(message); | ||
| } catch (e) { | ||
| final retriable = e is StreamChatNetworkError && e.isRetriable; | ||
| if (retriable) { | ||
| // Keep the optimistic reaction and queue it for replay on reconnect. | ||
| await _client.pendingOperationsManager.enqueue( | ||
| ReactionPendingOperation.add( | ||
| reaction, | ||
| skipPush: skipPush, | ||
| enforceUnique: enforceUnique, | ||
| ), | ||
| ); | ||
| } else { | ||
| // Reset the message on terminal failure. Use replace (not merge) | ||
| // so the rollback wins over the optimistic local state — otherwise | ||
| // `Message.updateWith`'s enrichment preservation would keep the | ||
| // optimistic `ownReactions` for messages that previously had none. | ||
| state?.replaceMessage(message); | ||
| } | ||
| rethrow; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not entirely sure if we should always |
||
| } | ||
| } | ||
|
|
@@ -1647,15 +1659,25 @@ class Channel { | |
| state?.updateMessage(updatedMessage); | ||
|
|
||
| try { | ||
| final deleteResponse = await _client.deleteReaction( | ||
| return await _client.deleteReaction( | ||
| message.id, | ||
| reaction.type, | ||
| ); | ||
| return deleteResponse; | ||
| } catch (_) { | ||
| // Reset the message if the update fails. Use replace (not merge) | ||
| // for symmetry with `sendReaction` — see that method for context. | ||
| state?.replaceMessage(message); | ||
| } catch (e) { | ||
| final retriable = e is StreamChatNetworkError && e.isRetriable; | ||
| if (retriable) { | ||
| // Keep the optimistic removal and queue it for replay on reconnect. | ||
| await _client.pendingOperationsManager.enqueue( | ||
| ReactionPendingOperation.delete( | ||
| messageId: message.id, | ||
| reactionType: reaction.type, | ||
| ), | ||
| ); | ||
| } else { | ||
| // Reset the message on terminal failure. Use replace (not merge) | ||
| // for symmetry with `sendReaction` — see that method for context. | ||
| state?.replaceMessage(message); | ||
| } | ||
| rethrow; | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ import 'package:rxdart/rxdart.dart'; | |
| import 'package:stream_chat/src/client/channel.dart'; | ||
| import 'package:stream_chat/src/client/channel_delivery_reporter.dart'; | ||
| import 'package:stream_chat/src/client/event_resolvers.dart' as event_resolvers; | ||
| import 'package:stream_chat/src/client/pending_operations_manager.dart'; | ||
| import 'package:stream_chat/src/client/query_channels_result.dart'; | ||
| import 'package:stream_chat/src/client/retry_policy.dart'; | ||
| import 'package:stream_chat/src/core/api/attachment_file_uploader.dart'; | ||
|
|
@@ -165,6 +166,11 @@ class StreamChatClient { | |
| late final _appSettingsManager = AppSettingsManager(_chatApi.general); | ||
| static final _systemEnvironmentManager = SystemEnvironmentManager(); | ||
|
|
||
| /// Owns the queue of pending operations (e.g. reactions added or removed | ||
| /// while offline) and replays them when the connection recovers. | ||
| @internal | ||
| late final pendingOperationsManager = PendingOperationsManager(this); | ||
|
|
||
| /// Updates the system environment information used by the client. | ||
| /// | ||
| /// The passed [environment] is sanitized before being applied: | ||
|
|
@@ -435,6 +441,8 @@ class StreamChatClient { | |
| // Connect to persistence client if its set. | ||
| if (chatPersistenceClient != null) { | ||
| await openPersistenceConnection(ownUser); | ||
| // Restore any operations that were queued before process death. | ||
| await pendingOperationsManager.hydrate(); | ||
| } | ||
|
|
||
| // Connect to websocket if [connectWebSocket] is true. | ||
|
|
@@ -607,6 +615,11 @@ class StreamChatClient { | |
| final connectionRecovered = !wasConnected && isConnected; | ||
|
|
||
| if (connectionRecovered) { | ||
| // Replay pending offline operations (e.g. reactions) BEFORE any | ||
| // server-state refresh, so the server has each mutation before a re-query | ||
| // returns state that would otherwise clobber the optimistic change. | ||
| await pendingOperationsManager.replay(); | ||
|
Comment on lines
+618
to
+621
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Major — the stated invariant only holds when replay succeeds; when it fails retriably the re-query wipes exactly the state this feature is meant to protect. The comment is right about ordering, but it is load-bearing in a way the code doesn't guarantee. If Concretely, Net user-visible behaviour: the reaction added offline disappears on reconnect and reappears at some later recovery when replay finally lands. That is worse than the pre-PR rollback, which was at least immediate and self-consistent. Two options: skip the re-query for channels that still have queued operations, or re-apply the optimistic state after reconciliation. Either way the invariant should be stated as "holds only if replay succeeded", because right now the failure path is the interesting one. Separately, this
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Valid, however I want to discuss the cases where the
I don't think that this is possible - we would have to override the
This is probably feasible, but far from trivial. I think we have to evaluate whether the effort is worth it. Also, I don't think any of the other platforms have such reconciliation logic.
This actually follows all other platforms: In real life, the pending operations queue would almost never be big. If we make this loop unawaited, we will introduce the complexity of having the replay + |
||
|
|
||
| // connection recovered | ||
| final cids = [...state.channels.keys.toSet()]; | ||
| if (cids.isNotEmpty) { | ||
|
|
@@ -2543,6 +2556,10 @@ class StreamChatClient { | |
| state.dispose(); | ||
| state = ClientState(this); | ||
|
|
||
| // clearing the in-memory pending-operation queue so a user's queued | ||
| // operations never replay under the next connected user. | ||
| pendingOperationsManager.clear(); | ||
|
|
||
| // clearing app settings cache. | ||
| _appSettingsManager.clear(); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,205 @@ | ||||||||||||||||||||||
| import 'package:meta/meta.dart'; | ||||||||||||||||||||||
| import 'package:stream_chat/src/client/client.dart'; | ||||||||||||||||||||||
| import 'package:stream_chat/src/client/reaction_pending_operation.dart'; | ||||||||||||||||||||||
| import 'package:stream_chat/src/core/error/error.dart'; | ||||||||||||||||||||||
| import 'package:stream_chat/src/core/models/pending_operation.dart'; | ||||||||||||||||||||||
| import 'package:stream_chat/src/core/models/reaction.dart'; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /// Owns the queue of [PendingOperation]s and replays them against the server | ||||||||||||||||||||||
| /// when the connection is recovered. | ||||||||||||||||||||||
| /// | ||||||||||||||||||||||
| /// The queue lives in memory for the current session and is the single source | ||||||||||||||||||||||
| /// replayed from. When persistence is enabled the queue is additionally | ||||||||||||||||||||||
| /// mirrored to [StreamChatClient.chatPersistenceClient], so operations survive | ||||||||||||||||||||||
| /// process death: [hydrate] loads them back into memory on the next connect. | ||||||||||||||||||||||
| /// Without persistence the queue is session-only, giving reactions same-session | ||||||||||||||||||||||
| /// replay across transient outages. | ||||||||||||||||||||||
| /// | ||||||||||||||||||||||
| /// Replay is at-least-once: an operation is removed only after the server | ||||||||||||||||||||||
| /// accepts or terminally rejects it, so a crash between acceptance and removal | ||||||||||||||||||||||
| /// re-sends it on the next recovery. Every operation type handled by | ||||||||||||||||||||||
| /// [_replayCallFor] must therefore be idempotent on the server — e.g. reactions | ||||||||||||||||||||||
| /// dedupe by (message, type, user). | ||||||||||||||||||||||
| @internal | ||||||||||||||||||||||
| class PendingOperationsManager { | ||||||||||||||||||||||
| /// Creates a manager for [client]'s pending-operation queue. | ||||||||||||||||||||||
| PendingOperationsManager(this._client); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| final StreamChatClient _client; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /// The in-memory queue, replayed in insertion order. | ||||||||||||||||||||||
| /// | ||||||||||||||||||||||
| /// Every entry carries a non-null [PendingOperation.id]: a positive DB | ||||||||||||||||||||||
| /// autoincrement id when the operation is mirrored to persistence, or a | ||||||||||||||||||||||
| /// negative session id otherwise. The two ranges never collide. | ||||||||||||||||||||||
| final _operations = <PendingOperation>[]; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Source of negative, session-only ids for operations that are not persisted. | ||||||||||||||||||||||
| int _memorySeq = 0; | ||||||||||||||||||||||
| int _nextMemoryId() => --_memorySeq; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Prevents overlapping replays. | ||||||||||||||||||||||
| bool _isReplaying = false; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /// Appends [operation] to the queue, mirroring it to persistence when | ||||||||||||||||||||||
| /// enabled so it survives process death. | ||||||||||||||||||||||
| Future<void> enqueue(PendingOperation operation) async { | ||||||||||||||||||||||
| int? id; | ||||||||||||||||||||||
| if (_client.persistenceEnabled) { | ||||||||||||||||||||||
| try { | ||||||||||||||||||||||
| id = await _client.chatPersistenceClient!.insertPendingOperation( | ||||||||||||||||||||||
| operation, | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| } catch (error, stk) { | ||||||||||||||||||||||
| // Keep the operation in memory so it still replays this session. | ||||||||||||||||||||||
| _client.logger.warning( | ||||||||||||||||||||||
| 'Failed to persist pending operation', | ||||||||||||||||||||||
| error, | ||||||||||||||||||||||
| stk, | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| _operations.add(operation.copyWith(id: id ?? _nextMemoryId())); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /// Loads any persisted operations into the in-memory queue. | ||||||||||||||||||||||
| /// | ||||||||||||||||||||||
| /// Called once per connect to restore operations that survived process | ||||||||||||||||||||||
| /// death. A no-op when persistence is disabled. | ||||||||||||||||||||||
| Future<void> hydrate() async { | ||||||||||||||||||||||
| if (!_client.persistenceEnabled) return; | ||||||||||||||||||||||
| try { | ||||||||||||||||||||||
| final stored = await _client.chatPersistenceClient!.getPendingOperations(); | ||||||||||||||||||||||
| _operations | ||||||||||||||||||||||
| ..clear() | ||||||||||||||||||||||
| ..addAll(stored); | ||||||||||||||||||||||
| } catch (error, stk) { | ||||||||||||||||||||||
| _client.logger.warning( | ||||||||||||||||||||||
| 'Failed to hydrate pending operations', | ||||||||||||||||||||||
| error, | ||||||||||||||||||||||
| stk, | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /// Empties the in-memory queue. | ||||||||||||||||||||||
| /// | ||||||||||||||||||||||
| /// Must be called on disconnect so a user's queued operations never replay | ||||||||||||||||||||||
| /// under a different user. The persisted mirror is user-scoped and closed | ||||||||||||||||||||||
| /// separately with the persistence connection. | ||||||||||||||||||||||
| void clear() { | ||||||||||||||||||||||
| _operations.clear(); | ||||||||||||||||||||||
| _memorySeq = 0; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
Comment on lines
+90
to
+93
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Major — resetting
The user's reaction is never sent, local optimistic state keeps showing it, and nothing is logged. Ids need to stay unique for the process lifetime:
Suggested change
This is the demonstrated instance of a broader gap: nothing owns the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pretty related to remark 1, I will see what we can do here. |
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /// Removes the operation with the given [id] from memory and, when persisted, | ||||||||||||||||||||||
| /// from the mirror. A delete of a memory-only (negative) id is a no-op. | ||||||||||||||||||||||
| Future<void> _remove(int id) async { | ||||||||||||||||||||||
| _operations.removeWhere((it) => it.id == id); | ||||||||||||||||||||||
| if (!_client.persistenceEnabled || id < 0) return; | ||||||||||||||||||||||
| try { | ||||||||||||||||||||||
| await _client.chatPersistenceClient!.deletePendingOperation(id); | ||||||||||||||||||||||
| } catch (error, stk) { | ||||||||||||||||||||||
| _client.logger.warning( | ||||||||||||||||||||||
| 'Failed to delete pending operation $id', | ||||||||||||||||||||||
| error, | ||||||||||||||||||||||
| stk, | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /// Replays each queued operation against the server in insertion order. | ||||||||||||||||||||||
| Future<void> replay() async { | ||||||||||||||||||||||
| if (_isReplaying) return; | ||||||||||||||||||||||
| _isReplaying = true; | ||||||||||||||||||||||
|
Comment on lines
+111
to
+114
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Major — replay has exactly one trigger, a dropped trigger is never rescheduled, and there is one supported mode where it never fires at all.
(3) is the sharpest of the three — worth either handling explicitly or documenting as unsupported. The guard itself is also completely uncovered by tests; a test that calls
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| try { | ||||||||||||||||||||||
| // Copy so removals during replay don't mutate the list being iterated. | ||||||||||||||||||||||
| final operations = List.of(_operations); | ||||||||||||||||||||||
| for (final operation in operations) { | ||||||||||||||||||||||
|
Comment on lines
+116
to
+119
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocker —
Sequence (verified against this branch with a throwaway test):
Second-order effect on the same path: The Suggested fix — an epoch bumped by int _generation = 0;
void clear() {
_operations.clear();
_generation++;
}
// inside replay():
final generation = _generation;
for (final operation in operations) {
if (generation != _generation) return; // cleared mid-replay
...
}Checking
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sounds like a valid concern, I will try to implement your proposal! |
||||||||||||||||||||||
| try { | ||||||||||||||||||||||
| final Future<void> Function()? call; | ||||||||||||||||||||||
| try { | ||||||||||||||||||||||
| call = _replayCallFor(operation); | ||||||||||||||||||||||
| } catch (error, stk) { | ||||||||||||||||||||||
| // Malformed payload for a known type — can never be replayed. | ||||||||||||||||||||||
| _client.logger.warning( | ||||||||||||||||||||||
| 'Dropping unreplayable pending operation ${operation.id}', | ||||||||||||||||||||||
| error, | ||||||||||||||||||||||
| stk, | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| await _remove(operation.id!); | ||||||||||||||||||||||
| continue; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if (call == null) { | ||||||||||||||||||||||
| // Unknown type (e.g. persisted by a newer app version) — drop it. | ||||||||||||||||||||||
| _client.logger.warning( | ||||||||||||||||||||||
| 'Dropping unknown pending operation type "${operation.type}" ' | ||||||||||||||||||||||
| '(${operation.id})', | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| await _remove(operation.id!); | ||||||||||||||||||||||
| continue; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| try { | ||||||||||||||||||||||
| await call(); | ||||||||||||||||||||||
| } on StreamChatNetworkError catch (error) { | ||||||||||||||||||||||
| // Keep transient failures for the next recovery. | ||||||||||||||||||||||
| if (error.isRetriable) continue; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Accepted or terminally rejected by the server — drop it. | ||||||||||||||||||||||
| await _remove(operation.id!); | ||||||||||||||||||||||
|
Comment on lines
+145
to
+153
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Major — no fail-fast on connection-level failures, and non-network errors pin an operation forever. Two separate problems in this block: 1. The loop 2. Only Worth noting the related
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 1. Valid concern, I will introduce detection of connectivity errors, and do an early exist in such cases. |
||||||||||||||||||||||
| } catch (error, stk) { | ||||||||||||||||||||||
| _client.logger.warning( | ||||||||||||||||||||||
| 'Error replaying pending operation ${operation.id}', | ||||||||||||||||||||||
| error, | ||||||||||||||||||||||
| stk, | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } catch (error, stk) { | ||||||||||||||||||||||
| _client.logger.severe( | ||||||||||||||||||||||
| 'Error replaying pending operations', | ||||||||||||||||||||||
| error, | ||||||||||||||||||||||
| stk, | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| } finally { | ||||||||||||||||||||||
| _isReplaying = false; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /// Returns the server call that replays [operation], or `null` if its type | ||||||||||||||||||||||
| /// is unknown to this version. | ||||||||||||||||||||||
| Future<void> Function()? _replayCallFor(PendingOperation operation) { | ||||||||||||||||||||||
| switch (operation.type) { | ||||||||||||||||||||||
| case ReactionPendingOperation.addType: | ||||||||||||||||||||||
| final targetMessageId = operation.targetMessageId; | ||||||||||||||||||||||
| if (targetMessageId == null) { | ||||||||||||||||||||||
| throw StateError('Missing targetMessageId for ${operation.type}'); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| final reaction = Reaction.fromJson( | ||||||||||||||||||||||
| operation.payload[ReactionPendingOperation.reactionKey] as Map<String, dynamic>, | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| final skipPush = operation.payload[ReactionPendingOperation.skipPushKey] as bool? ?? false; | ||||||||||||||||||||||
| final enforceUnique = operation.payload[ReactionPendingOperation.enforceUniqueKey] as bool? ?? false; | ||||||||||||||||||||||
| return () => _client.sendReaction( | ||||||||||||||||||||||
| targetMessageId, | ||||||||||||||||||||||
| reaction, | ||||||||||||||||||||||
| skipPush: skipPush, | ||||||||||||||||||||||
| enforceUnique: enforceUnique, | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| case ReactionPendingOperation.deleteType: | ||||||||||||||||||||||
| final targetMessageId = operation.targetMessageId; | ||||||||||||||||||||||
| if (targetMessageId == null) { | ||||||||||||||||||||||
| throw StateError('Missing targetMessageId for ${operation.type}'); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| final reactionType = operation.payload[ReactionPendingOperation.reactionTypeKey] as String; | ||||||||||||||||||||||
| return () => _client.deleteReaction(targetMessageId, reactionType); | ||||||||||||||||||||||
| default: | ||||||||||||||||||||||
| // Unknown operation type — cannot be replayed by this version. | ||||||||||||||||||||||
| return null; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
Comment on lines
+175
to
+204
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Major — inconsistent strictness at the parse boundary; the silent Within one function there are three different policies for missing data:
The third one is the problem. Also note the Minimum fix here is consistency — treat a missing final skipPush = operation.payload[ReactionPendingOperation.skipPushKey] as bool;
final enforceUnique = operation.payload[ReactionPendingOperation.enforceUniqueKey] as bool;The better fix is to stop parsing at replay time altogether — see the suggestion on
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These two fields are optional in the API being replayed.
I also think that renaming the keys is almost impossible in real life - it will be a breaking change. But if you disagree, I am happy to put the |
||||||||||||||||||||||
| } | ||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cross-SDK divergence — this predicate makes Flutter roll back exactly the errors React Native queues.
Raising this as its own thread (it was an aside in my
PendingOperationsManager.replay()comment) because comparing against the RN/JS implementation turns it from a theoretical sharp edge into a behavioural inconsistency between Stream SDKs.RN classifies with an explicit terminal allowlist (
stream-chat/src/offline-support/offline_support_api.ts:1154):Everything not on that list is queued and replayed. Flutter's
isRetriable => data == nullis the inverse: any response carrying a body is terminal. So for the same user action:429 and transient 5xx are the canonical "retry this later" responses, and they're the ones a real device hits under load — so the headline feature currently doesn't engage in the failure modes most likely to occur after plain offline. Worth deciding deliberately rather than inheriting it from
data == null.Two things RN does here that are worth borrowing:
data == nullfails destructive (unknown error with a body ⇒ discard the user's reaction).queueTask(offline_support_api.ts:1127-1136) checkswsConnection?.isHealthyand throws anOfflineErrorwithout a network round-trip, so the common offline case never waits on a Dio timeout. Here every offline reaction pays a full timeout before being queued.I'd leave
isRetriableitself alone — it's pre-existing and shared withRetryQueue. The narrow fix is to not reuse it for this decision, since this is the first place it determines whether local state is kept or reverted rather than merely whether to retry a send.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
isRetriable?wsConnection?.isHealthy- this goes a bit against this comment (pt 3) -> where you mentioned that theconnectWebSocket: falsecase would not work with the current implementation of the replay manager - we should align and decide how to proceed on this topic.