fix(events): hold aggregate gaps on production claim

PostgreSQL claim now uses the same next-version rule as MemoryBus, and checkpoints only move forward. A later version cannot be leased while an earlier one is still missing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-19 09:24:51 +00:00
co-authored by Cursor
parent 01ca861e94
commit 2a666df3c1
3 changed files with 35 additions and 4 deletions
+2 -3
View File
@@ -98,8 +98,7 @@ func (b *MemoryBus) Claim(consumer, worker string, n int, now time.Time) ([]*Del
continue continue
} }
env := b.envelopes[id] env := b.envelopes[id]
head := b.heads[env.AggregateID] if !VersionClaimable(b.heads[env.AggregateID], env.AggregateVersion) {
if env.AggregateVersion != 0 && env.AggregateVersion > head+1 {
continue // gap: do not skip missing versions continue // gap: do not skip missing versions
} }
token, err := randomToken() token, err := randomToken()
@@ -147,7 +146,7 @@ func (b *MemoryBus) Apply(consumer string, claimed *Delivery, fail error) error
return fail return fail
} }
head := b.heads[env.AggregateID] head := b.heads[env.AggregateID]
if env.AggregateVersion != 0 && env.AggregateVersion > head+1 { if !VersionClaimable(head, env.AggregateVersion) {
d.Status = "pending" d.Status = "pending"
d.LeaseOwner = "" d.LeaseOwner = ""
d.FencingToken = "" d.FencingToken = ""
+21 -1
View File
@@ -36,6 +36,16 @@ type Lease struct {
Envelope Envelope Envelope Envelope
} }
// VersionClaimable is the gap rule shared by MemoryBus and claimSQL.
// Version 0 is unversioned and always claimable. A versioned event is claimable
// only when it does not skip past the consumer checkpoint (head+1).
func VersionClaimable(head, version int64) bool {
if version == 0 {
return true
}
return version <= head+1
}
const claimSQL = ` const claimSQL = `
WITH candidates AS ( WITH candidates AS (
SELECT d.event_id, d.consumer SELECT d.event_id, d.consumer
@@ -62,6 +72,16 @@ WITH candidates AS (
AND ex.aggregate_type = e.aggregate_type AND ex.aggregate_type = e.aggregate_type
AND ex.aggregate_id = e.aggregate_id AND ex.aggregate_id = e.aggregate_id
) )
AND (
e.aggregate_version = 0
OR e.aggregate_version <= 1 + COALESCE((
SELECT c.last_aggregate_version
FROM event_consumer_checkpoints c
WHERE c.consumer = d.consumer
AND c.aggregate_type = e.aggregate_type
AND c.aggregate_id = e.aggregate_id
), 0)
)
ORDER BY d.next_attempt_at, d.event_id ORDER BY d.next_attempt_at, d.event_id
FOR UPDATE OF d SKIP LOCKED FOR UPDATE OF d SKIP LOCKED
LIMIT $2 LIMIT $2
@@ -230,7 +250,7 @@ func AckLease(ctx context.Context, tx pgx.Tx, l Lease) error {
) VALUES ($1,$2,$3,$4,$5,$6) ) VALUES ($1,$2,$3,$4,$5,$6)
ON CONFLICT (consumer, aggregate_type, aggregate_id) DO UPDATE SET ON CONFLICT (consumer, aggregate_type, aggregate_id) DO UPDATE SET
last_event_id = EXCLUDED.last_event_id, last_event_id = EXCLUDED.last_event_id,
last_aggregate_version = EXCLUDED.last_aggregate_version, last_aggregate_version = GREATEST(COALESCE(event_consumer_checkpoints.last_aggregate_version, 0), EXCLUDED.last_aggregate_version),
fencing_token = EXCLUDED.fencing_token, fencing_token = EXCLUDED.fencing_token,
updated_at = now() updated_at = now()
`, l.Consumer, l.AggregateType, l.AggregateID, l.EventID, l.Envelope.AggregateVersion, l.Token); err != nil { `, l.Consumer, l.AggregateType, l.AggregateID, l.EventID, l.Envelope.AggregateVersion, l.Token); err != nil {
+12
View File
@@ -15,6 +15,18 @@ func TestClaimSQLUsesSkipLockedOnlyForClaim(t *testing.T) {
if strings.Count(claimSQL, "SKIP LOCKED") != 1 { if strings.Count(claimSQL, "SKIP LOCKED") != 1 {
t.Fatal("SKIP LOCKED is only for claim") t.Fatal("SKIP LOCKED is only for claim")
} }
if !strings.Contains(claimSQL, "event_consumer_checkpoints") || !strings.Contains(claimSQL, "e.aggregate_version = 0") {
t.Fatal("claim SQL must hold the same aggregate gap as MemoryBus")
}
}
func TestVersionClaimableMatchesMemoryBusGap(t *testing.T) {
if !VersionClaimable(0, 0) || !VersionClaimable(0, 1) || VersionClaimable(0, 2) {
t.Fatal("head 0 allows unversioned and v1 only")
}
if !VersionClaimable(1, 2) || VersionClaimable(1, 3) {
t.Fatal("head 1 allows v2 and rejects v3")
}
} }
func TestSkipLockedAbsentFromInsertSQL(t *testing.T) { func TestSkipLockedAbsentFromInsertSQL(t *testing.T) {