Skip to content

feat!: require pg_eventstore v3 (2.0.0) - #34

Merged
aroeczek merged 5 commits into
mainfrom
feat/pg-eventstore-v3
Jul 29, 2026
Merged

feat!: require pg_eventstore v3 (2.0.0)#34
aroeczek merged 5 commits into
mainfrom
feat/pg-eventstore-v3

Conversation

@aroeczek

@aroeczek aroeczek commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Releases all four gems as 2.0.0, moving yes-core to pg_eventstore ~> 3.0.

⚠️ Do not merge or adopt until the target event store has been migrated to v3. v3's schema is incompatible with v1 — this code cannot run against a v1 store.

Why 2.0.0 and not 1.5.0

Consumers declare these gems at ~> 1.3 from the git source, and main is already at 1.4.0. So 1.5.0 would satisfy the existing constraint and could be pulled in by an unrelated bundle update — putting v3 code against a still-v1 store with no deliberate decision anywhere.

2.0.0 makes that structurally impossible. Adoption now requires an explicit Gemfile edit, which is exactly what it should require.

The actual v3 change: two OTel span attributes

pg_eventstore migration 13 drops events.link_id in favour of the bigint link_global_position, so Event#link_id raises NoMethodError:

file change
yes-core/lib/yes/core/commands/stateless/handler.rb 'event.link_id' => result.link_id'event.link_global_position' => result.link_global_position
yes-core/lib/yes/core/command_handling/event_publisher.rb same

📊 Dashboards / trace queries keyed on event.link_id need updating to event.link_global_position.

yes-auth had to move too

yes-auth.gemspec pinned yes-core '~> 1.0', which would have made the monorepo unresolvable the moment yes-core hit 2.0.0. Bumped to ~> 2.0.

Verified by resolving the full set — pg_eventstore 3.0.0, all four gems at 2.0.0, no conflicts.

Two v3 API breaks found by CI

Neither is in the gem's migration notes.

1. WrongExpectedRevisionError.new now requires verdict: — signature is (revision:, expected_revision:, stream:, verdict:), where the Symbol selects the message. Omitting it raises ArgumentError: missing keyword: :verdict instead of the intended error, so retry logic keyed on WrongExpectedRevisionError silently stops working. 7 specs caught it.

Verdicts assigned per the gem's own rules in Commands::RevisionCheck::StreamRevisionComparison, rather than blanket-set, so each message matches the condition detected:

site condition verdict
event_publisher.rb, stateless/handler.rb held vs stored revision differ :unmatched_stream_revision
command_executor_spec 1 vs 2 :unmatched_stream_revision
command_group_executor_spec Integer vs :no_stream :expected_not_to_have_stream

2. setup.rake reads PG_EVENTSTORE_URI at load time (top-level configure block), not lazily inside a task. Since the Rakefile loads it unconditionally, every rake invocation now needs the variable — db:create db:migrate aborted on KeyError despite never touching the event store. Fixed in CI config here; any app whose Rakefile loads setup.rake needs PG_EVENTSTORE_URI present for deploy-time rake db:migrate.

Also updated the four per-gem Gemfile.locks — CI runs each gem from its own directory in frozen mode.

Verification

✅ All green: RuboCop, yes-core, yes-auth, yes-command-api, yes-read-api — 1184 examples in yes-core.

yes-core moves to pg_eventstore ~> 3.0, whose schema is incompatible with v1, so
this is a major bump for all four gems.

2.0.0 rather than 1.5.0 is deliberate. Consumers declare these gems at `~> 1.3`
from the git source and main is already at 1.4.0 -- so 1.5.0 would satisfy the
existing constraint and could be pulled in by an unrelated `bundle update`, putting
v3 code against a still-v1 store. 2.0.0 makes that structurally impossible;
adoption now requires an explicit Gemfile edit, which is what it should require.

The v3 code change itself is two OpenTelemetry span attributes. pg_eventstore
migration 13 drops events.link_id in favour of the bigint link_global_position, so
Event#link_id raises NoMethodError:

  * Commands::Stateless::Handler
  * CommandHandling::EventPublisher

Dashboards or trace queries keyed on `event.link_id` need updating to
`event.link_global_position`.

yes-auth pinned `yes-core ~> 1.0`, which would have made the monorepo unresolvable
the moment yes-core went to 2.0.0 -- bumped to ~> 2.0. Verified by resolving the
full set: pg_eventstore 3.0.0, all four gems at 2.0.0, no conflicts.

NOT verified locally: rubocop and rspec both need Ruby 3.4.5 per the Gemfile and
the dev machine had 3.4.1. CI runs both on 3.4.5 with Postgres 16 + Redis, and its
"Set up EventStore DB" step exercises pg_eventstore 3.0's migrations for the first
time in this repo -- watch that step in particular.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
aroeczek and others added 3 commits July 28, 2026 11:10
CI runs each gem's suite from its own directory with bundler in frozen mode, so the
four per-gem Gemfile.lock files must record the new versions too -- only the root
lockfile had been regenerated, and the jobs failed with "The gemspecs for path gems
changed, but the lockfile can't be updated because frozen mode is set".

All four now resolve yes-core 2.0.0 and pg_eventstore 3.0.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pg_eventstore 3.0's setup.rake resolves the URI through ENV.fetch at LOAD time,
inside a top-level `configure` block -- not lazily inside a task. Since the Rakefile
loads that file unconditionally, EVERY rake invocation now needs the variable, not
just the pg_eventstore ones.

So `db:create db:migrate` and `app:db:migrate:message_bus` aborted with
`KeyError: key not found: "PG_EVENTSTORE_URI"` even though neither touches the event
store. yes-auth passed only because it skips both steps.

Worth knowing beyond this repo: any app whose Rakefile loads setup.rake inherits the
same requirement, so deploy-time `rake db:migrate` needs PG_EVENTSTORE_URI present
in the environment after upgrading to v3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pg_eventstore 3.0 changed the signature to
`initialize(revision:, expected_revision:, stream:, verdict:)`. `verdict` is a
Symbol selecting the user-facing message, and it is mandatory -- so all 7
construction sites raised `ArgumentError: missing keyword: :verdict` instead of the
error they were meant to produce, failing 7 specs.

Verdicts follow the gem's own rules in
Commands::RevisionCheck::StreamRevisionComparison:

  * two differing Integer revisions       -> :unmatched_stream_revision
  * Integer revision vs :no_stream        -> :expected_not_to_have_stream

Applied per site rather than blanket-set, so the rendered message matches the
condition actually detected:

  lib   event_publisher.rb, stateless/handler.rb  -> :unmatched_stream_revision
        (both only reach the raise when held and stored revisions differ)
  spec  command_executor_spec (revision 1 vs 2)   -> :unmatched_stream_revision
        command_group_executor_spec (n vs :no_stream) -> :expected_not_to_have_stream

This is a second v3 API break beyond the link_global_position rename, and it is not
in the migration notes -- worth checking other consumers that construct this error
directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
encryptor_spec.rb unit-tests #serialize/#deserialize by calling them directly, which
cannot catch changes in WHEN pg_eventstore invokes them. That is exactly the gap v3
fell through: 3.0 began running #deserialize on append_to_stream as well as on reads,
and nothing in this repo could see it, because nothing registers an encryptor in
config.middlewares. The host applications gate their registration on
`!Rails.env.test?`, so their suites cannot see it either -- yousty-eventsourcing was
the only repo whose CI caught the change, and only because it registers a dummy
encryptor in its own spec setup.

Registers one here, mutating config.middlewares the same way the host apps do (appended
last so :with_indifferent_access stays first) and removing it afterwards, then covers
the three behaviours worth pinning:

  * the protected attribute is encrypted AT REST -- asserted by reading with the
    encryptor bypassed, which is how EncryptorAdapter-style read paths see events
  * reads decrypt
  * append_to_stream returns an already-decrypted event, the v3 behaviour change itself,
    so a future change to it fails here rather than silently in a consumer

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@aroeczek

aroeczek commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

⚠️ Heads-up for anyone running the pg_eventstore 3.0.0 migrations

A bug in pg_eventstore 3.0.0 itself, not in this PR — but since this PR pins that version, it is worth recording. Found while migrating a 34.5M-event store from v1.13.3 to v3.0.0.

Symptom

rake aborted!
ConnectionPool::TimeoutError: Waited 5.0 sec, 0/5 available
  .../db/migrations/12_migrate_legacy_links.rb:29

The migration aborts part-way. Re-running resumes from the last recorded level, then fails at the same place.

Cause

lib/pg_eventstore/tasks/setup.rake wraps the entire migration loop in a single connection checkout:

PgEventstore.connection(:_eventstore_db_connection).with do |conn|   # holds 1 connection
  conn.exec('CREATE TABLE IF NOT EXISTS migrations ...')
  ...
  Dir[...].each do |f_name|
    load(f_name, true)     # <- every .rb migration runs INSIDE this block

Migrations 12 and 18 then size their pool to exactly the number of workers they spawn:

CONCURRENCY = ENV['CONCURRENCY']&.to_i || 10
PgEventstore.configure(name: :_eventstore_db_connection) do |config|
  config.connection_pool_size = CONCURRENCY        # 12 and 18
end
threads = CONCURRENCY.times.map { Thread.new { ... } }

With the outer checkout still held, only CONCURRENCY - 1 slots are free, so one worker never gets a connection and dies after the 5-second checkout timeout. Confirmed live — the outer connection sits idle for the whole run:

pid  | state | wait_event | running_for | query
1250 | idle  | ClientRead | 00:12:36    | SELECT number FROM migrations ORDER BY number DESC LIMIT 1

Migration 16 is unaffected: it uses connection_pool_size = CONCURRENCY * 10.

Why it does not always show up

Structural at any CONCURRENCY, but only fatal when workers hold connections longer than 5 seconds. On a small or fast store the starved worker gets a freed connection almost immediately and the migration passes. On a large store it fires reliably: migration 12 batches up to 1000 individual UPDATEs per exec, and each batch takes tens of seconds against a partitioned events table.

So it hits exactly the installations that can least afford it: big stores, mid-migration, in a maintenance window.

Workaround

Patch the two migrations in the image that runs them:

RUN GEMDIR=$(ruby -e 'require "pg_eventstore"; puts Gem.loaded_specs["pg_eventstore"].gem_dir') \
 && sed -i 's/config.connection_pool_size = CONCURRENCY$/config.connection_pool_size = CONCURRENCY + 2/' \
      "$GEMDIR/db/migrations/12_migrate_legacy_links.rb" \
      "$GEMDIR/db/migrations/18_populate_events_global_index.rb"

The $ anchor matters — it must not match migration 16's CONCURRENCY * 10.

A proper upstream fix would either not hold the outer connection across load, or size those two pools to CONCURRENCY + 1. Worth filing against pg_eventstore.

Other things worth knowing before you run these migrations

  • pg_eventstore 3.0.0 ships a native C extension (ext/pg_eventstore_ext), new in this major. A runner image needs build-essential and libpq-dev.
  • WrongExpectedRevisionError.new now requires verdict: (handled in this PR). Omitting it raises ArgumentError instead of the expected error, so rescue PgEventstore::WrongExpectedRevisionError retry logic silently stops retrying rather than failing loudly.
  • setup.rake resolves PG_EVENTSTORE_URI at load time, inside a top-level configure block. Any Rakefile that loads it makes every rake task require the variable — including ones unrelated to the event store, such as db:migrate. Ensure it is set wherever rake runs, including deploy-time migration jobs.
  • Migrations 16 and 18 truncate and restart from scratch. An abort at 90% loses all of it. Migration 12 is resumable (it filters link_global_position IS NULL).
  • max_locks_per_transaction may need raising. If events is partitioned, every migration statement locks all partitions plus their indexes. With 859 partitions that is ~6,900 locks per worker against a max_locks_per_transaction × max_connections table — the default 128 × 100 = 12,800 slots caps you at about 5 workers, above which you get out of shared memory.

Measured cost, for anyone sizing a window

Full 9 → 32 migration on 34.5M events / 859 partitions / 705k links, on 16 vCPU:

migration time share
12 back-fill links 1h 02m 48%
16+17 streams index 34m 26%
18 events index + CLUSTER 16m 12%
19–32 (everything else) 11m 9%
total ~2h 04m

Migration 12 dominates and is not one the release notes flag as slow. Migrations 19 and 26 — which look like they should be the expensive serial ones — are nearly free, because 13/14/22/24 drop the PK and all but one index before 26 rewrites the table.

Link back-fill throughput scaled 5,000 → 11,900 → 15,700 rows/min at CONCURRENCY 5 → 16 → 48, so more workers keep helping in wall-clock terms even as per-core efficiency collapses from lock contention.

@aroeczek
aroeczek merged commit 3529816 into main Jul 29, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant