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.
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
PathTokenobject. This is normally invisible because the process thatpickles is also the process that unpickles, or because a
fork()childinherits the parent's
PathToken._interndictionary. It becomes visible withspawn/forkservermultiprocessing, or in any separate consumer process —notably, Python 3.14 changed the default POSIX start method from
forktoforkserver, so applications that worked previously can start failing onupgrade.
MCVE
Output:
Clearing
PathToken._internis a stand-in for a fresh interpreter; the samefailures occur with an actual
spawn/forkserverchild or a separateprocess, 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— propagatedLoadoptions, e.g. the("column:*")elementload_only()adds to defer everything else;InstanceState.callables— a_LoadLazyAttributewhoseloadopthas aroot level
"relationship:_sa_default"/"relationship:*"path.Cause
PathRegistry._serialize_path()renders aPathTokenas its plain string.PathRegistry._deserialize_path()then has to tell a token apart from amapped class / attribute key, and does so by consulting
PathToken._intern:PathToken._internis populated lazily, as_TokenRegistryobjects areconstructed — 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 asa mapped attribute (
KeyError) and"relationship:_sa_default"is inspectedas a mapped class, yielding
Noneand then theRootRegistry.__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 raisesArgumentErrorfor anything else — and mapped attribute keys are Python identifiers, so they
can never contain
":". Deserialization can therefore recognize these stringsand call
PathToken.intern()on them, which is also what populates theregistry in the first place.
Testing
test/orm/test_pickled.py, clearingPathToken._internto stand in for afresh interpreter:
PathRegistry.serialize()/deserialize()over thecombinations of
column/relationship×*/_sa_default× root level/ mapper level token;
load_only()and withraiseload("*"), asserting the reconstructed loader paths.Affects 2.0 and 2.1.