Skip to content

Optimize mongodb_v2 and migrate to mongo-driver v2 - #442

Merged
klowdo merged 7 commits into
looplab:mainfrom
AltScore:feat/mongodb-v2-performance
Jun 2, 2026
Merged

Optimize mongodb_v2 and migrate to mongo-driver v2#442
klowdo merged 7 commits into
looplab:mainfrom
AltScore:feat/mongodb-v2-performance

Conversation

@rrodriguez-consulting

Copy link
Copy Markdown

Summary

  • Optimize mongodb_v2 eventstore performance: Move global position update outside the transaction to reduce lock contention. Extract updateStreamForEntity helper for fail-fast optimistic locking. Add WithSortEventsOnDB option for version-based event ordering on queries.
  • Migrate all MongoDB packages from mongo-driver v1 to v2: Update codec/bson, eventstore, outbox, and repo packages. Maintain full backward compatibility — UUIDs continue to be stored as strings, and the decoder handles both string and binary formats.
  • Add testcontainers for all MongoDB integration tests: No external MongoDB required to run tests.

Changes

mongodb_v2 eventstore optimizations

  • Global position counter updated outside TX to minimize lock contention on $all stream document
  • Stream entity updated first in TX for fail-fast on optimistic locking conflicts
  • WithSortEventsOnDB() option for explicit version-based ordering on Load/LoadFrom
  • Position ID verification handles both int32 and int64 BSON types
  • Safe type assertion in updateStreamForEntity (two-value form)

mongo-driver v2 migration (all packages)

  • bsoncodec, bsonrw, bsontype consolidated into bson package
  • Custom Registry exported from codec/bson for UUID string encoding (no global registry in v2)
  • Marshal/Unmarshal helpers ensure backward-compatible UUID encoding
  • UUID decoder accepts both string (v1) and binary (v2) formats
  • mongo.Connect(ctx, opts) replaced with mongo.Connect(opts)
  • writeconcern.New(writeconcern.WMajority()) replaced with writeconcern.Majority()
  • mongo.SessionContext replaced with context.Context in transaction callbacks
  • options.Update() replaced with options.UpdateOne()
  • FindOptions replaced with FindOptionsBuilder for v2 Lister interface
  • bson.D for sort documents (ordered)

Testing

  • Testcontainers with MongoDB 7 replica set for all integration tests
  • Backward compatibility test verifying v1-encoded bytes decode correctly with v2
  • Roundtrip test confirming v2 marshal produces identical bytes to v1
  • 41 unit test packages pass, 16 integration tests pass

Test plan

  • go test -short ./... — 41 packages pass
  • go test ./eventstore/mongodb_v2/... — 6 integration tests pass
  • go test ./eventstore/mongodb/... — 4 integration tests pass
  • go test ./outbox/mongodb/... — 4 integration tests pass
  • go test ./repo/mongodb/... — 2 integration tests pass
  • Verified UUID stored as string in events, streams, and projection collections
  • Verified backward compatibility: v1 bytes unmarshal correctly with v2 codec

🤖 Generated with Claude Code

Performance optimizations for mongodb_v2 eventstore:
- Move global position update outside transaction to reduce lock
  contention on the $all stream document
- Extract updateStreamForEntity and updateGlobalPosition helpers
- Update stream before inserting events for fail-fast optimistic
  locking with duplicate-key normalization
- Add WithSortEventsOnDB option for version-based event ordering
- Clone metadata map in newEvt to prevent caller mutation on
  transaction failure
- Fix CountDocuments error check order in Replace
- Handle both int32 and int64 in position ID verification with
  default case for unexpected types

Migrate all MongoDB packages from mongo-driver v1 to v2:
- Consolidate bsoncodec, bsonrw, bsontype imports into bson package
- Export custom Registry for UUID codec with Marshal/Unmarshal helpers
  that ensure backward-compatible string encoding
- UUID decoder handles both string and binary formats
- Pass Registry via database options SetRegistry for all packages
- Use bsoncodec.Marshal/Unmarshal for event data serialization
- Replace mongo.Connect(ctx, opts) with mongo.Connect(opts)
- Replace writeconcern.New(writeconcern.WMajority()) with
  writeconcern.Majority()
- Replace mongo.SessionContext with context.Context
- Replace options.Update() with options.UpdateOne()
- Update FindOptions to FindOptionsBuilder for v2 Lister interface
- Use errors.Is for ErrNoDocuments comparison
- Fix import grouping to follow stdlib / 3rd party / internal

Testing:
- Add testcontainers for all MongoDB integration tests
- Refactor TestMain to use runWithMongo helper to prevent container
  leaks on os.Exit
- Add backward compatibility test for v1 encoded data

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@rrodriguez-consulting
rrodriguez-consulting force-pushed the feat/mongodb-v2-performance branch from b85ab87 to 1c59b3e Compare March 30, 2026 19:54
@rrodriguez-consulting

Copy link
Copy Markdown
Author

@klowdo What do you think?

@klowdo

klowdo commented May 19, 2026

Copy link
Copy Markdown
Collaborator

Global position ordering trade-off

Moving updateGlobalPosition outside the transaction is a good throughput optimization, but it changes the semantics of the global position — events can now appear out of commit order and gaps are possible on rollback.

This is fine for many use cases, but not all. Some consumers (projectors, catch-up subscriptions) may rely on position reflecting commit order.

Suggestion: Make this behavior configurable with three modes:

  1. Inside transaction (original behavior) — strict ordering, higher lock contention on $all. Safe default for backward compatibility.
  2. Outside transaction (this PR) — better throughput, allows gaps and out-of-order positions. Opt-in for high-concurrency workloads.
  3. No global position — skip $all entirely. For systems that don't use global position at all and want zero contention.

Something like:

WithGlobalPositionStrategy(GlobalPositionInTX)    // default, backward compat
WithGlobalPositionStrategy(GlobalPositionOutsideTX) // this PR's behavior
WithGlobalPositionStrategy(GlobalPositionDisabled)   // skip $all entirely

This way existing users get no behavior change, and users who understand the trade-off can opt into the faster path.

The outbox is unaffected regardless — it uses WithEventHandlerInTX which runs inside the save transaction and never reads the global position. Per-aggregate ordering is always guaranteed by optimistic locking on the stream document.

@klowdo

klowdo commented May 19, 2026

Copy link
Copy Markdown
Collaborator

Correction on my previous comment — disabling global position entirely is not viable since sagas depend on it. Updated suggestion:

Two modes should be enough:

WithGlobalPositionStrategy(GlobalPositionInTX)       // default, backward compat
WithGlobalPositionStrategy(GlobalPositionOutsideTX)   // this PR's behavior
  • InTX (default): current behavior, strict commit-order positions, higher lock contention on $all. Safe default, no breaking change.
  • OutsideTX: better throughput, but positions can have gaps and may not reflect commit order. Opt-in for workloads where throughput matters more than strict position ordering.

@rrodriguez-consulting

Copy link
Copy Markdown
Author

@klowdo can you a quick review this, we are using this in production from our fork and everything is working fine

@klowdo

klowdo commented May 20, 2026

Copy link
Copy Markdown
Collaborator

only because you've used this in production doesn't mean that it work for everyone.

in a heavy concurrency load the events can be stored in different order than creation. this gap could send out events in wrong order to saga/projection with can cause a ripple effect on the event stream.

Why do you need to make this outside of the transaction? what kind of load do you have on your system? or this just a pre-optimization ?

Comment thread eventstore/mongodb_v2/eventstore.go Outdated
Raul Rodriguez and others added 5 commits May 20, 2026 07:24
Co-authored-by: Felix Svensson <klowdo.fs@gmail.com>
…formance

# Conflicts:
#	codec/bson/command.go
#	codec/bson/event.go
#	codec/bson/uuid.go
#	eventstore/mongodb/eventstore.go
#	eventstore/mongodb_v2/eventmaintenance.go
#	eventstore/mongodb_v2/eventstore.go
#	outbox/mongodb/outbox_test.go
Expose the global-position update strategy as a configurable option with two
modes:

- GlobalPositionInTX (default): increments the global position inside the
  save transaction. Backward-compatible: positions strictly reflect commit
  order and there are no gaps, at the cost of write-lock contention on the
  $all document under concurrent saves across aggregates.

- GlobalPositionOutsideTX: increments the global position with an atomic
  FindOneAndUpdate before opening the save transaction. Removes $all as a
  serialization point and substantially improves throughput under
  high-concurrency / many-aggregate workloads. Trade-off: positions may
  have gaps and may not reflect commit order across aggregates.
  Per-aggregate version ordering is still guaranteed by the stream-document
  optimistic lock.

The Save function dispatches to saveInTX or saveOutsideTX based on the
strategy. saveInTX preserves the original upstream flow byte-for-byte for
backward compatibility; saveOutsideTX implements the optimized path.

Tests:
- TestGlobalPositionOutsideTXIntegration runs the eventstore acceptance
  suite against the OutsideTX strategy.
- TestGlobalPositionStrategyGapBehavior empirically demonstrates the
  trade-off by racing two saves on the same aggregate and asserting that
  $all advances by 1 in InTX (rolled back on abort) vs 2 in OutsideTX (gap
  on abort).
- TestPerAggregateOrderingBothStrategies verifies per-aggregate version
  monotonicity holds under both modes.
- BenchmarkSaveStrategiesConcurrent measures throughput and error rates
  under a disbursement-shaped workload (concurrent saves on different
  aggregates with a constrained connection pool).
…atrix

Replace BenchmarkSaveStrategiesConcurrent (a single concurrency/pool point)
with BenchmarkSaveStrategiesMatrix that sweeps:

- strategy: {InTX, OutsideTX}
- load profile: {burst, append}
  - burst: a fresh aggregate per save (disbursement webhook pattern)
  - append: sequential saves on pre-populated aggregates via an atomic
    counter, so concurrent saves usually target distinct aggregates
    (steady-state state-machine pattern)
- concurrency: {16, 64, 200}
- pool size: {20, 50, 200}

Add a warmupPool step so first-save TCP handshakes don't pollute the timed
window. Each cell uses one client, one DB, one EventStore shared by all
goroutines — matching how a real service is wired.

The matrix surfaces the contention knee: when concurrency exceeds pool size,
InTX latency grows ~5x to ~95x while OutsideTX stays roughly flat. This is
the operational pattern that motivated the optional OutsideTX strategy in
the first place.
Document the two global-position strategies (InTX default, OutsideTX
opt-in), the trade-off they represent, which consumers are affected by
gaps vs ordering, and the empirical benchmark matrix showing the
contention knee at concurrency > pool size. Also documents how to
reproduce the benchmark and how the trade-off behavior is asserted as a
test.
@rrodriguez-consulting

Copy link
Copy Markdown
Author

Hey @klowdo — fair points, thanks for the pushback. You were right that "we use it in prod" alone isn't really an argument. Updated the PR so InTX
is the default and OutsideTX is opt-in; the original flow is preserved exactly on the InTX path. Full write-up of the trade-off and the benchmark
matrix is in eventstore/mongodb_v2/STRATEGY.md (probably more useful than this comment).

To answer your "why outside the transaction / what load / pre-optimization?" questions:

Not pre-optimization. At AltScore we run a disbursements service that gets state-change webhooks from STP (Mexican payment network). Each webhook =
one Save() on its own aggregate. Under burst — STP delivers 200–300 confirmations within a few seconds — every concurrent Save() was serializing
on the $all write lock inside the TX. 200-connection pool saturated, webhooks timed out, STP retried, cascade. Moving $all outside the TX removed
the contention and the issue went away.

Why it's safe for our specific workload:

  • Each webhook is an independent aggregate; no cross-aggregate sagas
  • Our sagas are saga.Saga handler-style (reactive per-event), not position-scanning
  • Outbox is WithEventHandler (after-save), doesn't read $all
  • Per-aggregate ordering is still guaranteed by the stream-doc optimistic lock — unchanged

On the gap behavior — you were also right. Added TestGlobalPositionStrategyGapBehavior that races two saves on the same aggregate and asserts
exactly what you said: InTX rolls back the $all increment on abort (no gap), OutsideTX keeps it (gap of 1). Felt better making it explicit and
tested instead of waving at it.

Benchmark: BenchmarkSaveStrategiesMatrix sweeps strategy × concurrency × pool size. burst profile (the disbursements-shaped workload), ms per save:

conc \ pool 20 InTX 20 OutsideTX 50 InTX 50 OutsideTX 200 InTX 200 OutsideTX
16 1.5 0.38 1.7 0.39 1.6 0.36
64 10.4 0.31 5.1 0.27 2.6 0.25
200 28.2 0.31 24.7 0.26 4.9 0.18

InTX collapses when concurrency > pool size; OutsideTX stays flat. Local mongo testcontainers, so the absolute numbers will be different on Atlas
(network roundtrip dominates), but the shape is the same. Full table + append profile + methodology in STRATEGY.md.

Small heads up: looks like CI hasn't actually run since April — the runs sit on action_required, since the upstream repo requires maintainer
approval for fork PRs. Locally go build / go vet / go test -short / golangci-lint run are all clean for this PR. If you approve CI, the lint
job will show 4 issues that are already failing on main since 99deb22 (context_test.go modernize × 2, eventbus/gcp SA1019 × 2 — the pubsub
deprecation only surfaces here because mongo-driver v2 transitively bumps pubsub to v1.50.1). Happy to fix in a follow-up if you want them in scope.

Let me know what you think — and if you want me to tweak the docs or add a godoc example for the option 🙏

@klowdo
klowdo merged commit 673a102 into looplab:main Jun 2, 2026
3 of 4 checks passed
@coveralls

Copy link
Copy Markdown

Coverage Status

coverage: 53.057% (+0.09%) from 52.965% — AltScore:feat/mongodb-v2-performance into looplab:main

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants