Repository navigation
Replies: 2 comments
TriageVerdict: this is a reproducible bug in Is this a misuse of humus?No. The end user picks a The trigger in #750's test is a revoke that outlasts Under AtLeastOnce, you could argue that a rewind only causes duplicates, which the contract allows. But nothing the end user configures bounds how far back it goes. The committed offset returns to wherever the fenced member stopped, however far the new owner has got since then. And #683 listed a late commit that rewinds the new owner's offset as a pre-reset bug that the rewrite fixed. Under AtMostOnce, the end user's Is it a feature request?No. What happens contradicts three things:
The fix doesn't change the API. Is it reproducible?Yes. I wrote two container tests based on AtLeastOnce: the committed offset moves backward. After the fence, the test produced 10 more records to the partition the fenced member still held. Its new owner processed them and committed offset 11. Then the test released the fenced member's record in flight.
AtMostOnce: records are processed twice. This used the same fence, and the new owner processed the same 10 records. This time, the test released the moved partition's record first, so franz-go began to rejoin. Then it released the kept partition's record, once franz-go had logged
So two lines of the report need correcting. AtMostOnce's guarantee doesn't hold. And a moved offset has now been observed. WhyThe report has the first half: franz-go learns of the fence and reports the partitions as revoked, not lost. Three more facts complete it:
On the options under Expected
I prototyped a fix on 4289e89 that works:
With the prototype, both reproductions pass. The committed offset stays at 11, and the fenced member processes nothing more of the partition it kept. The five existing container tests pass, twice each, and the unit tests pass under The prototype (service.go, against 4289e89)diff --git a/queue/kafka/service.go b/queue/kafka/service.go
index 06265217..f1e17d28 100644
--- a/queue/kafka/service.go
+++ b/queue/kafka/service.go
@@ -19,4 +19,5 @@ import (
"github.com/twmb/franz-go/pkg/kerr"
"github.com/twmb/franz-go/pkg/kgo"
+ "github.com/twmb/franz-go/pkg/kmsg"
"github.com/twmb/franz-go/plugin/kotel"
"go.opentelemetry.io/otel/attribute"
@@ -306,4 +307,9 @@ type partition struct {
lost atomic.Bool
+ // member is the member ID the partition is held under, set by every
+ // OnPartitionsAssigned while the partition is held. A commit franz-go
+ // would send under another member ID is not sent.
+ member atomic.Pointer[string]
+
// pending is the last record run processed and has not committed. Only
// run uses it.
@@ -459,5 +465,6 @@ func refused(err error) bool {
// franz-go rejoins the group on it, so it is not the Service's failure.
func notMember(err error) bool {
- return errors.Is(err, kerr.UnknownMemberID) ||
+ return errors.Is(err, errNotHeld) ||
+ errors.Is(err, kerr.UnknownMemberID) ||
errors.Is(err, kerr.IllegalGeneration) ||
errors.Is(err, kerr.FencedInstanceID) ||
@@ -465,4 +472,9 @@ func notMember(err error) bool {
}
+// errNotHeld is a commit not sent because franz-go would send it under a
+// member ID the partition is not held under: the member was fenced out and
+// rejoined as a new member.
+var errNotHeld = errors.New("the partition is not held under the group's current member ID")
+
// fail records err as the error Serve returns, if it is the first, and
// stops polling, so the Service stops as on shutdown.
@@ -491,4 +503,5 @@ func (g *group) partition(tp topicPartition) *partition {
func (g *group) assigned(_ context.Context, cl *kgo.Client, assigned map[string][]int32) {
g.logPartitions("partitions assigned", assigned)
+ member, _ := cl.GroupMetadata()
g.mu.Lock()
defer g.mu.Unlock()
@@ -496,4 +509,7 @@ func (g *group) assigned(_ context.Context, cl *kgo.Client, assigned map[string]
return
}
+ for _, p := range g.parts {
+ p.member.Store(&member)
+ }
for topic, partitions := range assigned {
for _, id := range partitions {
@@ -513,4 +529,5 @@ func (g *group) assigned(_ context.Context, cl *kgo.Client, assigned map[string]
},
}
+ p.member.Store(&member)
g.parts[tp] = p
go g.run(cl, p)
@@ -691,5 +708,11 @@ func (g *group) commit(cl *kgo.Client, p *partition) bool {
func (g *group) commitRecord(cl *kgo.Client, p *partition, r *kgo.Record) error {
start := time.Now()
- err := cl.CommitRecords(g.work, r)
+ ctx := kgo.PreCommitFnContext(g.work, func(req *kmsg.OffsetCommitRequest) error {
+ if m := p.member.Load(); m == nil || req.MemberID != *m {
+ return errNotHeld
+ }
+ return nil
+ })
+ err := cl.CommitRecords(ctx, r)
attrs := append(p.attrs[:len(p.attrs):len(p.attrs)], semconv.MessagingOperationTypeSettle)
if err != nil {#758 has the acceptance criteria: the guard, container tests for both deliveries, and corrections to |
|
Fixed by #766, merged as def58a7. What changed: each partition now records the member ID it is held under: that of the session that assigned it, and of each later session that kept it ( So a fenced member that rejoins as a new member commits nothing more for the partitions it held, whether franz-go reports them as revoked or as lost. Their new owner decides the committed offset. Both reproductions from the triage are now container tests ( |
Uh oh!
There was an error while loading. Please reload this page.
What happens
When a
queue/kafkaService is fenced out of its consumer group, it can still commit the offset it had pending from before the fence. Kafka accepts that commit under the member's new generation. If another member owns the partition by then, the late commit can move the new owner's committed offset backward or forward.At-least-once still holds:
So no record is lost. But this is the case the Kafka proposal's revision listed as a pre-reset bug the rewrite fixed: "a late commit could rewind the new owner's". The package doc also says the opposite of what happens against Kafka.
Why
The design assumed that a fenced member's partitions are lost, and the Service stops a lost partition without committing (
service.go#L664-L689skips the commit whenp.lostis set).Against Kafka itself, franz-go learns of the fence from the refused commit (
UNKNOWN_MEMBER_ID), not from a heartbeat. It rejoins as a new member, and the partition the member still held comes back throughOnPartitionsRevoked, notOnPartitionsLost. Two things then combine:committreats a commit refused because the member isn't in the group's current generation as "not a failure: franz-go rejoins, p.pending stays pending, and the next commit covers it".runcommitsp.pendingon the way out. The member has rejoined by then, so the commit carries the new generation and Kafka accepts it.kfake only shows the "lost" path, because its tests inject
UNKNOWN_MEMBER_IDon a heartbeat, so the unit tests never reach this.How it was found
#731's container test,
TestKafka_a_member_whose_revoke_outlasts_RebalanceTimeout_is_fenced, fences a member againstapache/kafka-native. Its PR, #750, records the revoke-instead-of-loss difference and this late commit under "Judgment calls". In that test the late commit equals what the new owner committed, so nothing moved. Moving an offset hasn't been observed, only reasoned from the code and the commit Kafka accepted.The docs
doc.go#L90-L93says: "When the member is fenced out of the group, its partitions are lost: another member may own them already, so the Service stops them after their record in flight without committing". Against Kafka, the partition is revoked and committed. The Failures table's row for a fenced-out member ("the records are committed by a later commit, or processed again by the partition's new owner") describes what the code does, but not that the later commit can belong to a partition this member no longer owns.Expected
A commit refused because the member was fenced out should end that member's claim on what it had pending. Its new owner, if there is one, decides the partition's committed offset. Some ways to get there:
p.pendingfor that partition, or mark the partition as lost, so neither the revoke nor a later batch commits it;Either way, a container test should show the new owner's committed offset is unchanged after the fenced member's revoke, and the doc's fencing paragraph and Failures row should say what happens against Kafka.
None of this changes
queue's orqueue/kafka's API,Delivery's guarantees, or how the Service runs onhumus.Main.References
All reactions