Skip to content

Commit 3881dca

Browse files
feywindgcf-owl-bot[bot]gemini-code-assist[bot]
authored
feat(pubsub): add support for streaming pull keepalives from the server (googleapis#7819)
* feat(pubsub): add support for streaming pull keepalives from the server * feat(pubsub): update algorithm to match Java impl * fix(pubsub): correct clientId value * chore: improve comment * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * fix: accept gemini suggestion for setTimeout * fix: accept gemini suggestion * fix: accept gemini suggestion Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * fix: accept gemini suggestion * fix: accept gemini test suggestion * fix: pull timeout into a constant --------- Co-authored-by: Owl Bot <gcf-owl-bot[bot]@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 7c89205 commit 3881dca

2 files changed

Lines changed: 114 additions & 2 deletions

File tree

handwritten/pubsub/src/message-stream.ts

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {defaultOptions} from './default-options';
2626
import {Duration} from './temporal';
2727
import {ExponentialRetry} from './exponential-retry';
2828
import {DebugMessage} from './debug';
29+
import {randomUUID} from 'crypto';
2930
import {logs as baseLogs, LoggingFunction} from './logs';
3031

3132
/**
@@ -77,6 +78,8 @@ const DEFAULT_OPTIONS: MessageStreamOptions = {
7778
retryMaxBackoff: Duration.from({seconds: 60}),
7879
};
7980

81+
const SERVER_KEEP_ALIVE_INTERVAL = 15000;
82+
8083
interface StreamState {
8184
highWaterMark: number;
8285
}
@@ -139,6 +142,9 @@ export class ChannelError extends Error implements grpc.ServiceError {
139142
interface StreamTracked {
140143
stream?: PullStream;
141144
receivedStatus?: boolean;
145+
lastPingTime?: number;
146+
lastResponseTime?: number;
147+
aliveTimer?: NodeJS.Timeout;
142148
}
143149

144150
/**
@@ -200,7 +206,7 @@ export class MessageStream extends PassThrough {
200206
*/
201207
setStreamAckDeadline(deadline: Duration) {
202208
const request: StreamingPullRequest = {
203-
streamAckDeadlineSeconds: deadline.totalOf('second'),
209+
streamAckDeadlineSeconds: deadline.seconds,
204210
};
205211

206212
for (const tracker of this._streams) {
@@ -227,6 +233,9 @@ export class MessageStream extends PassThrough {
227233

228234
for (let i = 0; i < this._streams.length; i++) {
229235
const tracker = this._streams[i];
236+
if (tracker.aliveTimer) {
237+
this._clearAliveTimer(tracker);
238+
}
230239
if (tracker.stream) {
231240
this._removeStream(i, 'overall message stream destroyed', 'n/a');
232241
}
@@ -253,6 +262,7 @@ export class MessageStream extends PassThrough {
253262
const tracker = this._streams[index];
254263
tracker.stream = stream;
255264
tracker.receivedStatus = false;
265+
tracker.lastResponseTime = Date.now();
256266

257267
stream
258268
.on('error', err => this._onError(index, err))
@@ -263,11 +273,46 @@ export class MessageStream extends PassThrough {
263273
private _onData(index: number, data: PullResponse): void {
264274
// Mark this stream as alive again. (reset backoff)
265275
const tracker = this._streams[index];
276+
tracker.lastResponseTime = Date.now();
266277
this._retrier.reset(tracker);
267278

268279
this.emit('data', data);
269280
}
270281

282+
private _clearAliveTimer(tracker: StreamTracked): void {
283+
if (tracker.aliveTimer) {
284+
clearTimeout(tracker.aliveTimer);
285+
tracker.aliveTimer = undefined;
286+
}
287+
}
288+
289+
private _checkAliveTimer(index: number): void {
290+
const tracker = this._streams[index];
291+
const lastPingTime = tracker.lastPingTime ?? -1;
292+
const lastResponseTime = tracker.lastResponseTime ?? 0;
293+
if (lastPingTime <= lastResponseTime) {
294+
return;
295+
}
296+
297+
this._removeStream(
298+
index,
299+
'no keepalive response from server within 15 seconds',
300+
'will be retried',
301+
);
302+
this._retrier.retryLater(tracker, () =>
303+
this._fillOne(index, undefined, 'retry'),
304+
);
305+
}
306+
307+
private _setAliveTimer(index: number): void {
308+
const tracker = this._streams[index];
309+
this._clearAliveTimer(tracker);
310+
311+
tracker.aliveTimer = setTimeout(() => {
312+
this._checkAliveTimer(index);
313+
}, SERVER_KEEP_ALIVE_INTERVAL);
314+
}
315+
271316
/**
272317
* Attempts to create and cache the desired number of StreamingPull requests.
273318
* gRPC does not supply a way to confirm that a stream is connected, so our
@@ -347,6 +392,8 @@ export class MessageStream extends PassThrough {
347392
maxOutstandingBytes: this._subscriber.useLegacyFlowControl
348393
? 0
349394
: this._subscriber.maxBytes,
395+
clientId: randomUUID().toString(),
396+
protocolVersion: 1, // Set protocol version to enable server keepalives
350397
};
351398
const otherArgs = {
352399
headers: {
@@ -386,12 +433,14 @@ export class MessageStream extends PassThrough {
386433
'sending keepAlive to %i streams',
387434
this._streams.length,
388435
);
389-
this._streams.forEach(tracker => {
436+
this._streams.forEach((tracker, index) => {
390437
// It's possible that a status event fires off (signaling the rpc being
391438
// closed) but the stream hasn't drained yet. Writing to such a stream will
392439
// result in a `write after end` error.
393440
if (!tracker.receivedStatus && tracker.stream) {
394441
tracker.stream.write({});
442+
tracker.lastPingTime = Date.now();
443+
this._setAliveTimer(index);
395444
}
396445
});
397446
}
@@ -511,6 +560,9 @@ export class MessageStream extends PassThrough {
511560
whatNext?: string,
512561
): void {
513562
const tracker = this._streams[index];
563+
if (tracker.aliveTimer) {
564+
this._clearAliveTimer(tracker);
565+
}
514566
if (tracker.stream) {
515567
logs.subscriberStreams.info(
516568
'closing stream %i; why: %s; next: %s',

handwritten/pubsub/test/message-stream.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,24 @@ describe('MessageStream', () => {
523523
});
524524

525525
describe('keeping streams alive', () => {
526+
it('should set protocolVersion in the initial packet', async () => {
527+
// The special handling for messageStream and the spy below are
528+
// so that we can test the initial message.
529+
messageStream.destroy();
530+
531+
const spy = sandbox.spy(FakeGrpcStream.prototype, 'write');
532+
const ms = new MessageStream(subscriber);
533+
await ms.start();
534+
535+
assert.strictEqual(spy.callCount, 5);
536+
const {args} = spy.firstCall;
537+
const request = args[0] as any;
538+
539+
assert.strictEqual(String(request.protocolVersion), '1');
540+
541+
ms.destroy();
542+
});
543+
526544
it('should keep the streams alive', () => {
527545
const frequency = 30000;
528546
const stubs = client.streams.map(stream => {
@@ -536,6 +554,48 @@ describe('MessageStream', () => {
536554
assert.deepStrictEqual(data, {});
537555
});
538556
});
557+
558+
it('should close stream if no data received for 15 seconds after keepalive', async () => {
559+
messageStream.destroy();
560+
client.streams.length = 0;
561+
562+
const ms = new MessageStream(subscriber);
563+
await ms.start();
564+
565+
const cancelSpies = client.streams.map(s => sandbox.spy(s, 'cancel'));
566+
567+
// wait for keepalive ping (30s) + 15s timeout
568+
sandbox.clock.tick(45000);
569+
570+
cancelSpies.forEach(spy => {
571+
assert.strictEqual(spy.callCount, 1);
572+
});
573+
574+
ms.destroy();
575+
});
576+
577+
it('should not close stream if data received within 15 seconds of keepalive', async () => {
578+
messageStream.destroy();
579+
580+
const ms = new MessageStream(subscriber);
581+
await ms.start();
582+
583+
const cancelSpies = client.streams.map(s => sandbox.spy(s, 'cancel'));
584+
585+
sandbox.clock.tick(30000);
586+
587+
// Simulating data prevents timeout
588+
client.streams.forEach(s => s.emit('data', {}));
589+
590+
// Wait for 15s timeout to pass
591+
sandbox.clock.tick(15000);
592+
593+
cancelSpies.forEach(spy => {
594+
assert.strictEqual(spy.callCount, 0);
595+
});
596+
597+
ms.destroy();
598+
});
539599
});
540600

541601
it('should allow updating the ack deadline', async () => {

0 commit comments

Comments
 (0)