Skip to content

unpickling ORM instance with a wildcard loader path fails in a process that has not yet created the PathToken #13493

Description

@zzzeek

Reported at #13491.

Unpickling an ORM instance that was loaded using a loader option involving a
wildcard token — load_only(), raiseload("*"), Load.load_only(), etc. —
fails in a Python process that has not already created the corresponding
PathToken object. This is normally invisible because the process that
pickles is also the process that unpickles, or because a fork() child
inherits the parent's PathToken._intern dictionary. It becomes visible with
spawn / forkserver multiprocessing, or in any separate consumer process —
notably, Python 3.14 changed the default POSIX start method from fork to
forkserver, so applications that worked previously can start failing on
upgrade.

MCVE

import pickle

from sqlalchemy import create_engine
from sqlalchemy import ForeignKey
from sqlalchemy import select
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import load_only
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column
from sqlalchemy.orm import raiseload
from sqlalchemy.orm import relationship
from sqlalchemy.orm import Session
from sqlalchemy.orm.path_registry import PathToken


class Base(DeclarativeBase):
    pass


class Parent(Base):
    __tablename__ = "parent"
    id: Mapped[int] = mapped_column(primary_key=True)
    data: Mapped[str]
    children: Mapped[list["Child"]] = relationship()


class Child(Base):
    __tablename__ = "child"
    id: Mapped[int] = mapped_column(primary_key=True)
    parent_id: Mapped[int] = mapped_column(ForeignKey("parent.id"))


e = create_engine("sqlite://")
Base.metadata.create_all(e)
with Session(e) as s:
    s.add(Parent(id=1, data="d", children=[Child(id=1)]))
    s.commit()


def run(name, *opts):
    with Session(e) as s:
        obj = s.scalars(select(Parent).options(*opts)).one()
        s.expunge_all()
    data = pickle.dumps(obj)

    # simulate a fresh interpreter: no path tokens created yet
    saved = dict(PathToken._intern)
    PathToken._intern.clear()
    try:
        pickle.loads(data)
        print(f"{name}: PASS")
    except Exception as ex:
        print(f"{name}: FAIL {type(ex).__name__}: {ex}")
    finally:
        PathToken._intern.update(saved)


run("load_only()", load_only(Parent.data))
run("raiseload('*')", raiseload("*"))

Output:

load_only(): FAIL KeyError: 'column:*'
raiseload('*'): FAIL IndexError: invalid argument for RootRegistry.__getitem__: None

Clearing PathToken._intern is a stand-in for a fresh interpreter; the same
failures occur with an actual spawn/forkserver child or a separate
process, per the matrix in the discussion.

The two errors correspond to the two places a token path rides along on a
pickled instance:

  • InstanceState.load_options — propagated Load options, e.g. the
    ("column:*") element load_only() adds to defer everything else;
  • InstanceState.callables — a _LoadLazyAttribute whose loadopt has a
    root level "relationship:_sa_default" / "relationship:*" path.

Cause

PathRegistry._serialize_path() renders a PathToken as its plain string.
PathRegistry._deserialize_path() then has to tell a token apart from a
mapped class / attribute key, and does so by consulting PathToken._intern:

def _deserialize_mapper_token(mcls):
    return (
        orm_base._inspect_mapped_class(mcls, configure=True)
        if mcls not in PathToken._intern
        else PathToken._intern[mcls]
    )

def _deserialize_key_token(mcls, key):
    if key is None:
        return None
    elif key in PathToken._intern:
        return PathToken._intern[key]
    else:
        mp = orm_base._inspect_mapped_class(mcls, configure=True)
        return mp.attrs[key]

PathToken._intern is populated lazily, as _TokenRegistry objects are
constructed — i.e. only once the receiving process has itself built a loader
path with that token. In a process that has not, "column:*" is looked up as
a mapped attribute (KeyError) and "relationship:_sa_default" is inspected
as a mapped class, yielding None and then the RootRegistry.__getitem__
IndexError.

Fix

Detect the token structurally rather than by registry membership. Tokens are
always "<strategy_wildcard_key>:*" or "<strategy_wildcard_key>:_sa_default"
— this is enforced by _CreatesToken.token(), which raises ArgumentError
for anything else — and mapped attribute keys are Python identifiers, so they
can never contain ":". Deserialization can therefore recognize these strings
and call PathToken.intern() on them, which is also what populates the
registry in the first place.

Testing

test/orm/test_pickled.py, clearing PathToken._intern to stand in for a
fresh interpreter:

  • a unit round trip of PathRegistry.serialize() / deserialize() over the
    combinations of column / relationship × * / _sa_default × root level
    / mapper level token;
  • pickle round trips of an instance loaded with load_only() and with
    raiseload("*"), asserting the reconstructed loader paths.

Affects 2.0 and 2.1.

Activity

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 workingloader optionsORM options like joinedload(), load_only(), these are complicated and have a lot of issuesorm

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions