Skip to content

Commit 6c6acda

Browse files
committed
Ignore a deferred un-assign superseded by a later rebalance
On ERR__REVOKE_PARTITIONS the un-assign is deferred until the processing queue and the offset ledger have drained. If the next rebalance granted the partitions back before that happened, the deferred callback still ran and un-assigned them: the consumer then owned partitions at the broker with no local assignment, and since group membership had not changed, nothing triggered another rebalance to rescue it. Track a rebalance id, bumped on every rebalance event, and give up on a deferred un-assign whose id no longer matches. The check runs again before un-assigning, as publishing offsets to zookeeper in between is asynchronous and leaves a second window for the partitions to come back. The drain watchdog is now cleared on every rebalance rather than only on assignment, so a timer armed by a superseded revoke can no longer disconnect a consumer that is not stuck. Issue: BB-835
1 parent 56a3c42 commit 6c6acda

3 files changed

Lines changed: 247 additions & 0 deletions

File tree

lib/BackbeatConsumer.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,9 @@ class BackbeatConsumer extends EventEmitter {
176176
}
177177
});
178178

179+
// a deferred un-assign gives up when this no longer matches
180+
this._rebalanceId = 0;
181+
179182
this._messagesConsumed = 0;
180183
// this variable represents how many kafka messages have been
181184
// requested without having been received yet, i.e. still
@@ -752,15 +755,45 @@ class BackbeatConsumer extends EventEmitter {
752755
}
753756
}
754757

758+
/**
759+
* Run a shutdown/rebalance step that must not abort the sequence it
760+
* belongs to, logging rather than throwing.
761+
*
762+
* @param {string} op - librdkafka operation name, for the log line
763+
* @param {function} fn - the call to attempt
764+
* @returns {undefined}
765+
*/
766+
_bestEffort(op, fn) {
767+
try {
768+
fn();
769+
} catch (e) {
770+
// ERR__STATE just means the client moved on without us
771+
const logger = this._consumer.isConnected() &&
772+
e.code !== kafka.CODES.ERRORS.ERR__STATE ? this._log.error : this._log.info;
773+
logger.bind(this._log)(`rdkafka.${op} failed`, {
774+
e: e.toString(),
775+
topic: this._topic,
776+
groupId: this._groupId,
777+
});
778+
}
779+
}
780+
755781
/**
756782
* @param {kafka.KafkaError} err Rebalance event
757783
* @param {TopicPartition[]} assignment List of (un)assigned partitions
758784
* @returns {void}
759785
*/
760786
_onRebalance(err, assignment) {
787+
const rebalanceId = ++this._rebalanceId;
788+
789+
clearTimeout(this._drainProcessQueueTimeout);
790+
this._drainProcessQueueTimeout = null;
791+
761792
if (err.code === kafka.CODES.ERRORS.ERR__ASSIGN_PARTITIONS) {
762793
this._log.info('rdkafka.assign', { assignment });
763794

795+
this._setDrain(null);
796+
764797
try {
765798
this._consumer.assign(assignment);
766799
if (this._circuitBreaker.state !== BreakerState.Nominal) {
@@ -779,7 +812,26 @@ class BackbeatConsumer extends EventEmitter {
779812
ledger: this._offsetLedger.getProcessingCount(this._topic),
780813
});
781814

815+
const isSuperseded = () => rebalanceId !== this._rebalanceId;
816+
const skipSuperseded = status => {
817+
this._log.info('skipping superseded un-assign', {
818+
status,
819+
rebalanceId,
820+
currentRebalanceId: this._rebalanceId,
821+
topic: this._topic,
822+
groupId: this._groupId,
823+
});
824+
KafkaBacklogMetrics.onRebalance(
825+
this._topic, this._groupId, unassignStatus.SUPERSEDED);
826+
};
827+
782828
const unassign = jsutil.once(status => {
829+
// before touching state that now belongs to a later rebalance
830+
if (isSuperseded()) {
831+
skipSuperseded(status);
832+
return;
833+
}
834+
783835
this._log.info(`processing queue ${status}, un-assigning`, {
784836
queueLen: this._processingQueue.length(),
785837
running: this._processingQueue.running(),
@@ -804,6 +856,12 @@ class BackbeatConsumer extends EventEmitter {
804856
}
805857

806858
const doUnassign = () => {
859+
// re-checked: publishing offsets above is asynchronous
860+
if (isSuperseded()) {
861+
skipSuperseded(status);
862+
return;
863+
}
864+
807865
this._resumePausedPartitions();
808866

809867
try {
@@ -889,6 +947,10 @@ class BackbeatConsumer extends EventEmitter {
889947
}, this._maxPollIntervalMs - 1000); // 1 second earlier, to be within the limit
890948
} else {
891949
this._log.error('rdkafka.rebalance', { err, assignment });
950+
// the bump above just superseded whatever revoke was pending and
951+
// dropped its watchdog, so nothing else will answer this callback,
952+
// and librdkafka requires one: assign(NULL) synchronises the state
953+
this._bestEffort('unassign', () => this._consumer.unassign());
892954
}
893955
}
894956

lib/constants.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const constants = {
2525
IDLE: 'idle',
2626
DRAINED: 'drained',
2727
TIMEOUT: 'timeout',
28+
SUPERSEDED: 'superseded',
2829
},
2930
statusReady: 'READY',
3031
statusUndefined: 'UNDEFINED',

tests/unit/backbeatConsumer.js

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ const assert = require('assert');
22
const sinon = require('sinon');
33

44
const BackbeatConsumer = require('../../lib/BackbeatConsumer');
5+
const KafkaBacklogMetrics = require('../../lib/KafkaBacklogMetrics');
56
const { CODES } = require('node-rdkafka');
67

78
const { kafka } = require('../config.json');
9+
const { unassignStatus } = require('../../lib/constants');
810
const { BreakerState } = require('breakbeat').CircuitBreaker;
911

1012
class BackbeatConsumerMock extends BackbeatConsumer {
@@ -397,4 +399,186 @@ describe('backbeatConsumer', () => {
397399
});
398400
});
399401
});
402+
403+
describe('_onRebalance deferred un-assign', () => {
404+
const REVOKE = { code: CODES.ERRORS.ERR__REVOKE_PARTITIONS };
405+
const ASSIGN = { code: CODES.ERRORS.ERR__ASSIGN_PARTITIONS };
406+
const partitions = [
407+
{ topic: 'my-test-topic', partition: 0 },
408+
{ topic: 'my-test-topic', partition: 1 },
409+
];
410+
411+
let consumer;
412+
let drainCallbacks;
413+
let queueIdle;
414+
let ledgerCount;
415+
416+
beforeEach(() => {
417+
consumer = new BackbeatConsumerMock({
418+
kafka,
419+
groupId: 'unittest-group',
420+
topic: 'my-test-topic',
421+
});
422+
423+
consumer._consumer = {
424+
assign: sinon.stub(),
425+
unassign: sinon.stub(),
426+
disconnect: sinon.stub(),
427+
commit: sinon.stub(),
428+
pause: sinon.stub(),
429+
resume: sinon.stub(),
430+
isConnected: () => true,
431+
assignments: () => [],
432+
subscription: () => ['my-test-topic'],
433+
};
434+
435+
queueIdle = false;
436+
ledgerCount = 1;
437+
drainCallbacks = [];
438+
consumer._processingQueue = {
439+
length: () => 0,
440+
running: () => (queueIdle ? 0 : 1),
441+
idle: () => queueIdle,
442+
setDrain: func => drainCallbacks.push(func),
443+
};
444+
consumer._offsetLedger.getProcessingCount = () => ledgerCount;
445+
446+
sinon.stub(KafkaBacklogMetrics, 'onRebalance');
447+
});
448+
449+
afterEach(() => {
450+
clearTimeout(consumer._drainProcessQueueTimeout);
451+
sinon.restore();
452+
});
453+
454+
const completeDrain = () => {
455+
queueIdle = true;
456+
ledgerCount = 0;
457+
consumer._drainCallback();
458+
};
459+
460+
it('should un-assign once the drain completes', done => {
461+
consumer.on('unassign', status => {
462+
assert.strictEqual(status, unassignStatus.DRAINED);
463+
assert(consumer._consumer.unassign.calledOnce);
464+
done();
465+
});
466+
467+
consumer._onRebalance(REVOKE, partitions);
468+
assert(consumer._consumer.unassign.notCalled);
469+
completeDrain();
470+
});
471+
472+
it('should un-assign immediately when nothing is in flight', done => {
473+
queueIdle = true;
474+
ledgerCount = 0;
475+
476+
consumer.on('unassign', status => {
477+
assert.strictEqual(status, unassignStatus.IDLE);
478+
assert(consumer._consumer.unassign.calledOnce);
479+
done();
480+
});
481+
482+
consumer._onRebalance(REVOKE, partitions);
483+
});
484+
485+
it('should not un-assign when a new assignment arrived while draining', () => {
486+
consumer._onRebalance(REVOKE, partitions);
487+
const deferredUnassign = drainCallbacks[drainCallbacks.length - 1];
488+
489+
// the next generation grants the partitions back mid-drain
490+
consumer._onRebalance(ASSIGN, partitions);
491+
assert(consumer._consumer.assign.calledOnce);
492+
493+
queueIdle = true;
494+
ledgerCount = 0;
495+
deferredUnassign();
496+
497+
assert(consumer._consumer.unassign.notCalled);
498+
assert(KafkaBacklogMetrics.onRebalance.calledWith(
499+
'my-test-topic', 'unittest-group', unassignStatus.SUPERSEDED));
500+
});
501+
502+
it('should synchronise the assignment on an arbitrary rebalance error',
503+
() => {
504+
// the bump above superseded whatever revoke was pending and
505+
// dropped its watchdog, so nothing else answers this callback
506+
consumer._onRebalance({ code: -1 }, partitions);
507+
508+
assert(consumer._consumer.unassign.calledOnce);
509+
});
510+
511+
it('should not un-assign when a later revoke superseded the drain', () => {
512+
consumer._onRebalance(REVOKE, partitions);
513+
const firstUnassign = drainCallbacks[drainCallbacks.length - 1];
514+
515+
consumer._onRebalance(REVOKE, partitions);
516+
517+
queueIdle = true;
518+
ledgerCount = 0;
519+
firstUnassign();
520+
521+
assert(consumer._consumer.unassign.notCalled);
522+
});
523+
524+
it('should not un-assign when the partitions were granted back while ' +
525+
'offsets were being published', () => {
526+
let publishDone;
527+
consumer._kafkaBacklogMetricsConfig = { zkPath: '/test', intervalS: 5 };
528+
consumer._publishOffsetsCron = cb => {
529+
publishDone = cb;
530+
};
531+
532+
consumer._onRebalance(REVOKE, partitions);
533+
completeDrain();
534+
assert.strictEqual(typeof publishDone, 'function');
535+
assert(consumer._consumer.unassign.notCalled);
536+
537+
consumer._onRebalance(ASSIGN, partitions);
538+
publishDone();
539+
540+
assert(consumer._consumer.unassign.notCalled);
541+
assert(KafkaBacklogMetrics.onRebalance.calledWith(
542+
'my-test-topic', 'unittest-group', unassignStatus.SUPERSEDED));
543+
});
544+
545+
it('should not leave a superseded revoke watchdog armed', () => {
546+
const clock = sinon.useFakeTimers();
547+
try {
548+
consumer._onRebalance(REVOKE, partitions);
549+
consumer._onRebalance(REVOKE, partitions);
550+
551+
clock.tick(consumer._maxPollIntervalMs + 1000);
552+
assert(consumer._consumer.disconnect.calledOnce);
553+
} finally {
554+
clock.restore();
555+
}
556+
});
557+
558+
it('should leave the current drain and timeout armed when a superseded ' +
559+
'un-assign fires', () => {
560+
consumer._onRebalance(REVOKE, partitions);
561+
const supersededUnassign = drainCallbacks[drainCallbacks.length - 1];
562+
563+
consumer._onRebalance(ASSIGN, partitions);
564+
consumer._onRebalance(REVOKE, partitions);
565+
566+
const currentDrain = consumer._drainCallback;
567+
const currentTimeout = consumer._drainProcessQueueTimeout;
568+
assert.notStrictEqual(currentDrain, null);
569+
assert.notStrictEqual(currentTimeout, null);
570+
571+
// or the callback returns before reaching the guard
572+
queueIdle = true;
573+
ledgerCount = 0;
574+
supersededUnassign();
575+
576+
assert(KafkaBacklogMetrics.onRebalance.calledWith(
577+
'my-test-topic', 'unittest-group', unassignStatus.SUPERSEDED));
578+
579+
assert.strictEqual(consumer._drainCallback, currentDrain);
580+
assert.strictEqual(consumer._drainProcessQueueTimeout, currentTimeout);
581+
assert(consumer._consumer.unassign.notCalled);
582+
});
583+
});
400584
});

0 commit comments

Comments
 (0)