-
Notifications
You must be signed in to change notification settings - Fork 14.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
chore(dao/command): Add transaction decorator to try to enforce "unit of work" #24969
chore(dao/command): Add transaction decorator to try to enforce "unit of work" #24969
Conversation
superset/daos/chart.py
Outdated
db.session.commit() | ||
except SQLAlchemyError as ex: | ||
db.session.rollback() | ||
raise ex |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We're really inconsistent with our error handling. The BaseDAO.delete
method wraps all SQLAlchemyError
errors as DAODeleteFailedError
whereas here they are left as is.
de2c324
to
37f0b24
Compare
37f0b24
to
4e51d4d
Compare
4e51d4d
to
b5c2e81
Compare
7b201dd
to
ef7bcd1
Compare
f03d7b0
to
10dc755
Compare
10dc755
to
092aa45
Compare
82de210
to
7b651ef
Compare
5c2be4e
to
03a1c64
Compare
03a1c64
to
fcfdf63
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you for all the hard work here @john-bodley. Even though we were not able to fully implement SIP-99B, this PR is a step in the right direction and removes a lot of unnecessary code. I left some first-pass comments:
|
||
try: | ||
result = func(*args, **kwargs) | ||
db.session.commit() # pylint: disable=consider-using-transaction |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because we were not able to use begin_nested
here, do you see any point where previously we had only a flush
that could be potentially rollbacked and now we have a @transaction
which will effectively commit? Something like:
Previously:
CommandA:
try:
do_something()
CommandB()
commit()
except Exception:
rollback()
CommandB:
do_something()
flush()
Now:
@transaction
CommandA:
do_something()
CommandB()
@transaction
CommandB:
do_something()
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@michael-s-molina given that these @transaction
decorators are defined at the "unit of work" level I think we're ok, i.e., I'm not sure where we ever had nested commands where one never committed and the outer explicitly rolled back.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think for now we should consider commands as the unit of work, meaning we should assume they always commit at the end. If this is not the case we should probably introduce a sort-of notion of a sub-command, that doesn't commit. But let's leave that for a follow-up.
superset/commands/database/update.py
Outdated
self._properties, | ||
commit=False, | ||
) | ||
database = DatabaseDAO.update(self._model, self._properties) | ||
database.set_sqlalchemy_uri(database.sqlalchemy_uri) | ||
ssh_tunnel = self._handle_ssh_tunnel(database) | ||
self._refresh_catalogs(database, original_database_name, ssh_tunnel) | ||
except SSHTunnelError: # pylint: disable=try-except-raise |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe in this case you don't need the try/catch
as there's no event logging or anything in the catch
block.
fcfdf63
to
06ae67c
Compare
06ae67c
to
51cb1f6
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a HUGE step in the right direction, and finally introduces a coherent pattern for dealing with complex ORM handling during the request lifecycle. Given that this fundamentally changes how the backend operates, I fear there may be significant risk for regressions here. However, those should be easy to fix now that we have consistent flushing, committing and rollbacking. If nothing else, these potential regrssions will highlight critical gaps in our test coverage. Therefore, I feel the benefits of this change far outweigh the intermediate regression risks it introduces.
@@ -240,6 +240,7 @@ ignore_basepython_conflict = true | |||
commands = | |||
superset db upgrade | |||
superset init | |||
superset load-test-users |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Random observation that's not directly related to this PR: I've always felt it's weird that the core application has functionality for loading test users. I feel at some point we should break that out into the test suite.
@@ -29,7 +31,6 @@ def cleanup_permissions() -> None: | |||
for pvm in pvms: | |||
pvms_dict[(pvm.permission, pvm.view_menu)].append(pvm) | |||
duplicates = [v for v in pvms_dict.values() if len(v) > 1] | |||
len(duplicates) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What on earth was this? 🤔
|
||
try: | ||
result = func(*args, **kwargs) | ||
db.session.commit() # pylint: disable=consider-using-transaction |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think for now we should consider commands as the unit of work, meaning we should assume they always commit at the end. If this is not the case we should probably introduce a sort-of notion of a sub-command, that doesn't commit. But let's leave that for a follow-up.
@@ -1211,6 +1211,9 @@ def test_chart_data_cache_no_login(self, cache_loader): | |||
""" | |||
Chart data cache API: Test chart data async cache request (no login) | |||
""" | |||
if get_example_database().backend == "presto": |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
unrelated comment: at some point we should replace Presto with Trino, as that's really where the broader community is at right now..
@@ -592,7 +592,6 @@ def test_import_v1_dashboard_multiple(self, mock_g): | |||
} | |||
command = v1.ImportDashboardsCommand(contents, overwrite=True) | |||
command.run() | |||
command.run() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nice
if entry is None or entry.is_expired(): | ||
return None | ||
|
||
return JsonKeyValueCodec().decode(entry.value) | ||
|
||
|
||
def _get_other_session() -> Session: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I disagree with this change (I may not have been able to accurately communicate why this is needed). But no worries, I will address this in #29344 after this PR lands and try to document the logic better.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you @john-bodley for addressing the comments. I agree with @villebro that the benefits greatly outweigh the risks here.
SUMMARY
This is a PR I've had on the back-burner for many months, but have struggled with on numerous occasions—often in part due to the flakey/delicate tests (and their associated frameworks). The initial desire was to fulfill the approach outlined in [SIP-99B] Proposal for (re)defining a "unit of work", but alas I failed, in part due to the challenges trying to untangle Superset logic which inherently is not overly conducive to adhering to the construct that a command should serve as a "unit of work".
Why is that? It's complicated, but asynchronous logic does not help given that a Celery task running within the confines of another command needs to read a previously committed state given the
READ COMMITTED
isolation level. Issues like this could likely be overcome by having two commands—prepare and execute—as opposed to a single execute command.The TL;DR is this PR should likely be interpreted as the first phase of SIP-99B. The general framework holds, i.e., DAOs no longer commit and a
transaction
decorator is used to wrap any command which perform either anINSERT
,UPDATE
, orDELETE
.Finally, I apologize for the size of the PR. I struggled to downside the footprint, but once you start enforcing that DAOs should not commit, then the files which touched begins to snowball.
Regrettably my time (for now) working on Apache Superset is likely drawing to a close, so for completeness I thought there was merit in sharing the incremental diff for what I was hoping to achieve in case @michael-s-molina @villebro et al. wanted to carry the baton on.
BEFORE/AFTER SCREENSHOTS OR ANIMATED GIF
TESTING INSTRUCTIONS
CI.
ADDITIONAL INFORMATION