-
Notifications
You must be signed in to change notification settings - Fork 384
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
Open
VelikovPetar
wants to merge
14
commits into
master
Choose a base branch
from
feature/FLU-506_add_reactions_offline_support
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
16594c3
feat(llc, persistence): add offline support for reactions
VelikovPetar 0e6c28b
Merge branch 'master' into feature/FLU-506_add_reactions_offline_support
VelikovPetar 91251ab
Skip e2e test
VelikovPetar ff7a4b1
Merge branch 'master' into feature/FLU-506_add_reactions_offline_support
VelikovPetar 2314f94
fix(llc, persistence): address review feedback on offline reactions
VelikovPetar e816523
Merge branch 'master' into feature/FLU-506_add_reactions_offline_support
VelikovPetar b9c7c39
Merge branch 'master' into feature/FLU-506_add_reactions_offline_support
VelikovPetar e2c2e2b
Fix formatting
VelikovPetar 37f972b
Merge remote-tracking branch 'origin/feature/FLU-506_add_reactions_of…
VelikovPetar 5e99c43
Add missing tests
VelikovPetar 5e1becb
Merge branch 'master' into feature/FLU-506_add_reactions_offline_support
testableapple fc92d9b
feat(ci): Enable 'user adds a reaction while offline' e2e test
VelikovPetar e3abbfa
Merge branch 'master' into feature/FLU-506_add_reactions_offline_support
VelikovPetar b7eae45
feat(llc): replay pending reactions in-memory even without persistence
VelikovPetar File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
205 changes: 205 additions & 0 deletions
205
packages/stream_chat/lib/src/client/pending_operations_manager.dart
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
|
|
||
| /// 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; | ||
|
|
||
| try { | ||
| // Copy so removals during replay don't mutate the list being iterated. | ||
| final operations = List.of(_operations); | ||
| for (final operation in operations) { | ||
| 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!); | ||
| } 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; | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
Not entirely sure if we should always
rethrowhere, maybe just in the non-retry-able case?