33
44import 'dart:async' ;
55
6+ import 'package:aws_kinesis_datastreams/aws_kinesis_datastreams.dart' show AmplifyKinesisClient;
7+ import 'package:aws_kinesis_datastreams/src/amplify_kinesis_client.dart' show AmplifyKinesisClient;
68import 'package:aws_kinesis_datastreams/src/db/kinesis_record_database.dart' ;
79import 'package:aws_kinesis_datastreams/src/exception/amplify_kinesis_exception.dart' ;
810import 'package:aws_kinesis_datastreams/src/impl/auto_flush_scheduler.dart' ;
@@ -16,6 +18,8 @@ import 'package:aws_kinesis_datastreams/src/model/flush_data.dart';
1618/// {@template aws_kinesis_datastreams.record_client}
1719/// Orchestrates record operations, managing the flow between storage,
1820/// scheduling, and sending.
21+ ///
22+ /// Not `final` to allow mocking in tests via [AmplifyKinesisClient.withRecordClient] .
1923/// {@endtemplate}
2024class RecordClient {
2125 /// {@macro aws_kinesis_datastreams.record_client}
@@ -41,38 +45,79 @@ class RecordClient {
4145 bool _closed = false ;
4246 bool _flushing = false ;
4347
48+ /// In-memory cache size tracker to avoid a DB query on every record() call.
49+ /// Recalculated from DB after deletes (flush/clearCache).
50+ int _cachedSize = 0 ;
51+ bool _cacheSizeInitialized = false ;
52+
53+ /// Simple async lock to prevent concurrent record() calls from
54+ /// racing on cache size checks.
55+ Completer <void >? _recordLock;
56+
4457 /// Maximum batch size in bytes (10 MiB Kinesis PutRecords limit).
45- static const int maxBatchSizeBytes = 10 * 1024 * 1024 ;
58+ static const int maxBatchSizeBytes = kKinesisMaxBatchBytes ;
4659
4760 /// Whether the client is currently enabled.
4861 bool get isEnabled => _enabled;
4962
5063 /// Whether the client has been closed.
5164 bool get isClosed => _closed;
5265
66+ /// Ensures the in-memory cache size is initialized from the database.
67+ Future <void > _ensureCacheSizeInitialized () async {
68+ if (! _cacheSizeInitialized) {
69+ _cachedSize = await _storage.getCurrentCacheSize ();
70+ _cacheSizeInitialized = true ;
71+ }
72+ }
73+
5374 /// Records data to the local cache.
5475 ///
5576 /// Throws [ClientClosedException] if the client has been closed.
77+ /// Throws [KinesisPartitionKeyInvalidException] if the partition key is
78+ /// empty or exceeds 256 characters.
5679 /// Throws [KinesisRecordTooLargeException] if the record exceeds the
5780 /// per-record size limit (10 MiB, partition key + data blob).
5881 /// Throws [KinesisLimitExceededException] if the cache is full.
5982 Future <void > record (KinesisRecord record) async {
6083 if (_closed) throw ClientClosedException ();
6184 if (! _enabled) return ;
6285
86+ // Validate partition key length (Kinesis requires 1-256 characters).
87+ if (record.partitionKey.isEmpty ||
88+ record.partitionKey.length > kKinesisMaxPartitionKeyLength) {
89+ throw KinesisPartitionKeyInvalidException (
90+ keyLength: record.partitionKey.length,
91+ );
92+ }
93+
6394 if (record.dataSize > kKinesisMaxRecordBytes) {
6495 throw KinesisRecordTooLargeException (
6596 recordBytes: record.dataSize,
6697 maxBytes: kKinesisMaxRecordBytes,
6798 );
6899 }
69100
70- final currentSize = await _storage. getCurrentCacheSize ();
71- if (currentSize + record.dataSize > _storage.maxCacheBytes ) {
72- throw KinesisLimitExceededException () ;
101+ // Acquire async lock to prevent concurrent cache size races.
102+ while (_recordLock != null ) {
103+ await _recordLock ! .future ;
73104 }
105+ _recordLock = Completer <void >();
106+
107+ try {
108+ await _ensureCacheSizeInitialized ();
74109
75- await _storage.saveRecord (record);
110+ if (_cachedSize + record.dataSize > _storage.maxCacheBytes) {
111+ throw KinesisLimitExceededException ();
112+ }
113+
114+ await _storage.saveRecord (record);
115+ _cachedSize += record.dataSize;
116+ } finally {
117+ final lock = _recordLock! ;
118+ _recordLock = null ;
119+ lock.complete ();
120+ }
76121 }
77122
78123 /// Flushes all cached records to Kinesis.
@@ -86,14 +131,36 @@ class RecordClient {
86131 var totalFlushed = 0 ;
87132
88133 try {
89- while (true ) {
90- final batch = await _storage.getRecordsBatch (
91- maxCount: _maxRecords,
92- maxBytes: maxBatchSizeBytes,
93- );
134+ // Safety bound: limit iterations to prevent infinite loops if records
135+ // keep failing but never exceed retries within a single flush cycle.
136+ var iterations = 0 ;
137+ const maxIterations = 100 ;
138+
139+ var consecutiveNoProgress = 0 ;
140+ // Allow enough no-progress iterations for records to exhaust their
141+ // retries before considering the batch stuck.
142+ final maxConsecutiveNoProgress = _maxRetries + 2 ;
143+
144+ while (iterations < maxIterations) {
145+ iterations++ ;
146+
147+ List <StoredRecord > batch;
148+ try {
149+ batch = await _storage.getRecordsBatch (
150+ maxCount: _maxRecords,
151+ maxBytes: maxBatchSizeBytes,
152+ );
153+ } on Exception catch (e) {
154+ throw KinesisStorageException (
155+ 'Failed to retrieve records batch' ,
156+ cause: e,
157+ );
158+ }
94159
95160 if (batch.isEmpty) break ;
96161
162+ final countBefore = await _storage.getRecordCount ();
163+
97164 final recordsByStream = < String , List <StoredRecord >> {};
98165 for (final record in batch) {
99166 recordsByStream.putIfAbsent (record.streamName, () => []).add (record);
@@ -105,58 +172,102 @@ class RecordClient {
105172 }
106173
107174 await _storage.deleteRecordsExceedingRetries (_maxRetries);
175+
176+ // Track whether the batch is making progress. If the record count
177+ // hasn't decreased for several consecutive iterations, the batch
178+ // is stuck (e.g. all records are retryable but haven't exceeded
179+ // max retries yet) — break to avoid spinning.
180+ final countAfter = await _storage.getRecordCount ();
181+ if (countAfter < countBefore) {
182+ consecutiveNoProgress = 0 ;
183+ } else {
184+ consecutiveNoProgress++ ;
185+ if (consecutiveNoProgress >= maxConsecutiveNoProgress) break ;
186+ }
108187 }
188+
189+ // Recalculate in-memory cache size from DB after deletes.
190+ _cachedSize = await _storage.getCurrentCacheSize ();
109191 } finally {
110192 _flushing = false ;
111193 }
112194
113195 return FlushData (recordsFlushed: totalFlushed);
114196 }
115197
116- Future <int > _sendStreamBatch (String streamName, List <StoredRecord > records) async {
198+ Future <int > _sendStreamBatch (
199+ String streamName,
200+ List <StoredRecord > records,
201+ ) async {
117202 final senderRecords = records
118- .map ((r) => KinesisSenderRecord (data: r.data, partitionKey: r.partitionKey))
203+ .map (
204+ (r) => KinesisSenderRecord (
205+ data: r.data,
206+ partitionKey: r.partitionKey,
207+ ),
208+ )
119209 .toList ();
120210
211+ PutRecordsResult result;
121212 try {
122- final result = await _sender.putRecords (streamName: streamName, records: senderRecords);
123-
124- await _storage.deleteRecords (result.successfulRecordIndices.map ((i) => records[i].id));
125- await _storage.incrementRetryCount (result.retryableRecordIndices.map ((i) => records[i].id));
126- await _storage.deleteRecords (result.failedRecordIndices.map ((i) => records[i].id));
127-
128- return result.successfulRecordIndices.length;
213+ result = await _sender.putRecords (
214+ streamName: streamName,
215+ records: senderRecords,
216+ );
129217 } on Exception {
218+ // Sender/SDK errors — increment retry count and continue.
219+ // Non-SDK exceptions (e.g. storage errors) are not caught here
220+ // because they originate from _storage calls below, not _sender.
130221 await _storage.incrementRetryCount (records.map ((r) => r.id));
131222 return 0 ;
132223 }
224+
225+ // Storage operations after a successful send propagate errors to caller.
226+ await _storage.deleteRecords (
227+ result.successfulRecordIndices.map ((i) => records[i].id),
228+ );
229+ await _storage.incrementRetryCount (
230+ result.retryableRecordIndices.map ((i) => records[i].id),
231+ );
232+ await _storage.deleteRecords (
233+ result.failedRecordIndices.map ((i) => records[i].id),
234+ );
235+
236+ return result.successfulRecordIndices.length;
133237 }
134238
135239 /// Clears all cached records.
136240 Future <ClearCacheData > clearCache () async {
137241 final count = await _storage.getRecordCount ();
138242 await _storage.clear ();
243+ // Reset in-memory cache size after clearing.
244+ _cachedSize = 0 ;
139245 return ClearCacheData (recordsCleared: count);
140246 }
141247
248+ /// Enables the client to accept and flush records.
142249 void enable () {
143250 _enabled = true ;
144251 _scheduler.enable ();
145252 }
146253
254+ /// Disables the client from accepting and flushing records.
147255 void disable () {
148256 _enabled = false ;
149257 _scheduler.disable ();
150258 }
151259
260+ /// Enables automatic flush operations.
152261 void enableAutoFlush () {
153262 _scheduler.enable ();
154263 }
155264
265+ /// Disables automatic flush operations.
156266 void disableAutoFlush () {
157267 _scheduler.disable ();
158268 }
159269
270+ /// Closes the client and releases all resources.
160271 Future <void > close () async {
161272 _closed = true ;
162273 await _scheduler.close ();
0 commit comments