Skip to content

MONGOID-5943: Add changeset persistence layer#6148

Draft
jamis wants to merge 48 commits into
mongodb:masterfrom
jamis:changeset-persistence-layer
Draft

MONGOID-5943: Add changeset persistence layer#6148
jamis wants to merge 48 commits into
mongodb:masterfrom
jamis:changeset-persistence-layer

Conversation

@jamis

@jamis jamis commented May 15, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR introduces a changeset persistence layer for Mongoid. Rather than writing each save, destroy, or atomic operation immediately to MongoDB, all operations are now staged in a Mongoid::Changeset and executed together as an optimized batch (using bulk writes where possible) when the outermost scope exits.

New public API

  • Mongoid.changeset { ... } — opens a changeset scope. All persistence calls inside the block (save, destroy, atomic ops) are staged and flushed together when the outermost block returns. Changesets nest: inner calls accumulate into the outermost one.
  • before_flush / after_flush callbacks — fire around the batch write, at the document level.
  • staged? / staged predicates on Mongoid::Document — true while the document has a pending entry in the current changeset.
  • Mongoid::Changeset::Entry — immutable value object representing one staged database operation (:insert, :update, :delete, :upsert, etc.).
  • Mongoid::Errors::InvalidChangesetOperation — raised when an operation is attempted on a terminated changeset.

How atomically changes

atomically is now implemented in terms of Mongoid.changeset. Nesting and join semantics are unchanged for the default (join_context: nil) case. join_context: false still works but is deprecated (see below).


Breaking changes

1. atomically(join_context: false) is deprecated

This option caused the inner block to persist immediately, independent of any enclosing context. It now emits a deprecation warning. The replacement is to call save outside any enclosing changeset scope. The option still functions for now but will be removed in a future version.

2. after_save / after_update / after_create callbacks run before changes are persisted

These callbacks used to fire after the document was persisted. Now, they run inside of a changeset and always fire after the document is staged (but before it is persisted). If you need logic to only run after the record is persisted, you should use after_flush or after_commit.

3. after_commit callbacks are deferred to flush time

after_commit callbacks now fire at flush time — after all staged entries have been written to the database — rather than immediately after each individual save. Code that depended on commit callbacks firing right after a nested save will observe different timing.

4. Referenced association setters call save!

Setters for referenced associations (has_one, has_many, belongs_to) now call save! rather than save, so validation errors raise rather than being silently swallowed. They also go through a changeset, rather than writing directly.

5. Nested attributes do not persist immediately

Assigning to an association via a nested attributes accessor no longer immediately persists the association. Instead, it is saved when the parent is saved, which gives validations a chance to run and influence whether the parent (and the child associations) are saved.

jamis added 30 commits May 12, 2026 09:28
Implement an immutable Struct to represent a single staged database operation,
capturing type (insert/update/delete/update_many/delete_many), collection,
selector, payload, document, and session at the moment an operation is staged.
Includes comprehensive specs covering all attributes and nil document handling.
…card

Introduces Mongoid::Changeset with entry accumulation, depth tracking for
nested scopes, discard, and a flush stub (full implementation in Task 6).
Also adds Mongoid::Errors::InvalidOperation for guarding against
post-termination use.
Add Mongoid.changeset{} and Mongoid.current_changeset, backed by
Threaded.current_changeset. Nested calls accumulate without flushing;
the outermost block flushes on exit or discards on error.
The raising stub broke lifecycle units and forced rescue scaffolding
throughout tests. A no-op with a TODO comment is the right placeholder
until Task 6 lands.
Use == instead of equal? in _build_batches so that two distinct
Mongo::Collection objects for the same logical collection are correctly
grouped into a bulk_write batch.

Replace doc.send(:in_transaction?) with entry.session&.in_transaction?
so the session captured at staging time is used, consistent with how
persistable.rb uses _session directly.

Extract _dispatch_commit helper to keep _flush_entries within the
cyclomatic-complexity limit.
Replace the old prepare_insert (which called insert_as_root/insert_as_embedded
directly and wrapped everything in run_callbacks(:commit)) with a new version
that stages a Changeset::Entry and delegates execution to Changeset#flush.
Root documents stage an :insert entry; embedded documents stage an :update
entry against the root collection (matching the existing driver call shape).

Remove insert_as_root, insert_as_embedded, and post_process_insert — document
state updates (new_record = false, flag_descendants_persisted) now happen inside
Changeset#_update_document_state at flush time.

Extend _update_document_state to handle embedded-insert :update entries:
when an :update entry carries a document that is still a new record, flip
new_record and persist descendants, matching what :insert does.

Guard Threaded.add_modified_document in post_process_persist so it is only
called when outside a changeset; inside a changeset, _dispatch_commit handles
transaction tracking after flush.
RootInsertable overrode insert_as_root (removed); update to override
_stage_insert_as_root instead. Also update a stale comment in touchable.rb
that referenced the old method name.
…entries

Using doc.new_record? as a discriminator on :update entries to detect embedded
inserts is fragile. Introduce a dedicated :embedded_insert type so the intent
is explicit and future :update entries cannot accidentally trigger insert-state
transitions. Also document why dirty state is cleared inside the changeset block
before the driver write.
Removing the explicit move_changes call from update_document's block
prevents post_process_persist from clearing previous_changes a second
time and corrupting after_save callback state.
…geset

Also fix Changeset#run to return the block's value so that destroy can
capture whether the callback chain aborted. Removes the manual
run_callbacks(:commit) wrapper from destroy — commit dispatch is now
handled by Changeset#_dispatch_commit at flush time.
Introduce :embedded_delete as a distinct entry type for embedded
document deletes (parallel to :embedded_insert), so the changeset
can mark the document destroyed at flush time. Update _execute_single,
_bulk_op_for, and _update_document_state accordingly, fix the stale
type comment in Entry, and add an integration spec covering the
deferred delete path.
Also fix Savable#save to return true when an insert is staged in an
active changeset (the document stays new_record? until flush, so the
old check produced a false negative when called inside a changeset block).
Replace the depth-counter/atomic-context-stack mechanism in
atomically with delegation to Mongoid.changeset{}. Nesting now
works through the changeset's own depth tracking. join_context: false
is deprecated and implemented via _atomically_independent, which
temporarily hides the enclosing changeset.

Remove executing_atomically?, persist_or_delay_atomic_operation,
and the _mongoid_push/pop/remove/reset atomic context helpers. Adapt
prepare_atomic_operation and persist_atomic_operations to stage
Changeset::Entry objects directly. process_atomic_operations now
always calls remove_change at stage time rather than deferring.

Update logical, multipliable, renamable, and unsettable to remove
executing_atomically? guards, always updating attributes via
process_attribute (or direct hash manipulation for rename). Update
all "marks a dirty change when executing atomically" specs to reflect
that dirty tracking is cleared at stage time.
Each of the 11 atomic-op modules (inc, mul, set, unset, bit, set_max,
set_min, pop, push, add_to_set, pull, pull_all, rename) now builds its
ops hash directly and stages a Changeset::Entry, removing the
prepare_atomic_operation / process_atomic_operations indirection.
Those two helpers are removed from Persistable; persist_atomic_operations
is kept because touchable.rb still calls it.
Rewires Contextual::Mongo#delete / #delete_all and #update_all to
accumulate :delete_many and :update_many entries in the changeset
instead of hitting the driver directly. Adds an :opts field to
Changeset::Entry for driver-level options (e.g. array_filters,
collation), and threads it through _execute_single. A private
_view_opts helper captures view-level options (collation) so they
survive the indirection through the changeset.
has_one and has_many proxy setters now call save! instead of save when
persisting a newly assigned document. Validation failures raise
Mongoid::Errors::Validations immediately rather than being silently
ignored.
Wrap the add_to_set and save! calls in Mongoid.changeset{} so both the
base document's foreign key update and the child document's save share a
single changeset and flush together. Change doc.save to doc.save! so
validation failures raise rather than silently succeed.
Fix Incrementable#inc to update in-memory attributes even on unpersisted
documents. The early return on `persisted?` was moved after the attribute
loop so in-memory state is always updated, matching the original behavior.
The DB-staging path is still gated on persisted?.

Also add a "Planning and Design" section to AGENTS.md.
jamis added 7 commits May 14, 2026 11:01
run_before_callbacks(:flush) and run_after_callbacks(:flush) are the
correct way to invoke the before and after sides of the :flush callback
chain separately; run_callbacks(:before_flush) does not exist.
Copilot AI review requested due to automatic review settings May 15, 2026 22:39
@jamis jamis requested a review from a team as a code owner May 15, 2026 22:39
@jamis jamis requested a review from comandeo-mongo May 15, 2026 22:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces a brand-new changeset persistence layer that defers all writes (saves, destroys, atomic ops, criteria-level updates/deletes, embedded batch ops) to a per-thread/fiber Mongoid::Changeset, flushed (via single-collection bulk_write where possible) when the outermost Mongoid.changeset { ... } scope exits. atomically is reimplemented on top of this layer (with join_context: false deprecated), new before_flush/after_flush callbacks and staged?/staged predicates are added, transactions inside changesets are forbidden, and several persistence return-value/timing semantics shift.

Changes:

  • New Mongoid::Changeset + Entry core, Mongoid.changeset entry point, before_flush/after_flush callbacks, and staged?/staged predicates.
  • All persistence call sites (insert/update/delete/upsert, atomic operators, embedded batchable, contextual delete/update_all, HABTM setter) rewritten to stage entries instead of issuing direct driver calls.
  • Behavior changes: atomically(join_context: false) deprecated; transactions inside a changeset raise Errors::TransactionInChangeset; deferred timing of save/commit callbacks; delete return-value semantics adjusted; HABTM/referenced-association setters use save!.

Reviewed changes

Copilot reviewed 66 out of 66 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
lib/mongoid.rb Adds Mongoid.changeset entry point.
lib/mongoid/changeset.rb, changeset/entry.rb New core: depth-tracked entry buffer with batched flush.
lib/mongoid/threaded.rb Adds thread/fiber-local current changeset accessor.
lib/mongoid/stateful.rb Adds staged? / staged predicates.
lib/mongoid/interceptable.rb Defines before_flush/after_flush callback hooks.
lib/mongoid/persistable.rb Replaces atomic-context machinery with changeset-based staging helpers.
lib/mongoid/persistable/{creatable,updatable,upsertable,savable,deletable,destroyable}.rb Rewritten to stage insert/update/delete/upsert entries.
lib/mongoid/persistable/{incrementable,logical,maxable,minable,multipliable,poppable,pullable,pushable,renamable,settable,unsettable}.rb Atomic operators now stage update entries with skip_callbacks/dirty-field bookkeeping.
lib/mongoid/contextual/{mongo,memory}.rb Criteria-level delete / update_all route through the changeset.
lib/mongoid/association/embedded/batchable.rb Embedded batch insert/clear/remove/replace stage updates.
lib/mongoid/association/referenced/{has_one,has_many,has_and_belongs_to_many}/proxy.rb Setters use save!; HABTM << uses an explicit changeset.
lib/mongoid/clients/sessions.rb Raises TransactionInChangeset if transaction opens inside a changeset.
lib/mongoid/errors/{invalid_changeset_operation,transaction_in_changeset}.rb + en.yml New error classes/messages.
lib/mongoid/touchable.rb Comment update referencing renamed staging method.
lib/mongoid/warnings.rb New deprecation warning for join_context: false.
lib/config/locales/en.yml New error message entries.
spec/** (numerous) Adds changeset/entry/atomic-callbacks/staged specs; updates atomic-op specs to reflect cleared dirty tracking; updates deletable/destroyable/upsertable/embeds_many specs to cover deferred behavior; updates persistable_spec/atomic semantics; updates interceptable spec for flush callbacks; updates threaded/stateful/transactions specs.
spec/mongoid/interceptable_spec_models.rb Renames insert_as_root hook to _stage_insert_as_root.
AGENTS.md Adds note that design docs should remain unstaged.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/mongoid/contextual/mongo.rb
Comment thread lib/mongoid/changeset.rb Outdated
Comment thread lib/mongoid/changeset.rb Outdated
Comment thread lib/mongoid/changeset.rb
Comment thread lib/mongoid/changeset.rb
Comment thread lib/mongoid/persistable/upsertable.rb
Comment thread lib/mongoid/persistable/savable.rb
Comment thread lib/mongoid/stateful.rb
@jamis jamis marked this pull request as draft May 15, 2026 22:57
@Jibola Jibola changed the title Add changeset persistence layer MONGOID-5943: Add changeset persistence layer May 28, 2026
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.

2 participants