horizontal_shard.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. # ext/horizontal_shard.py
  2. # Copyright (C) 2005-2024 the SQLAlchemy authors and contributors
  3. # <see AUTHORS file>
  4. #
  5. # This module is part of SQLAlchemy and is released under
  6. # the MIT License: https://www.opensource.org/licenses/mit-license.php
  7. """Horizontal sharding support.
  8. Defines a rudimental 'horizontal sharding' system which allows a Session to
  9. distribute queries and persistence operations across multiple databases.
  10. For a usage example, see the :ref:`examples_sharding` example included in
  11. the source distribution.
  12. .. deepalchemy:: The horizontal sharding extension is an advanced feature,
  13. involving a complex statement -> database interaction as well as
  14. use of semi-public APIs for non-trivial cases. Simpler approaches to
  15. refering to multiple database "shards", most commonly using a distinct
  16. :class:`_orm.Session` per "shard", should always be considered first
  17. before using this more complex and less-production-tested system.
  18. """
  19. from __future__ import annotations
  20. from typing import Any
  21. from typing import Callable
  22. from typing import Dict
  23. from typing import Iterable
  24. from typing import Optional
  25. from typing import Tuple
  26. from typing import Type
  27. from typing import TYPE_CHECKING
  28. from typing import TypeVar
  29. from typing import Union
  30. from .. import event
  31. from .. import exc
  32. from .. import inspect
  33. from .. import util
  34. from ..orm import PassiveFlag
  35. from ..orm._typing import OrmExecuteOptionsParameter
  36. from ..orm.interfaces import ORMOption
  37. from ..orm.mapper import Mapper
  38. from ..orm.query import Query
  39. from ..orm.session import _BindArguments
  40. from ..orm.session import _PKIdentityArgument
  41. from ..orm.session import Session
  42. from ..util.typing import Protocol
  43. from ..util.typing import Self
  44. if TYPE_CHECKING:
  45. from ..engine.base import Connection
  46. from ..engine.base import Engine
  47. from ..engine.base import OptionEngine
  48. from ..engine.result import IteratorResult
  49. from ..engine.result import Result
  50. from ..orm import LoaderCallableStatus
  51. from ..orm._typing import _O
  52. from ..orm.bulk_persistence import BulkUDCompileState
  53. from ..orm.context import QueryContext
  54. from ..orm.session import _EntityBindKey
  55. from ..orm.session import _SessionBind
  56. from ..orm.session import ORMExecuteState
  57. from ..orm.state import InstanceState
  58. from ..sql import Executable
  59. from ..sql._typing import _TP
  60. from ..sql.elements import ClauseElement
  61. __all__ = ["ShardedSession", "ShardedQuery"]
  62. _T = TypeVar("_T", bound=Any)
  63. ShardIdentifier = str
  64. class ShardChooser(Protocol):
  65. def __call__(
  66. self,
  67. mapper: Optional[Mapper[_T]],
  68. instance: Any,
  69. clause: Optional[ClauseElement],
  70. ) -> Any:
  71. ...
  72. class IdentityChooser(Protocol):
  73. def __call__(
  74. self,
  75. mapper: Mapper[_T],
  76. primary_key: _PKIdentityArgument,
  77. *,
  78. lazy_loaded_from: Optional[InstanceState[Any]],
  79. execution_options: OrmExecuteOptionsParameter,
  80. bind_arguments: _BindArguments,
  81. **kw: Any,
  82. ) -> Any:
  83. ...
  84. class ShardedQuery(Query[_T]):
  85. """Query class used with :class:`.ShardedSession`.
  86. .. legacy:: The :class:`.ShardedQuery` is a subclass of the legacy
  87. :class:`.Query` class. The :class:`.ShardedSession` now supports
  88. 2.0 style execution via the :meth:`.ShardedSession.execute` method.
  89. """
  90. def __init__(self, *args: Any, **kwargs: Any) -> None:
  91. super().__init__(*args, **kwargs)
  92. assert isinstance(self.session, ShardedSession)
  93. self.identity_chooser = self.session.identity_chooser
  94. self.execute_chooser = self.session.execute_chooser
  95. self._shard_id = None
  96. def set_shard(self, shard_id: ShardIdentifier) -> Self:
  97. """Return a new query, limited to a single shard ID.
  98. All subsequent operations with the returned query will
  99. be against the single shard regardless of other state.
  100. The shard_id can be passed for a 2.0 style execution to the
  101. bind_arguments dictionary of :meth:`.Session.execute`::
  102. results = session.execute(
  103. stmt,
  104. bind_arguments={"shard_id": "my_shard"}
  105. )
  106. """
  107. return self.execution_options(_sa_shard_id=shard_id)
  108. class ShardedSession(Session):
  109. shard_chooser: ShardChooser
  110. identity_chooser: IdentityChooser
  111. execute_chooser: Callable[[ORMExecuteState], Iterable[Any]]
  112. def __init__(
  113. self,
  114. shard_chooser: ShardChooser,
  115. identity_chooser: Optional[IdentityChooser] = None,
  116. execute_chooser: Optional[
  117. Callable[[ORMExecuteState], Iterable[Any]]
  118. ] = None,
  119. shards: Optional[Dict[str, Any]] = None,
  120. query_cls: Type[Query[_T]] = ShardedQuery,
  121. *,
  122. id_chooser: Optional[
  123. Callable[[Query[_T], Iterable[_T]], Iterable[Any]]
  124. ] = None,
  125. query_chooser: Optional[Callable[[Executable], Iterable[Any]]] = None,
  126. **kwargs: Any,
  127. ) -> None:
  128. """Construct a ShardedSession.
  129. :param shard_chooser: A callable which, passed a Mapper, a mapped
  130. instance, and possibly a SQL clause, returns a shard ID. This id
  131. may be based off of the attributes present within the object, or on
  132. some round-robin scheme. If the scheme is based on a selection, it
  133. should set whatever state on the instance to mark it in the future as
  134. participating in that shard.
  135. :param identity_chooser: A callable, passed a Mapper and primary key
  136. argument, which should return a list of shard ids where this
  137. primary key might reside.
  138. .. versionchanged:: 2.0 The ``identity_chooser`` parameter
  139. supersedes the ``id_chooser`` parameter.
  140. :param execute_chooser: For a given :class:`.ORMExecuteState`,
  141. returns the list of shard_ids
  142. where the query should be issued. Results from all shards returned
  143. will be combined together into a single listing.
  144. .. versionchanged:: 1.4 The ``execute_chooser`` parameter
  145. supersedes the ``query_chooser`` parameter.
  146. :param shards: A dictionary of string shard names
  147. to :class:`~sqlalchemy.engine.Engine` objects.
  148. """
  149. super().__init__(query_cls=query_cls, **kwargs)
  150. event.listen(
  151. self, "do_orm_execute", execute_and_instances, retval=True
  152. )
  153. self.shard_chooser = shard_chooser
  154. if id_chooser:
  155. _id_chooser = id_chooser
  156. util.warn_deprecated(
  157. "The ``id_chooser`` parameter is deprecated; "
  158. "please use ``identity_chooser``.",
  159. "2.0",
  160. )
  161. def _legacy_identity_chooser(
  162. mapper: Mapper[_T],
  163. primary_key: _PKIdentityArgument,
  164. *,
  165. lazy_loaded_from: Optional[InstanceState[Any]],
  166. execution_options: OrmExecuteOptionsParameter,
  167. bind_arguments: _BindArguments,
  168. **kw: Any,
  169. ) -> Any:
  170. q = self.query(mapper)
  171. if lazy_loaded_from:
  172. q = q._set_lazyload_from(lazy_loaded_from)
  173. return _id_chooser(q, primary_key)
  174. self.identity_chooser = _legacy_identity_chooser
  175. elif identity_chooser:
  176. self.identity_chooser = identity_chooser
  177. else:
  178. raise exc.ArgumentError(
  179. "identity_chooser or id_chooser is required"
  180. )
  181. if query_chooser:
  182. _query_chooser = query_chooser
  183. util.warn_deprecated(
  184. "The ``query_chooser`` parameter is deprecated; "
  185. "please use ``execute_chooser``.",
  186. "1.4",
  187. )
  188. if execute_chooser:
  189. raise exc.ArgumentError(
  190. "Can't pass query_chooser and execute_chooser "
  191. "at the same time."
  192. )
  193. def _default_execute_chooser(
  194. orm_context: ORMExecuteState,
  195. ) -> Iterable[Any]:
  196. return _query_chooser(orm_context.statement)
  197. if execute_chooser is None:
  198. execute_chooser = _default_execute_chooser
  199. if execute_chooser is None:
  200. raise exc.ArgumentError(
  201. "execute_chooser or query_chooser is required"
  202. )
  203. self.execute_chooser = execute_chooser
  204. self.__shards: Dict[ShardIdentifier, _SessionBind] = {}
  205. if shards is not None:
  206. for k in shards:
  207. self.bind_shard(k, shards[k])
  208. def _identity_lookup(
  209. self,
  210. mapper: Mapper[_O],
  211. primary_key_identity: Union[Any, Tuple[Any, ...]],
  212. identity_token: Optional[Any] = None,
  213. passive: PassiveFlag = PassiveFlag.PASSIVE_OFF,
  214. lazy_loaded_from: Optional[InstanceState[Any]] = None,
  215. execution_options: OrmExecuteOptionsParameter = util.EMPTY_DICT,
  216. bind_arguments: Optional[_BindArguments] = None,
  217. **kw: Any,
  218. ) -> Union[Optional[_O], LoaderCallableStatus]:
  219. """override the default :meth:`.Session._identity_lookup` method so
  220. that we search for a given non-token primary key identity across all
  221. possible identity tokens (e.g. shard ids).
  222. .. versionchanged:: 1.4 Moved :meth:`.Session._identity_lookup` from
  223. the :class:`_query.Query` object to the :class:`.Session`.
  224. """
  225. if identity_token is not None:
  226. obj = super()._identity_lookup(
  227. mapper,
  228. primary_key_identity,
  229. identity_token=identity_token,
  230. **kw,
  231. )
  232. return obj
  233. else:
  234. for shard_id in self.identity_chooser(
  235. mapper,
  236. primary_key_identity,
  237. lazy_loaded_from=lazy_loaded_from,
  238. execution_options=execution_options,
  239. bind_arguments=dict(bind_arguments) if bind_arguments else {},
  240. ):
  241. obj2 = super()._identity_lookup(
  242. mapper,
  243. primary_key_identity,
  244. identity_token=shard_id,
  245. lazy_loaded_from=lazy_loaded_from,
  246. **kw,
  247. )
  248. if obj2 is not None:
  249. return obj2
  250. return None
  251. def _choose_shard_and_assign(
  252. self,
  253. mapper: Optional[_EntityBindKey[_O]],
  254. instance: Any,
  255. **kw: Any,
  256. ) -> Any:
  257. if instance is not None:
  258. state = inspect(instance)
  259. if state.key:
  260. token = state.key[2]
  261. assert token is not None
  262. return token
  263. elif state.identity_token:
  264. return state.identity_token
  265. assert isinstance(mapper, Mapper)
  266. shard_id = self.shard_chooser(mapper, instance, **kw)
  267. if instance is not None:
  268. state.identity_token = shard_id
  269. return shard_id
  270. def connection_callable( # type: ignore [override]
  271. self,
  272. mapper: Optional[Mapper[_T]] = None,
  273. instance: Optional[Any] = None,
  274. shard_id: Optional[ShardIdentifier] = None,
  275. **kw: Any,
  276. ) -> Connection:
  277. """Provide a :class:`_engine.Connection` to use in the unit of work
  278. flush process.
  279. """
  280. if shard_id is None:
  281. shard_id = self._choose_shard_and_assign(mapper, instance)
  282. if self.in_transaction():
  283. trans = self.get_transaction()
  284. assert trans is not None
  285. return trans.connection(mapper, shard_id=shard_id)
  286. else:
  287. bind = self.get_bind(
  288. mapper=mapper, shard_id=shard_id, instance=instance
  289. )
  290. if isinstance(bind, Engine):
  291. return bind.connect(**kw)
  292. else:
  293. assert isinstance(bind, Connection)
  294. return bind
  295. def get_bind(
  296. self,
  297. mapper: Optional[_EntityBindKey[_O]] = None,
  298. *,
  299. shard_id: Optional[ShardIdentifier] = None,
  300. instance: Optional[Any] = None,
  301. clause: Optional[ClauseElement] = None,
  302. **kw: Any,
  303. ) -> _SessionBind:
  304. if shard_id is None:
  305. shard_id = self._choose_shard_and_assign(
  306. mapper, instance=instance, clause=clause
  307. )
  308. assert shard_id is not None
  309. return self.__shards[shard_id]
  310. def bind_shard(
  311. self, shard_id: ShardIdentifier, bind: Union[Engine, OptionEngine]
  312. ) -> None:
  313. self.__shards[shard_id] = bind
  314. class set_shard_id(ORMOption):
  315. """a loader option for statements to apply a specific shard id to the
  316. primary query as well as for additional relationship and column
  317. loaders.
  318. The :class:`_horizontal.set_shard_id` option may be applied using
  319. the :meth:`_sql.Executable.options` method of any executable statement::
  320. stmt = (
  321. select(MyObject).
  322. where(MyObject.name == 'some name').
  323. options(set_shard_id("shard1"))
  324. )
  325. Above, the statement when invoked will limit to the "shard1" shard
  326. identifier for the primary query as well as for all relationship and
  327. column loading strategies, including eager loaders such as
  328. :func:`_orm.selectinload`, deferred column loaders like :func:`_orm.defer`,
  329. and the lazy relationship loader :func:`_orm.lazyload`.
  330. In this way, the :class:`_horizontal.set_shard_id` option has much wider
  331. scope than using the "shard_id" argument within the
  332. :paramref:`_orm.Session.execute.bind_arguments` dictionary.
  333. .. versionadded:: 2.0.0
  334. """
  335. __slots__ = ("shard_id", "propagate_to_loaders")
  336. def __init__(
  337. self, shard_id: ShardIdentifier, propagate_to_loaders: bool = True
  338. ):
  339. """Construct a :class:`_horizontal.set_shard_id` option.
  340. :param shard_id: shard identifier
  341. :param propagate_to_loaders: if left at its default of ``True``, the
  342. shard option will take place for lazy loaders such as
  343. :func:`_orm.lazyload` and :func:`_orm.defer`; if False, the option
  344. will not be propagated to loaded objects. Note that :func:`_orm.defer`
  345. always limits to the shard_id of the parent row in any case, so the
  346. parameter only has a net effect on the behavior of the
  347. :func:`_orm.lazyload` strategy.
  348. """
  349. self.shard_id = shard_id
  350. self.propagate_to_loaders = propagate_to_loaders
  351. def execute_and_instances(
  352. orm_context: ORMExecuteState,
  353. ) -> Union[Result[_T], IteratorResult[_TP]]:
  354. active_options: Union[
  355. None,
  356. QueryContext.default_load_options,
  357. Type[QueryContext.default_load_options],
  358. BulkUDCompileState.default_update_options,
  359. Type[BulkUDCompileState.default_update_options],
  360. ]
  361. if orm_context.is_select:
  362. active_options = orm_context.load_options
  363. elif orm_context.is_update or orm_context.is_delete:
  364. active_options = orm_context.update_delete_options
  365. else:
  366. active_options = None
  367. session = orm_context.session
  368. assert isinstance(session, ShardedSession)
  369. def iter_for_shard(
  370. shard_id: ShardIdentifier,
  371. ) -> Union[Result[_T], IteratorResult[_TP]]:
  372. bind_arguments = dict(orm_context.bind_arguments)
  373. bind_arguments["shard_id"] = shard_id
  374. orm_context.update_execution_options(identity_token=shard_id)
  375. return orm_context.invoke_statement(bind_arguments=bind_arguments)
  376. for orm_opt in orm_context._non_compile_orm_options:
  377. # TODO: if we had an ORMOption that gets applied at ORM statement
  378. # execution time, that would allow this to be more generalized.
  379. # for now just iterate and look for our options
  380. if isinstance(orm_opt, set_shard_id):
  381. shard_id = orm_opt.shard_id
  382. break
  383. else:
  384. if active_options and active_options._identity_token is not None:
  385. shard_id = active_options._identity_token
  386. elif "_sa_shard_id" in orm_context.execution_options:
  387. shard_id = orm_context.execution_options["_sa_shard_id"]
  388. elif "shard_id" in orm_context.bind_arguments:
  389. shard_id = orm_context.bind_arguments["shard_id"]
  390. else:
  391. shard_id = None
  392. if shard_id is not None:
  393. return iter_for_shard(shard_id)
  394. else:
  395. partial = []
  396. for shard_id in session.execute_chooser(orm_context):
  397. result_ = iter_for_shard(shard_id)
  398. partial.append(result_)
  399. return partial[0].merge(*partial[1:])