Skip to content

get_named_validator returns one validator shared across threads; concurrent validation fails with _RefResolutionError #1434

Closed

Description

@bokelley

Summary

adcp.validation.schema_loader.get_named_validator caches one Draft7Validator per schema, built on jsonschema's legacy RefResolver, and returns that same instance to every caller. RefResolver keeps a mutable scope stack: following a $ref into another document pushes that document's scope, and leaving pops it. When two threads validate with the shared validator at the same time, one thread can resolve #/definitions/... against the other thread's document and fail with _RefResolutionError: Unresolvable JSON pointer.

Versions: adcp 8.0.0, jsonschema 4.25.1, Python 3.13.

Reproduction

import collections, threading
from adcp.validation.schema_loader import get_named_validator

validator = get_named_validator("core/targeting-overlay-support.json", version="3.2")
declaration = {
    "geo_countries": True,
    "geo_metros": {"systems": ["nielsen_dma"]},
    "geo_postal_areas": {"US": ["zip"]},
    "frequency_cap": True,
}
errors = collections.Counter()

def work():
    for _ in range(300):
        try:
            list(validator.iter_errors(declaration))
        except Exception as exc:
            errors[f"{type(exc).__name__}: {exc}"] += 1

threads = [threading.Thread(target=work) for _ in range(8)]
for t in threads: t.start()
for t in threads: t.join()
print(dict(errors))

Output:

{"_RefResolutionError: Unresolvable JSON pointer: 'definitions/countrySupport'": 1259,
 "_RefResolutionError: Unresolvable JSON pointer: 'definitions/supported'": 6}

Run on a single thread, the same declaration validates with no errors.

Impact

Any threaded server that validates with get_named_validator (Flask, or WSGI under a threaded worker) fails intermittently under concurrent requests. We hit it in production-like traffic on a management API that validates overlay_support declarations.

The task validators in schema_loader are built and cached the same way (state.compiled plus RefResolver). We haven't reproduced it there, but they may share the problem.

Suggested fix

Any of these would work:

  • Build the validator on referencing.Registry (jsonschema ≥ 4.18), which keeps no per-validation mutable state.
  • Cache only the schema and store, and return a validator with a fresh RefResolver per call.
  • Cache validators per thread.

Workaround

We build a validator per call, with its own resolver over the cached validator's schema, base URI and store (the store is only read):

shared = get_named_validator(path, version=version)
resolver = RefResolver(base_uri=shared.resolver.base_uri, referrer=shared.schema, store=shared.resolver.store)
validator = Draft7Validator(shared.schema, resolver=resolver, format_checker=shared.format_checker)

Activity

  1. added
    claude-triagingTriage routine is actively working on this issue (1-3 min)
    on Oct 7, 2026
  2. bokelley commented on Oct 7, 2026

    @bokelley
    ContributorAuthor

    Triage

    Classification: Bug
    Bucket(s): validation (no validation label in repo — gap noted for maintainers)
    Status: ready-to-implement

    What the experts said:

    • code-reviewer: Both get_named_validator (L765) and get_validator (L693) cache a Draft7Validator holding a shared mutable RefResolver._scopes_stack; concurrent iter_errors() corrupts the scope stack — blocker, fix both in one patch.
    • ad-tech-protocol-expert: Confirmed production blocker on all threaded WSGI stacks (Flask, gunicorn --threads, uWSGI enable-threads); fresh RefResolver per call is unconditionally correct since the scope stack is local to one traversal and the store is read-only; non-breaking semver patch.

    My take: This is a hard crash under any concurrent validation workload, not an edge case. Both functions must be fixed together — patching only get_named_validator leaves the higher-traffic get_validator path (called on every validate_request / validate_response) broken.

    Implementation scope:

    • File: src/adcp/validation/schema_loader.py only. Add a _ValidatorSpec(schema, base_uri, store, format_checker) dataclass; cache it in state.compiled / state.named_compiled instead of the validator. Have both get_* functions call spec.make() — _make_ref_resolver(…) + Draft7Validator(…) — on each return. Schema I/O and _reachable_schema_store graph walk stay cached; only the O(1) RefResolver + validator construction moves to call time. Preserve referrer=schema (not {}) in every fresh RefResolver — load-bearing for fragment-only $defs refs.
    • Test: Add a concurrent test (4+ threads × ≥100 iterations each, core/targeting-overlay-support.json) confirming zero _RefResolutionError exceptions.
    • Checks: pytest tests/ · mypy src/ · ruff check . · black --check .
    • Non-breaking: public signatures unchanged; Draft7Validator behavior is identical; object identity across calls was never documented.

    Triaged by Claude Code. Session: https://claude.ai/code/session_015eyJqam8c1ZMC8TvovbLu4


    Generated by Claude Code

  3. added
    bugSomething isn't working
    pythonPull requests that update python code
    and removed
    claude-triagingTriage routine is actively working on this issue (1-3 min)
    on Oct 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingclaude-triagedpythonPull requests that update python code

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions