8000 Refactor scoping and make default_scopes thread-safe (rebased) by smtlaissezfaire · Pull Request #772 · mongomapper/mongomapper · GitHub
[go: up one dir, main page]

Skip to content

Refactor scoping and make default_scopes thread-safe (rebased) - #772

Merged
cursor[bot] merged 3 commits into
masterfrom
cursor/threadsafe-scope-context-ceb7
Nov 5, 2026
Merged

cursor[bot] merged 3 commits into
masterfrom
cursor/threadsafe-scope-context-ceb7

Conversation

@smtlaissezfaire
Copy link
Copy Markdown
Member

Rebase of #683 onto current master.

Originally opened by @a2ikm. Their commit is preserved (author Masato Ikeda); this branch does not modify the original pull request.

lib/mongo_mapper/plugins/scopes.rb had not changed since 2021, so the commit cherry-picked cleanly. The follow-up commit reconciles the new thread-local current_context with how query behaves on master.

What the original change does

Scopes used to unscope by clearing the shared @default_scopes array (and the thread-local active_scopes list). Another thread could observe an empty default scope while unscoped was running. default_scopes is the stored definition of those scopes and should not be cleared.

current_context is a thread-local query instead:

  • unscoped { ... } stores a plain query, so default scopes are not applied inside the block
  • with_scope(...) { ... } stores a query that already includes default scopes plus the arguments, and nested with_scope stacks on that query
  • otherwise it is nil, and query builds a new query and applies default_scopes

active_scopes is removed. current_context is the active scope. Anything calling Model.active_scopes will break.

Rework

Two master behaviors did not survive the first version of query once current_context was set:

  1. Query options. Model.query(limit: 1, sort: :age) inside with_scope / unscoped went through where, which puts every key in the criteria hash. limit and sort then stop being query options. query now clone.amends the context, which is what Querying#query does for a fresh query. A later query in the same scope does not keep that limit.
  2. Default-scope re-entry. A default scope proc that calls where on the model used to run inside unscoped, which cleared @default_scopes so the inner query did not apply default scopes again. Without that, the proc recurses until the stack overflows. The re-entry guard is a thread-local flag. It does not clear @default_scopes, so the thread-safety fix stays intact.

query also dupes its options hash before deleting the internal :unscoped key, so the caller's hash is left alone (and a frozen hash does not raise).

Risks

  • active_scopes is gone. It was public enough to have a thread-safety changelog entry. Callers should use with_scope / unscoped rather than reading the list.
  • Thread-local keys are still per class name (mongo_mapper_#{name}_current_context). Two classes that share a name, including Doc() helpers that both report "Class", still share a context on one thread. That matches the old active_scopes key.
  • A default scope that is already a query object replaces the context instead of merging, because process_scope returns a non-Hash scope as-is. That is unchanged from master. Block-form default_scope { where(...) } stores the query produced at definition time, and later default scopes are built on top of earlier ones, which is why stacked default scopes still work.
  • Single-collection _type criteria are applied when the context query is first built (with_scope, unscoped, or a normal query). Later queries inside that context clone it, so _type is kept. Identity-map marking still happens on the query IdentityMap#query returns.
Open in Web Open in Cursor 

a2ikm and others added 3 commits November 4, 2026 16:51
We've used `active_scopes` and `default_scopes` to store scopes in the current
context. However, it has been thread-unsafe because we clear `default_scopes`
to unscope.
Moreover, `default_scopes` is the master data of default scopes, and we shouldn't
clear it.

Therefore, this commit introduces `current_context` as a thread local variable to
store the current scoped context in it:

- When `unscoped { ... }` is called, `current_context` stores a plain query object
- When `with_scope(...) { ... }` is called, `current_context` stores a query object
  scoped with `default_scopes` and the method's arguments
- Otherwise, it stores nothing - nil, so we need to build a new query with
  `default_scopes`

`current_context` includes active scopes naturally, so this commit removes the method.
Inside with_scope and unscoped, query now clones the thread-local context
and amend()s criteria plus query options such as limit and sort. where()
was folding those options into the criteria hash.

A default scope that calls back into query no longer reapplies default
scopes on that call, so it does not recurse. The guard is thread-local and
does not clear the shared default_scopes array. query also dupes its
options hash before removing the internal :unscoped flag.

Co-authored-by: Scott Taylor <smtlaissezfaire@users.noreply.github.com>
Bring the thread-safe scope rewrite up to date with current master,
including the embedded NoRootDocument change and MongoDB 9.0 CI.
Co-authored-by: Scott Taylor <smtlaissezfaire@users.noreply.github.com>
@cursor
cursor Bot merged commit 968e7b2 into master Nov 5, 2026
72 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.

3 participants

0