Skip to content

Commit 93e4ba5

Browse files
committed
fix(pubsub): remediate mid-stream errors, teardown races, and settings preservation
- Close StreamController on mid-stream gRPC errors in streamingPullWithStream. - Set isCancelled synchronously before cancelAll in streamingPull teardown to prevent races. - Preserve custom settings on Topic.create() and Subscription.create() by returning this. - Align default maxMessages to 100 on PubSub.pull and update error docstrings. - Pass totalTimeout: null in doc_examples.dart for unlimited streaming pull reconnection. - Harmonize BatchingSettings error message to 'Must be greater than zero'. - Add comprehensive tests for mid-stream error completion and 4-stream concurrency teardown. TAG=agy CONV=bc7c6164-e9cb-4a0f-b326-7752c814e1ce
1 parent 499cfff commit 93e4ba5

10 files changed

Lines changed: 276 additions & 10 deletions

File tree

pkgs/google_cloud_pubsub/CHANGELOG.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
## 0.1.0-wip
22

3+
- Preserved configured `publishSettings` and `ackSettings` when calling
4+
`Topic.create()` and `Subscription.create()`, returning `this`.
5+
- Cleanly closed stream controller with `unawaited(controller.close())` on
6+
mid-stream gRPC errors in `PubSub.streamingPullWithStream` to prevent
7+
downstream consumers from hanging.
8+
- Aligned default `maxMessages` in `PubSub.pull` to 100 to match
9+
`Subscription.pull`.
10+
- Synchronously set `isCancelled = true` before stream cancellation in
11+
`Subscription.streamingPull` teardown on non-retryable errors or exhausted
12+
retries to prevent duplicate errors and orphaned reconnect timers.
13+
- Harmonized error messages in `BatchingSettings` to 'Must be greater than
14+
zero'.
15+
316
- Closed stream controller on setup failure in `PubSub.streamingPullWithStream`
417
to prevent consumers from hanging.
518
- Added immediate no-op on empty collections for `PubSub.publishMessages`,
@@ -31,9 +44,10 @@
3144
- Aligned error classification so `StatusCode.aborted` is retryable across
3245
raw `GrpcError` and mapped exceptions, while `StatusCode.dataLoss` is
3346
non-retryable across both.
34-
- Defaulted `totalTimeout` in custom `RetrySettings` passed to
35-
`streamingPull` to `null` (indefinite reconnection) unless explicitly
36-
specified.
47+
- Clarified reconnection timeout in `Subscription.streamingPull`: omitting retry
48+
defaults to unlimited reconnection timeout (`totalTimeout: null`), while
49+
custom `RetrySettings` retain their configured `totalTimeout` (default 1
50+
minute) unless explicitly overridden.
3751
- Replaced constructor assertions in `BatchingSettings` and `RetrySettings`
3852
with always-on parameter validation, and updated `PublishSettings` and
3953
`AckSettings` constructors to default to newly created instances.

pkgs/google_cloud_pubsub/example/doc_examples.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Future<void> streamingPullExample(PubSub pubsub) async {
2626
streamAckDeadlineSeconds: 30,
2727
retry: RetrySettings(
2828
maxRetries: 10,
29+
totalTimeout: null,
2930
initialDelay: const Duration(seconds: 1),
3031
maxDelay: const Duration(seconds: 30),
3132
),

pkgs/google_cloud_pubsub/lib/src/batching.dart

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,15 @@ final class BatchingSettings {
4242
throw ArgumentError.value(
4343
maxMessages,
4444
'maxMessages',
45-
'Must be greater than 0',
45+
'Must be greater than zero',
4646
);
4747
}
4848
if (maxBytes <= 0) {
49-
throw ArgumentError.value(maxBytes, 'maxBytes', 'Must be greater than 0');
49+
throw ArgumentError.value(
50+
maxBytes,
51+
'maxBytes',
52+
'Must be greater than zero',
53+
);
5054
}
5155
if (maxDelay <= Duration.zero) {
5256
throw ArgumentError.value(

pkgs/google_cloud_pubsub/lib/src/client.dart

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,12 +361,14 @@ final class PubSub {
361361
/// The [subscription] must be in the format
362362
/// `projects/<project-id>/subscriptions/<subscription-id>`.
363363
///
364+
/// It is an error if [maxMessages] is not greater than 0.
365+
///
364366
/// Throws a [NotFoundException] if the subscription does not exist.
365367
///
366368
/// See the [official documentation](https://cloud.google.com/pubsub/docs/reference/rpc/google.pubsub.v1#google.pubsub.v1.Subscriber.Pull).
367369
Future<List<ReceivedMessage>> pull(
368370
String subscription, {
369-
int maxMessages = 1,
371+
int maxMessages = 100,
370372
}) async {
371373
if (maxMessages <= 0) {
372374
throw ArgumentError.value(
@@ -455,6 +457,7 @@ final class PubSub {
455457
} else {
456458
controller.addError(e, s);
457459
}
460+
unawaited(controller.close());
458461
},
459462
onDone: () {
460463
controller.close();

pkgs/google_cloud_pubsub/lib/src/subscription.dart

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -271,8 +271,10 @@ final class Subscription {
271271
/// Returns a [Subscription] instance representing the created subscription.
272272
///
273273
/// See the [official documentation](https://cloud.google.com/pubsub/docs/reference/rpc/google.pubsub.v1#google.pubsub.v1.Subscriber.CreateSubscription).
274-
Future<Subscription> create({required String topic}) =>
275-
pubsub.createSubscription(name, topic: topic);
274+
Future<Subscription> create({required String topic}) async {
275+
await pubsub.createSubscription(name, topic: topic);
276+
return this;
277+
}
276278

277279
/// Deletes this subscription on the server.
278280
///
@@ -283,6 +285,9 @@ final class Subscription {
283285

284286
/// Pulls up to [maxMessages] from this subscription.
285287
///
288+
/// It is an error if [maxMessages] is not greater than 0.
289+
/// It is an error if called on a closed [Subscription].
290+
///
286291
/// Throws a [NotFoundException] if the subscription does not exist.
287292
///
288293
/// See the [official documentation](https://cloud.google.com/pubsub/docs/reference/rpc/google.pubsub.v1#google.pubsub.v1.Subscriber.Pull).
@@ -311,7 +316,9 @@ final class Subscription {
311316
///
312317
/// The stream automatically reconnects on transient network errors using the
313318
/// configured [retry] settings (defaulting to [AckSettings.retry] with
314-
/// unlimited total duration).
319+
/// unlimited total duration). Custom [RetrySettings] retain their configured
320+
/// [RetrySettings.totalTimeout] (which defaults to 1 minute) unless
321+
/// `totalTimeout: null` is passed for unlimited reconnection duration.
315322
/// Reconnections use exponential backoff, which resets once a connection
316323
/// has been sustained and healthy (>= 15 seconds) or successfully yields
317324
/// messages.
@@ -454,6 +461,7 @@ final class Subscription {
454461
if (_isClosed || isCancelled || controller.isClosed) return;
455462

456463
if (error != null && !isRetryable(error)) {
464+
isCancelled = true;
457465
controller.addError(error, stackTrace);
458466
await cancelAll();
459467
activeOrReconnectingStreams = 0;
@@ -489,6 +497,7 @@ final class Subscription {
489497
controller.addError(error, stackTrace);
490498
}
491499
if (activeOrReconnectingStreams == 0) {
500+
isCancelled = true;
492501
await cancelAll();
493502
_activeStreamingPullControllers.remove(controller);
494503
await controller.close();

pkgs/google_cloud_pubsub/lib/src/topic.dart

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,10 @@ final class Topic {
159159
/// Returns a [Topic] instance representing the created topic.
160160
///
161161
/// See the [official documentation](https://cloud.google.com/pubsub/docs/reference/rpc/google.pubsub.v1#google.pubsub.v1.Publisher.CreateTopic).
162-
Future<Topic> create() => pubsub.createTopic(name);
162+
Future<Topic> create() async {
163+
await pubsub.createTopic(name);
164+
return this;
165+
}
163166

164167
/// Deletes this topic on the server.
165168
///

pkgs/google_cloud_pubsub/test/batching_test.dart

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,5 +187,78 @@ void main() {
187187
throwsA(isA<ArgumentError>()),
188188
);
189189
});
190+
191+
test('parameter validation error messages are harmonized', () {
192+
expect(
193+
() => BatchingSettings(maxMessages: 0),
194+
throwsA(
195+
isA<ArgumentError>().having(
196+
(e) => e.message,
197+
'message',
198+
'Must be greater than zero',
199+
),
200+
),
201+
);
202+
expect(
203+
() => BatchingSettings(maxMessages: -1),
204+
throwsA(
205+
isA<ArgumentError>().having(
206+
(e) => e.message,
207+
'message',
208+
'Must be greater than zero',
209+
),
210+
),
211+
);
212+
expect(
213+
() => BatchingSettings(maxBytes: 0),
214+
throwsA(
215+
isA<ArgumentError>().having(
216+
(e) => e.message,
217+
'message',
218+
'Must be greater than zero',
219+
),
220+
),
221+
);
222+
expect(
223+
() => BatchingSettings(maxBytes: -10),
224+
throwsA(
225+
isA<ArgumentError>().having(
226+
(e) => e.message,
227+
'message',
228+
'Must be greater than zero',
229+
),
230+
),
231+
);
232+
expect(
233+
() => BatchingSettings(maxDelay: Duration.zero),
234+
throwsA(
235+
isA<ArgumentError>().having(
236+
(e) => e.message,
237+
'message',
238+
'Must be greater than zero',
239+
),
240+
),
241+
);
242+
expect(
243+
() => BatchingSettings(maxDelay: const Duration(milliseconds: -1)),
244+
throwsA(
245+
isA<ArgumentError>().having(
246+
(e) => e.message,
247+
'message',
248+
'Must be greater than zero',
249+
),
250+
),
251+
);
252+
expect(
253+
() => BatchingSettings(maxDelay: const Duration(seconds: -10)),
254+
throwsA(
255+
isA<ArgumentError>().having(
256+
(e) => e.message,
257+
'message',
258+
'Must be greater than zero',
259+
),
260+
),
261+
);
262+
});
190263
});
191264
}

pkgs/google_cloud_pubsub/test/lifecycle_test.dart

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,12 @@ class FakePublisherClient extends Fake implements generated.PublisherClient {
8585
}
8686
return FakeResponseFuture(completer.future);
8787
}
88+
89+
@override
90+
grpc.ResponseFuture<generated.Topic> createTopic(
91+
generated.Topic request, {
92+
grpc.CallOptions? options,
93+
}) => FakeResponseFuture(Future.value(request));
8894
}
8995

9096
class FakeSubscriberClient extends Fake implements generated.SubscriberClient {
@@ -137,6 +143,12 @@ class FakeSubscriberClient extends Fake implements generated.SubscriberClient {
137143
lastMaxMessages = request.maxMessages;
138144
return FakeResponseFuture(Future.value(generated.PullResponse()));
139145
}
146+
147+
@override
148+
grpc.ResponseFuture<generated.Subscription> createSubscription(
149+
generated.Subscription request, {
150+
grpc.CallOptions? options,
151+
}) => FakeResponseFuture(Future.value(request));
140152
}
141153

142154
class FakeClientChannel extends Fake implements grpc.ClientChannel {
@@ -303,6 +315,17 @@ void main() {
303315
expect(fakePublisher.publishCalled, isFalse);
304316
},
305317
);
318+
319+
test('Topic.create() returns this and preserves publishSettings', () async {
320+
final customSettings = PublishSettings(
321+
batching: BatchingSettings(maxMessages: 42),
322+
);
323+
final topic = client.topic('my-topic', publishSettings: customSettings);
324+
final created = await topic.create();
325+
expect(identical(created, topic), isTrue);
326+
expect(created.publishSettings, same(customSettings));
327+
expect(created.publishSettings.batching.maxMessages, equals(42));
328+
});
306329
});
307330

308331
group('Subscription Lifecycle & Batcher', () {
@@ -412,6 +435,22 @@ void main() {
412435
expect(fakeSubscriber.modifyAckDeadlineCalled, isFalse);
413436
},
414437
);
438+
439+
test(
440+
'Subscription.create() returns this and preserves ackSettings',
441+
() async {
442+
final customSettings = AckSettings(
443+
batching: BatchingSettings(maxMessages: 42),
444+
);
445+
final sub = client.subscription('my-sub', ackSettings: customSettings);
446+
final created = await sub.create(
447+
topic: 'projects/test-project/topics/my-topic',
448+
);
449+
expect(identical(created, sub), isTrue);
450+
expect(created.ackSettings, same(customSettings));
451+
expect(created.ackSettings.batching.maxMessages, equals(42));
452+
},
453+
);
415454
});
416455

417456
group('PubSub Client Empty List No-Ops & Parameter Validation', () {
@@ -523,5 +562,18 @@ void main() {
523562
throwsA(isA<ArgumentError>()),
524563
);
525564
});
565+
566+
test('PubSub.pull defaults to maxMessages 100', () async {
567+
await client.pull('projects/test-project/subscriptions/my-sub');
568+
expect(fakeSubscriber.pullCalled, isTrue);
569+
expect(fakeSubscriber.lastMaxMessages, equals(100));
570+
});
571+
572+
test('Subscription.pull defaults to maxMessages 100', () async {
573+
final sub = client.subscription('my-sub');
574+
await sub.pull();
575+
expect(fakeSubscriber.pullCalled, isTrue);
576+
expect(fakeSubscriber.lastMaxMessages, equals(100));
577+
});
526578
});
527579
}

0 commit comments

Comments
 (0)