Skip to content

Commit 1367851

Browse files
authored
Merge pull request #31 from febus982/docs_lifecycle
Documentation about components life cycle and concurrency
2 parents 9b95abd + 4c1ca8f commit 1367851

File tree

6 files changed

+163
-0
lines changed

6 files changed

+163
-0
lines changed

docs/lifecycle.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
## When should you create each component?
2+
3+
### Bind manager
4+
5+
The `SQLAlchemyBindManager` object holds all the SQLAlchemy Engines, which
6+
are supposed to be global objects. Therefore it should be created on application
7+
startup and be globally accessible.
8+
9+
From SQLAlchemy documentation:
10+
11+
> The Engine is intended to normally be a permanent fixture established up-front
12+
> and maintained throughout the lifespan of an application.
13+
14+
### Repositories
15+
16+
[SQLAlchemy documentation](https://docs.sqlalchemy.org/en/20/orm/session_basics.html#when-do-i-construct-a-session-when-do-i-commit-it-and-when-do-i-close-it)
17+
recommends we create `Session` object at the beginning of a logical operation where
18+
database access is potentially anticipated.
19+
20+
The repository starts a `Session` for each _operation_, in order to maintain isolation.
21+
This means you can create a repository object almost whenever you want.
22+
23+
/// details | There two exceptions: creating repositories in global variables or concurrent asyncio tasks
24+
type: warning
25+
The repository creates and maintain the lifecycle of a session object to avoid
26+
emitting unnecessary queries to refresh the model state on new session.
27+
28+
The session is not thread safe, therefore the repository is not thread safe as well.
29+
30+
Check the [Notes on multithreaded applications](/manager/session/#note-on-multithreaded-applications)
31+
32+
The `AsyncSession` [is not safe on concurrent asyncio tasks](https://docs.sqlalchemy.org/en/20/orm/session_basics.html#is-the-session-thread-safe-is-asyncsession-safe-to-share-in-concurrent-tasks),
33+
therefore the same repository instance can't be used in multiple asyncio tasks like
34+
when using `asyncio.gather()`
35+
///
36+
37+
Even using multiple repository instances will work fine, however as they will have completely
38+
different sessions, it's likely that the second repository will fire additional SELECT queries
39+
to get the state of the object prior to saving it.
40+
41+
/// details | Example
42+
```python
43+
from sqlalchemy import String
44+
from sqlalchemy.orm import Mapped, mapped_column
45+
from sqlalchemy_bind_manager import SQLAlchemyBindManager ,SQLAlchemyConfig
46+
from sqlalchemy_bind_manager.repository import SQLAlchemyRepository
47+
48+
config = SQLAlchemyConfig(
49+
engine_url="sqlite:///./sqlite.db",
50+
engine_options=dict(connect_args={"check_same_thread": False}, echo=True),
51+
session_options=dict(expire_on_commit=False),
52+
)
53+
54+
sa_manager = SQLAlchemyBindManager(config={})
55+
56+
class MyModel(sa_manager.get_bind().model_declarative_base):
57+
id: Mapped[int] = mapped_column(primary_key=True)
58+
name: Mapped[str] = mapped_column(String(30))
59+
60+
def update_my_model():
61+
# Create 2 instances of the same repository
62+
repo = SQLAlchemyRepository(sa_manager.get_bind(), model_class=MyModel)
63+
repo2 = SQLAlchemyRepository(sa_manager.get_bind(), model_class=MyModel)
64+
65+
o = repo.get(1)
66+
o.name = "John"
67+
68+
repo2.save(o)
69+
70+
update_my_model()
71+
```
72+
///
73+
74+
The recommendation is of course to use the same repository instance, where possible,
75+
and structure your code in a way to match the single repository instance approach.
76+
77+
For example a strategy similar to this would be optimal, if possible:
78+
79+
* Create repositories
80+
* Retrieve all the models you need
81+
* Do the changes you need, as per business logic
82+
* Save all the changed models as needed
83+
84+
/// tip | Using multiple repository instances is the only way to safely use concurrent asyncio tasks
85+
86+
///
87+
88+
### Unit of work
89+
90+
The Unit of Work session management follows the **same exact rules as the repository**,
91+
therefore you should approach the creation af a `UnitOfWork` object in the same way.

docs/repository/repository.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,16 @@ using either approach:
9494

9595
Note that `AsyncSession` has [the same limitation on lazy loading](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html#asyncio-orm-avoid-lazyloads),
9696
even when keeping the session opened, so it makes sense that the two Repository implementations behave consistently.
97+
98+
/// details | Lazy loading using `AsyncAttrs`
99+
100+
SQLAlchemy has recently added the `AsyncAttrs` model class mixin to allow lazy loading model attributes
101+
with `AsyncSession`, however having to `await` a model property introduce a coupling between the
102+
application logic and the storage layer.
103+
104+
This would mean the application logic has to know about the storage layer and make a distinction
105+
between sync and async models. This doesn't feel right, at least for now,
106+
therefore it's not enabled by default.
107+
108+
If you want to attempt lazy loading refer to [SQLAlchemy documentation](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html#synopsis-orm)
109+
///

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ nav:
4747
- Repository:
4848
- Repository usage: repository/repository.md
4949
- Unit of work: repository/uow.md
50+
- Components life cycle: lifecycle.md
5051

5152
markdown_extensions:
5253
- pymdownx.details
File renamed without changes.

tests/repository/async_/test_operation_isolation.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,35 @@ async def test_update_model_doesnt_update_other_models_from_same_repo(
5858
assert updated_model2.name == "SomeoneElse"
5959

6060

61+
async def test_update_model_updates_models_retrieved_by_other_repos(
62+
repository_class, model_class, sa_manager
63+
):
64+
repo = repository_class(sa_manager.get_bind())
65+
repo2 = repository_class(sa_manager.get_bind())
66+
67+
# Populate a database entry to be used for tests
68+
model1 = model_class(
69+
name="Someone",
70+
)
71+
await repo.save(model1)
72+
assert model1.model_id is not None
73+
74+
# Retrieve the models
75+
new_model1 = await repo.get(model1.model_id)
76+
77+
# Update the model with another repository instance
78+
new_model1.name = "StillSomeoneElse"
79+
await repo2.save(new_model1)
80+
81+
# Check model1 has been updated
82+
updated_model1 = await repo2.get(model1.model_id)
83+
assert updated_model1.name == "StillSomeoneElse"
84+
85+
# Check model1 has been updated
86+
updated_model1b = await repo.get(model1.model_id)
87+
assert updated_model1b.name == "StillSomeoneElse"
88+
89+
6190
@patch.object(AsyncSessionHandler, "commit", return_value=None, new_callable=AsyncMock)
6291
async def test_commit_triggers_once_per_operation_using_internal_uow(
6392
mocked_uow_commit: AsyncMock, repository_class, model_class, sa_manager

tests/repository/sync/test_operation_isolation.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,35 @@ def test_update_model_doesnt_update_other_models_from_same_repo(
5858
assert updated_model2.name == "SomeoneElse"
5959

6060

61+
def test_update_model_updates_models_retrieved_by_other_repos(
62+
repository_class, model_class, sa_manager
63+
):
64+
repo = repository_class(sa_manager.get_bind())
65+
repo2 = repository_class(sa_manager.get_bind())
66+
67+
# Populate a database entry to be used for tests
68+
model1 = model_class(
69+
name="Someone",
70+
)
71+
repo.save(model1)
72+
assert model1.model_id is not None
73+
74+
# Retrieve the models
75+
new_model1 = repo.get(model1.model_id)
76+
77+
# Update the model with another repository instance
78+
new_model1.name = "StillSomeoneElse"
79+
repo2.save(new_model1)
80+
81+
# Check model1 has been updated
82+
updated_model1 = repo2.get(model1.model_id)
83+
assert updated_model1.name == "StillSomeoneElse"
84+
85+
# Check model1 has been updated
86+
updated_model1b = repo.get(model1.model_id)
87+
assert updated_model1b.name == "StillSomeoneElse"
88+
89+
6190
@patch.object(SessionHandler, "commit", return_value=None)
6291
def test_commit_triggers_once_per_operation(
6392
mocked_uow_commit: MagicMock, repository_class, model_class, sa_manager

0 commit comments

Comments
 (0)