Skip to content

Commit 366f5e2

Browse files
authored
feat: enable write retry and nack pending writes on reconnect (#443)
1 parent 7a561a7 commit 366f5e2

5 files changed

Lines changed: 536 additions & 118 deletions

File tree

handwritten/bigquery-storage/src/managedwriter/pending_write.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,41 @@ type AppendRowRequest =
2828
export class PendingWrite {
2929
private request: AppendRowRequest;
3030
private response?: AppendRowsResponse;
31+
private attempts: number;
3132
private promise: Promise<AppendRowsResponse>;
3233
private resolveFunc?: (response: AppendRowsResponse) => void;
3334
private rejectFunc?: (reason?: protos.google.rpc.IStatus) => void;
3435

3536
constructor(request: AppendRowRequest) {
3637
this.request = request;
38+
this.attempts = 0;
3739
this.promise = new Promise((resolve, reject) => {
3840
this.resolveFunc = resolve;
3941
this.rejectFunc = reject;
4042
});
4143
}
4244

45+
/**
46+
* Increase number of attempts and return current value.
47+
*
48+
* @private
49+
* @internal
50+
* @returns {number} current number of attempts
51+
*/
52+
_increaseAttempts(): number {
53+
return this.attempts++;
54+
}
55+
56+
/**
57+
* Resolve pending write with error or AppendRowResponse.
58+
* This resolves the promise accessed via GetResult()
59+
*
60+
* @see GetResult
61+
*
62+
* @private
63+
* @internal
64+
* @returns {number} current number of attempts
65+
*/
4366
_markDone(err: Error | null, response?: AppendRowsResponse) {
4467
if (err) {
4568
this.rejectFunc && this.rejectFunc(err);

handwritten/bigquery-storage/src/managedwriter/stream_connection.ts

Lines changed: 102 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import * as protos from '../../protos/protos';
1919
import {WriterClient} from './writer_client';
2020
import {PendingWrite} from './pending_write';
2121
import {logger} from './logger';
22-
import {parseStorageErrors} from './error';
2322

2423
type TableSchema = protos.google.cloud.bigquery.storage.v1.ITableSchema;
2524
type IInt64Value = protos.google.protobuf.IInt64Value;
@@ -56,6 +55,7 @@ export class StreamConnection extends EventEmitter {
5655
private _streamId: string;
5756
private _writeClient: WriterClient;
5857
private _connection?: gax.CancellableStream | null;
58+
private _lastConnectionError?: gax.GoogleError | null;
5959
private _callOptions?: gax.CallOptions;
6060
private _pendingWrites: PendingWrite[];
6161

@@ -76,6 +76,7 @@ export class StreamConnection extends EventEmitter {
7676
if (this.isOpen()) {
7777
this.close();
7878
}
79+
this._lastConnectionError = null;
7980
const callOptions = this.resolveCallOptions(
8081
this._streamId,
8182
this._callOptions
@@ -86,7 +87,23 @@ export class StreamConnection extends EventEmitter {
8687
this._connection.on('data', this.handleData);
8788
this._connection.on('error', this.handleError);
8889
this._connection.on('close', () => {
89-
this.trace('connection closed');
90+
this.trace('connection closed', this._lastConnectionError);
91+
if (this.hasPendingWrites()) {
92+
const retrySettings = this._writeClient._retrySettings;
93+
if (
94+
retrySettings.enableWriteRetries &&
95+
this.isRetryableError(this._lastConnectionError)
96+
) {
97+
this.reconnect();
98+
this.resendAllPendingWrites();
99+
} else {
100+
const err = new gax.GoogleError(
101+
'Connection failure, please retry the request'
102+
);
103+
err.code = gax.Status.UNAVAILABLE;
104+
this.ackAllPendingWrites(err);
105+
}
106+
}
90107
});
91108
this._connection.on('pause', () => {
92109
this.trace('connection paused');
@@ -106,62 +123,53 @@ export class StreamConnection extends EventEmitter {
106123

107124
private handleError = (err: gax.GoogleError) => {
108125
this.trace('on error', err, JSON.stringify(err));
109-
if (this.shouldReconnect(err)) {
110-
this.reconnect();
111-
return;
112-
}
113-
let nextPendingWrite = this.getNextPendingWrite();
114-
if (this.isPermanentError(err)) {
115-
this.trace('found permanent error', err);
116-
while (nextPendingWrite) {
117-
this.ackNextPendingWrite(err);
118-
nextPendingWrite = this.getNextPendingWrite();
119-
}
120-
this.emit('error', err);
121-
return;
122-
}
123-
if (this.isRequestError(err) && nextPendingWrite) {
126+
this._lastConnectionError = err;
127+
const nextPendingWrite = this.getNextPendingWrite();
128+
if (nextPendingWrite) {
124129
this.trace(
125130
'found request error with pending write',
126131
err,
127132
nextPendingWrite
128133
);
129-
this.ackNextPendingWrite(err);
134+
this.handleRetry(err);
135+
}
136+
if (this.listenerCount('error') === 0 && this.isRetryableError(err)) {
130137
return;
131138
}
132139
this.emit('error', err);
133140
};
134141

135-
private shouldReconnect(err: gax.GoogleError): boolean {
136-
const reconnectionErrorCodes = [
137-
gax.Status.UNAVAILABLE,
138-
gax.Status.RESOURCE_EXHAUSTED,
142+
private handleRetry(err: gax.GoogleError) {
143+
const retrySettings = this._writeClient._retrySettings;
144+
if (retrySettings.enableWriteRetries && this.isRetryableError(err)) {
145+
if (!this.isConnectionClosed()) {
146+
const pw = this._pendingWrites.pop()!;
147+
this.send(pw);
148+
}
149+
} else {
150+
this.ackNextPendingWrite(err);
151+
}
152+
}
153+
154+
private isRetryableError(err?: gax.GoogleError | null): boolean {
155+
if (!err) {
156+
return false;
157+
}
158+
const errorCodes = [
139159
gax.Status.ABORTED,
160+
gax.Status.UNAVAILABLE,
140161
gax.Status.CANCELLED,
141-
gax.Status.DEADLINE_EXCEEDED,
142162
gax.Status.INTERNAL,
163+
gax.Status.DEADLINE_EXCEEDED,
143164
];
144-
return !!err.code && reconnectionErrorCodes.includes(err.code);
165+
return !!err.code && errorCodes.includes(err.code);
145166
}
146167

147-
private isPermanentError(err: gax.GoogleError): boolean {
148-
if (err.code === gax.Status.INVALID_ARGUMENT) {
149-
const storageErrors = parseStorageErrors(err);
150-
for (const storageError of storageErrors) {
151-
if (
152-
storageError.errorMessage?.includes(
153-
'Schema mismatch due to extra fields in user schema'
154-
)
155-
) {
156-
return true;
157-
}
158-
}
168+
private isConnectionClosed() {
169+
if (this._connection) {
170+
return this._connection.destroyed || this._connection.closed;
159171
}
160-
return false;
161-
}
162-
163-
private isRequestError(err: gax.GoogleError): boolean {
164-
return err.code === gax.Status.INVALID_ARGUMENT;
172+
return true;
165173
}
166174

167175
private resolveCallOptions(
@@ -183,15 +191,23 @@ export class StreamConnection extends EventEmitter {
183191
}
184192

185193
private handleData = (response: AppendRowsResponse) => {
186-
this.trace('data arrived', response);
187-
const pw = this.getNextPendingWrite();
188-
if (!pw) {
194+
this.trace('data arrived', response, this._pendingWrites.length);
195+
if (!this.hasPendingWrites()) {
189196
this.trace('data arrived with no pending write available', response);
190197
return;
191198
}
192199
if (response.updatedSchema) {
193200
this.emit('schemaUpdated', response.updatedSchema);
194201
}
202+
const responseErr = response.error;
203+
if (responseErr) {
204+
const gerr = new gax.GoogleError(responseErr.message!);
205+
gerr.code = responseErr.code!;
206+
if (this.isRetryableError(gerr)) {
207+
this.handleRetry(gerr);
208+
return;
209+
}
210+
}
195211
this.ackNextPendingWrite(null, response);
196212
};
197213

@@ -238,13 +254,38 @@ export class StreamConnection extends EventEmitter {
238254
return this._streamId;
239255
};
240256

257+
private hasPendingWrites(): boolean {
258+
return this._pendingWrites.length > 0;
259+
}
260+
241261
private getNextPendingWrite(): PendingWrite | null {
242262
if (this._pendingWrites.length > 0) {
243-
return this._pendingWrites[0];
263+
return this._pendingWrites[this._pendingWrites.length - 1];
244264
}
245265
return null;
246266
}
247267

268+
private resendAllPendingWrites() {
269+
const pendingWritesToRetry = [...this._pendingWrites]; // copy array;
270+
let pw = pendingWritesToRetry.pop();
271+
while (pw) {
272+
this._pendingWrites.pop(); // remove from real queue
273+
this.send(pw); // .send immediately adds to the queue
274+
pw = pendingWritesToRetry.pop();
275+
}
276+
}
277+
278+
private ackAllPendingWrites(
279+
err: Error | null,
280+
result?:
281+
| protos.google.cloud.bigquery.storage.v1.IAppendRowsResponse
282+
| undefined
283+
) {
284+
while (this.hasPendingWrites()) {
285+
this.ackNextPendingWrite(err, result);
286+
}
287+
}
288+
248289
private ackNextPendingWrite(
249290
err: Error | null,
250291
result?:
@@ -253,6 +294,7 @@ export class StreamConnection extends EventEmitter {
253294
) {
254295
const pw = this._pendingWrites.pop();
255296
if (pw) {
297+
this.trace('ack pending write:', pw, err, result);
256298
pw._markDone(err, result);
257299
}
258300
}
@@ -279,23 +321,27 @@ export class StreamConnection extends EventEmitter {
279321
}
280322

281323
private send(pw: PendingWrite) {
282-
const request = pw.getRequest();
283-
if (!this._connection) {
284-
pw._markDone(new Error('connection closed'));
324+
const retrySettings = this._writeClient._retrySettings;
325+
const tries = pw._increaseAttempts();
326+
if (tries > retrySettings.maxRetryAttempts) {
327+
pw._markDone(
328+
new Error(`pending write max retries reached: ${tries} attempts`)
329+
);
285330
return;
286331
}
287-
if (this._connection.destroyed || this._connection.closed) {
332+
if (this.isConnectionClosed()) {
288333
this.reconnect();
289334
}
290335
this.trace('sending pending write', pw);
291336
try {
292-
this._connection.write(request, err => {
337+
const request = pw.getRequest();
338+
this._pendingWrites.unshift(pw);
339+
this._connection?.write(request, err => {
293340
this.trace('wrote pending write', err, this._pendingWrites.length);
294341
if (err) {
295342
pw._markDone(err); //TODO: add retries
296343
return;
297344
}
298-
this._pendingWrites.unshift(pw);
299345
});
300346
} catch (err) {
301347
pw._markDone(err as Error);
@@ -306,14 +352,16 @@ export class StreamConnection extends EventEmitter {
306352
* Check if connection is open and ready to send requests.
307353
*/
308354
isOpen(): boolean {
309-
return !!this._connection;
355+
return !this.isConnectionClosed();
310356
}
311357

312358
/**
313-
* Reconnect and re send inflight requests.
359+
* Re open appendRows BiDi gRPC connection.
314360
*/
315361
reconnect() {
316-
this.trace('reconnect called');
362+
this.trace(
363+
`reconnect called with ${this._pendingWrites.length} pending writes`
364+
);
317365
this.close();
318366
this.open();
319367
}
@@ -347,7 +395,6 @@ export class StreamConnection extends EventEmitter {
347395
async flushRows(request?: {
348396
offset?: IInt64Value['value'];
349397
}): Promise<FlushRowsResponse | null> {
350-
this.close();
351398
if (this.isDefaultStream()) {
352399
return null;
353400
}

handwritten/bigquery-storage/src/managedwriter/writer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ export class Writer {
160160
offsetValue?: IInt64Value['value']
161161
): PendingWrite {
162162
let offset: AppendRowRequest['offset'];
163-
if (offsetValue) {
163+
if (offsetValue !== undefined && offsetValue !== null) {
164164
offset = {
165165
value: offsetValue,
166166
};

handwritten/bigquery-storage/src/managedwriter/writer_client.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ import {StreamConnection} from './stream_connection';
2323
type StreamConnections = {
2424
connectionList: StreamConnection[];
2525
};
26+
type RetrySettings = {
27+
enableWriteRetries: boolean;
28+
maxRetryAttempts: number;
29+
};
2630
type CreateWriteStreamRequest =
2731
protos.google.cloud.bigquery.storage.v1.ICreateWriteStreamRequest;
2832
type BatchCommitWriteStreamsRequest =
@@ -55,6 +59,12 @@ export class WriterClient {
5559
private _client: BigQueryWriteClient;
5660
private _connections: StreamConnections;
5761
private _open: boolean;
62+
/**
63+
* Retry settings, only internal for now.
64+
* @private
65+
* @internal
66+
*/
67+
_retrySettings: RetrySettings;
5868

5969
constructor(opts?: ClientOptions) {
6070
const baseOptions = {
@@ -69,6 +79,10 @@ export class WriterClient {
6979
connectionList: [],
7080
};
7181
this._open = false;
82+
this._retrySettings = {
83+
enableWriteRetries: false,
84+
maxRetryAttempts: 4,
85+
};
7286
}
7387

7488
/**
@@ -102,6 +116,29 @@ export class WriterClient {
102116
return this._open;
103117
}
104118

119+
/**
120+
* Enables StreamConnections to automatically retry failed appends.
121+
*
122+
* Enabling retries is best suited for cases where users want to achieve at-least-once
123+
* append semantics. Use of automatic retries may complicate patterns where the user
124+
* is designing for exactly-once append semantics.
125+
*/
126+
enableWriteRetries(enable: boolean) {
127+
this._retrySettings.enableWriteRetries = enable;
128+
}
129+
130+
/**
131+
* Change max retries attempts on child StreamConnections.
132+
*
133+
* The default valuen is to retry 4 times.
134+
*
135+
* Only valid right now when write retries are enabled.
136+
* @see enableWriteRetries.
137+
*/
138+
setMaxRetryAttempts(retryAttempts: number) {
139+
this._retrySettings.maxRetryAttempts = retryAttempts;
140+
}
141+
105142
/**
106143
* Creates a write stream to the given table.
107144
* Additionally, every table has a special stream named DefaultStream

0 commit comments

Comments
 (0)