Skip to content

Commit 292fe21

Browse files
Merge branch 'micmro-feature/grpc-cancellation'
2 parents 3ac7802 + f80cd9e commit 292fe21

7 files changed

Lines changed: 132 additions & 12 deletions

File tree

packages/microservices/client/client-grpc.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { ClientOptions } from '../interfaces/client-metadata.interface';
88
import { GRPC_DEFAULT_URL } from './../constants';
99
import { ClientGrpc, GrpcOptions } from './../interfaces';
1010
import { ClientProxy } from './client-proxy';
11+
import { GRPC_CANCELLED } from './constants';
1112

1213
let grpcPackage: any = {};
1314

@@ -25,7 +26,7 @@ export class ClientGrpcProxy extends ClientProxy implements ClientGrpc {
2526
this.grpcClient = this.createClient();
2627
}
2728

28-
public getService<T = any>(name: string): T {
29+
public getService<T extends {}>(name: string): T {
2930
const { options } = this.options as GrpcOptions;
3031
if (!this.grpcClient[name]) {
3132
throw new InvalidGrpcServiceException();
@@ -59,10 +60,30 @@ export class ClientGrpcProxy extends ClientProxy implements ClientGrpc {
5960
): (...args) => Observable<any> {
6061
return (...args) => {
6162
return new Observable(observer => {
63+
let isClientCanceled = false;
6264
const call = client[methodName](...args);
65+
6366
call.on('data', (data: any) => observer.next(data));
64-
call.on('error', (error: any) => observer.error(error));
65-
call.on('end', () => observer.complete());
67+
call.on('error', (error: any) => {
68+
if (error.details === GRPC_CANCELLED) {
69+
call.destroy();
70+
if (isClientCanceled) {
71+
return;
72+
}
73+
}
74+
observer.error(error);
75+
});
76+
call.on('end', () => {
77+
call.removeAllListeners();
78+
observer.complete();
79+
});
80+
return () => {
81+
if (call.finished) {
82+
return undefined;
83+
}
84+
isClientCanceled = true;
85+
call.cancel();
86+
};
6687
});
6788
};
6889
}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export const ECONNREFUSED = 'ECONNREFUSED';
2-
export const CONN_ERR = 'CONN_ERR';
2+
export const CONN_ERR = 'CONN_ERR';
3+
export const GRPC_CANCELLED = 'Cancelled';

packages/microservices/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export const MESSAGE_EVENT = 'message';
1010
export const ERROR_EVENT = 'error';
1111
export const CLOSE_EVENT = 'close';
1212
export const SUBSCRIBE = 'subscribe';
13+
export const CANCEL_EVENT = 'cancelled';
1314

1415
export const PATTERN_METADATA = 'pattern';
1516
export const CLIENT_CONFIGURATION_METADATA = 'client';
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
export interface ClientGrpc {
2-
getService<T = any>(name: string): T;
2+
getService<T extends {}>(name: string): T;
33
}

packages/microservices/server/server-grpc.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import { fromEvent } from 'rxjs';
2+
import { takeUntil } from 'rxjs/operators';
13
import { InvalidGrpcPackageException } from '../exceptions/invalid-grpc-package.exception';
24
import { InvalidProtoDefinitionException } from '../exceptions/invalid-proto-definition.exception';
35
import { GrpcOptions, MicroserviceOptions } from '../interfaces/microservice-configuration.interface';
4-
import { GRPC_DEFAULT_URL } from './../constants';
6+
import { CANCEL_EVENT, GRPC_DEFAULT_URL } from './../constants';
57
import { CustomTransportStrategy } from './../interfaces';
68
import { Server } from './server';
79

@@ -103,7 +105,9 @@ export class ServerGrpc extends Server implements CustomTransportStrategy {
103105
return async (call, callback) => {
104106
const handler = methodHandler(call.request, call.metadata);
105107
const result$ = this.transformToObservable(await handler);
106-
await result$.forEach(data => call.write(data));
108+
await result$.pipe(
109+
takeUntil(fromEvent(call, CANCEL_EVENT)),
110+
).forEach(data => call.write(data));
107111
call.end();
108112
};
109113
}

packages/microservices/test/client/client-grpc.spec.ts

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ describe('ClientGrpcProxy', () => {
5555
const cln = { [methodName]: { responseStream: false } };
5656
const spy = sinon.spy(client, 'createUnaryServiceMethod');
5757
client.createServiceMethod(cln, methodName);
58-
58+
5959
expect(spy.called).to.be.true;
6060
});
6161
});
@@ -71,7 +71,7 @@ describe('ClientGrpcProxy', () => {
7171
const obj = { [methodName]: () => ({ on: (type, fn) => fn() }) };
7272

7373
let stream$: Observable<any>;
74-
74+
7575
beforeEach(() => {
7676
stream$ = client.createStreamServiceMethod(obj, methodName)();
7777
});
@@ -83,6 +83,75 @@ describe('ClientGrpcProxy', () => {
8383
expect(spy.called).to.be.true;
8484
});
8585
});
86+
87+
describe('flow-control', () => {
88+
const methodName = 'm';
89+
type EvtCallback = (...args: any[]) => void;
90+
let callMock: {
91+
on: (type: string, fn: EvtCallback) => void,
92+
cancel: sinon.SinonSpy,
93+
finished: boolean,
94+
destroy: sinon.SinonSpy,
95+
removeAllListeners: sinon.SinonSpy,
96+
};
97+
let eventCallbacks: { [type: string]: EvtCallback };
98+
let obj;
99+
const dataSpy = sinon.spy();
100+
const errorSpy = sinon.spy();
101+
const completeSpy = sinon.spy();
102+
103+
let stream$: Observable<any>;
104+
105+
beforeEach(() => {
106+
dataSpy.reset();
107+
errorSpy.reset();
108+
completeSpy.reset();
109+
eventCallbacks = {};
110+
callMock = {
111+
on: (type, fn) => eventCallbacks[type] = fn,
112+
cancel: sinon.spy(),
113+
finished: false,
114+
destroy: sinon.spy(),
115+
removeAllListeners: sinon.spy(),
116+
};
117+
obj = { [methodName]: () => callMock };
118+
stream$ = client.createStreamServiceMethod(obj, methodName)();
119+
});
120+
121+
it('propagates server errors', () => {
122+
const err = new Error('something happened');
123+
stream$.subscribe(dataSpy, errorSpy, completeSpy);
124+
eventCallbacks.data('a');
125+
eventCallbacks.data('b');
126+
callMock.finished = true;
127+
eventCallbacks.error(err);
128+
eventCallbacks.data('c');
129+
130+
expect(Object.keys(eventCallbacks).length).to.eq(3);
131+
expect(dataSpy.args).to.eql([['a'], ['b']]);
132+
expect(errorSpy.args[0][0]).to.eql(err);
133+
expect(completeSpy.called).to.be.false;
134+
expect(callMock.cancel.called).to.be.false;
135+
});
136+
137+
it('handles client side cancel', () => {
138+
const grpcServerCancelErrMock = {
139+
details: 'Cancelled',
140+
};
141+
const subscription = stream$.subscribe(dataSpy, errorSpy);
142+
eventCallbacks.data('a');
143+
eventCallbacks.data('b');
144+
subscription.unsubscribe();
145+
eventCallbacks.error(grpcServerCancelErrMock);
146+
eventCallbacks.end();
147+
eventCallbacks.data('c');
148+
149+
expect(callMock.cancel.called, 'should call call.cancel()').to.be.true;
150+
expect(callMock.destroy.called, 'should call call.destroy()').to.be.true;
151+
expect(dataSpy.args).to.eql([['a'], ['b']]);
152+
expect(errorSpy.called, 'should not error if client canceled').to.be.false;
153+
});
154+
});
86155
});
87156

88157
describe('createUnaryServiceMethod', () => {
@@ -95,7 +164,7 @@ describe('ClientGrpcProxy', () => {
95164
const obj = { [methodName]: (callback) => callback(null, {}) };
96165

97166
let stream$: Observable<any>;
98-
167+
99168
beforeEach(() => {
100169
stream$ = client.createUnaryServiceMethod(obj, methodName)();
101170
});

packages/microservices/test/server/server-grpc.spec.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { ServerGrpc } from '../../server/server-grpc';
22
import { expect } from 'chai';
33
import * as sinon from 'sinon';
4-
import { Observable } from 'rxjs';
4+
import { of } from 'rxjs';
55
import { join } from 'path';
66
import { InvalidGrpcPackageException } from '../../exceptions/invalid-grpc-package.exception';
77

@@ -149,12 +149,36 @@ describe('ServerGrpc', () => {
149149
});
150150
describe('on call', () => {
151151
it('should call native method', async () => {
152-
const call = { write: sinon.spy(), end: sinon.spy() };
152+
const call = {
153+
write: sinon.spy(),
154+
end: sinon.spy(),
155+
addListener: sinon.spy(),
156+
removeListener: sinon.spy(),
157+
};
153158
const callback = sinon.spy();
154159
const native = sinon.spy();
155160

156161
await server.createStreamServiceMethod(native)(call, callback);
157162
expect(native.called).to.be.true;
163+
expect(call.addListener.calledWith('cancelled')).to.be.true;
164+
expect(call.removeListener.calledWith('cancelled')).to.be.true;
165+
});
166+
167+
it(`should close the result observable when receiving an 'cancelled' event from the client`, async () => {
168+
let cancelCb: () => void;
169+
const call = {
170+
write: sinon.stub().onSecondCall().callsFake(() => cancelCb()),
171+
end: sinon.spy(),
172+
addListener: (name, cb) => cancelCb = cb,
173+
removeListener: sinon.spy(),
174+
};
175+
const result$ = of(1, 2, 3);
176+
const callback = sinon.spy();
177+
const native = sinon.stub().returns(new Promise((resolve, reject) => resolve(result$)));
178+
179+
await server.createStreamServiceMethod(native)(call, callback);
180+
expect(call.write.calledTwice).to.be.true;
181+
expect(call.end.called).to.be.true;
158182
});
159183
});
160184
});

0 commit comments

Comments
 (0)