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:
@@ -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 = ""
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user