mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 22:12:21 -04:00
commit immediately when there's no active checkpoint
Reviews and operations on the backend that support undoing can now be committed immediately, so they will not be lost in the event of a crash. This required tweaks to a few places: - don't set collection mtime on save() unless changes were made in Python, as otherwise we end up accidentally clearing the backend undo queue - autosave() is now run on every reset() - garbage collection now runs in a timer, instead of relying on autosave() to be run periodically
This commit is contained in:
parent
40aff4447a
commit
57d7e3e2ab
10 changed files with 79 additions and 43 deletions
|
@ -90,9 +90,6 @@ class Collection:
|
||||||
self.path = os.path.abspath(path)
|
self.path = os.path.abspath(path)
|
||||||
self.reopen()
|
self.reopen()
|
||||||
|
|
||||||
self.log(self.path, anki.version)
|
|
||||||
self._lastSave = time.time()
|
|
||||||
self._undo: _UndoInfo = None
|
|
||||||
self.media = MediaManager(self, server)
|
self.media = MediaManager(self, server)
|
||||||
self.models = ModelManager(self)
|
self.models = ModelManager(self)
|
||||||
self.decks = DeckManager(self)
|
self.decks = DeckManager(self)
|
||||||
|
@ -230,15 +227,18 @@ class Collection:
|
||||||
# to check if the backend updated the modification time.
|
# to check if the backend updated the modification time.
|
||||||
return self.db.last_begin_at != self.mod
|
return self.db.last_begin_at != self.mod
|
||||||
|
|
||||||
def save(
|
def save(self, name: Optional[str] = None, trx: bool = True) -> None:
|
||||||
self, name: Optional[str] = None, mod: Optional[int] = None, trx: bool = True
|
|
||||||
) -> None:
|
|
||||||
"Flush, commit DB, and take out another write lock if trx=True."
|
"Flush, commit DB, and take out another write lock if trx=True."
|
||||||
# commit needed?
|
# commit needed?
|
||||||
if self.db.mod or self.modified_after_begin():
|
if self.db.modified_in_python or self.modified_after_begin():
|
||||||
self.mod = intTime(1000) if mod is None else mod
|
if self.db.modified_in_python:
|
||||||
|
self.db.execute("update col set mod = ?", intTime(1000))
|
||||||
|
self.db.modified_in_python = False
|
||||||
|
else:
|
||||||
|
# modifications made by the backend will have already bumped
|
||||||
|
# mtime
|
||||||
|
pass
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.mod = False
|
|
||||||
if trx:
|
if trx:
|
||||||
self.db.begin()
|
self.db.begin()
|
||||||
elif not trx:
|
elif not trx:
|
||||||
|
@ -247,14 +247,15 @@ class Collection:
|
||||||
self.db.rollback()
|
self.db.rollback()
|
||||||
|
|
||||||
self._save_checkpoint(name)
|
self._save_checkpoint(name)
|
||||||
self._lastSave = time.time()
|
|
||||||
|
|
||||||
def autosave(self) -> Optional[bool]:
|
def autosave(self) -> None:
|
||||||
"Save if 5 minutes has passed since last save. True if saved."
|
"""Save any pending changes.
|
||||||
if time.time() - self._lastSave > 300:
|
If a checkpoint was taken in the last 5 minutes, don't save."""
|
||||||
|
if not self._have_outstanding_checkpoint():
|
||||||
|
# if there's no active checkpoint, we can save immediately
|
||||||
|
self.save()
|
||||||
|
elif time.time() - self._last_checkpoint_at > 300:
|
||||||
self.save()
|
self.save()
|
||||||
return True
|
|
||||||
return None
|
|
||||||
|
|
||||||
def close(self, save: bool = True, downgrade: bool = False) -> None:
|
def close(self, save: bool = True, downgrade: bool = False) -> None:
|
||||||
"Disconnect from DB."
|
"Disconnect from DB."
|
||||||
|
@ -290,6 +291,9 @@ class Collection:
|
||||||
assert not self.db
|
assert not self.db
|
||||||
assert self.path.endswith(".anki2")
|
assert self.path.endswith(".anki2")
|
||||||
|
|
||||||
|
self._last_checkpoint_at = time.time()
|
||||||
|
self._undo: _UndoInfo = None
|
||||||
|
|
||||||
(media_dir, media_db) = media_paths_from_col_path(self.path)
|
(media_dir, media_db) = media_paths_from_col_path(self.path)
|
||||||
|
|
||||||
log_path = ""
|
log_path = ""
|
||||||
|
@ -355,6 +359,7 @@ class Collection:
|
||||||
|
|
||||||
def reset(self) -> None:
|
def reset(self) -> None:
|
||||||
"Rebuild the queue and reload data after DB modified."
|
"Rebuild the queue and reload data after DB modified."
|
||||||
|
self.autosave()
|
||||||
self.sched.reset()
|
self.sched.reset()
|
||||||
|
|
||||||
# Deletion logging
|
# Deletion logging
|
||||||
|
@ -752,17 +757,16 @@ table.review-log {{ {revlog_style} }}
|
||||||
def undo_status(self) -> UndoStatus:
|
def undo_status(self) -> UndoStatus:
|
||||||
"Return the undo status. At the moment, redo is not supported."
|
"Return the undo status. At the moment, redo is not supported."
|
||||||
# check backend first
|
# check backend first
|
||||||
status = self._backend.get_undo_status()
|
if status := self._check_backend_undo_status():
|
||||||
if status.undo or status.redo:
|
|
||||||
return status
|
return status
|
||||||
|
|
||||||
if not self._undo:
|
if not self._undo:
|
||||||
return status
|
return UndoStatus()
|
||||||
|
|
||||||
if isinstance(self._undo, _ReviewsUndo):
|
if isinstance(self._undo, _ReviewsUndo):
|
||||||
status.undo = self.tr(TR.SCHEDULING_REVIEW)
|
return UndoStatus(undo=self.tr(TR.SCHEDULING_REVIEW))
|
||||||
elif isinstance(self._undo, Checkpoint):
|
elif isinstance(self._undo, Checkpoint):
|
||||||
status.undo = self._undo.name
|
return UndoStatus(undo=self._undo.name)
|
||||||
else:
|
else:
|
||||||
assert_exhaustive(self._undo)
|
assert_exhaustive(self._undo)
|
||||||
assert False
|
assert False
|
||||||
|
@ -796,6 +800,16 @@ table.review-log {{ {revlog_style} }}
|
||||||
assert_exhaustive(self._undo)
|
assert_exhaustive(self._undo)
|
||||||
assert False
|
assert False
|
||||||
|
|
||||||
|
def _check_backend_undo_status(self) -> Optional[UndoStatus]:
|
||||||
|
"""Return undo status if undo available on backend.
|
||||||
|
If backend has undo available, clear the Python undo state."""
|
||||||
|
status = self._backend.get_undo_status()
|
||||||
|
if status.undo or status.redo:
|
||||||
|
self.clear_python_undo()
|
||||||
|
return status
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
def save_card_review_undo_info(self, card: Card) -> None:
|
def save_card_review_undo_info(self, card: Card) -> None:
|
||||||
"Used by V1 and V2 schedulers to record state prior to review."
|
"Used by V1 and V2 schedulers to record state prior to review."
|
||||||
if not isinstance(self._undo, _ReviewsUndo):
|
if not isinstance(self._undo, _ReviewsUndo):
|
||||||
|
@ -805,6 +819,10 @@ table.review-log {{ {revlog_style} }}
|
||||||
entry = ReviewUndo(card=copy.copy(card), was_leech=was_leech)
|
entry = ReviewUndo(card=copy.copy(card), was_leech=was_leech)
|
||||||
self._undo.entries.append(entry)
|
self._undo.entries.append(entry)
|
||||||
|
|
||||||
|
def _have_outstanding_checkpoint(self) -> bool:
|
||||||
|
self._check_backend_undo_status()
|
||||||
|
return isinstance(self._undo, Checkpoint)
|
||||||
|
|
||||||
def _undo_checkpoint(self) -> Checkpoint:
|
def _undo_checkpoint(self) -> Checkpoint:
|
||||||
assert isinstance(self._undo, Checkpoint)
|
assert isinstance(self._undo, Checkpoint)
|
||||||
self.rollback()
|
self.rollback()
|
||||||
|
@ -814,6 +832,7 @@ table.review-log {{ {revlog_style} }}
|
||||||
|
|
||||||
def _save_checkpoint(self, name: Optional[str]) -> None:
|
def _save_checkpoint(self, name: Optional[str]) -> None:
|
||||||
"Call via .save(). If name not provided, clear any existing checkpoint."
|
"Call via .save(). If name not provided, clear any existing checkpoint."
|
||||||
|
self._last_checkpoint_at = time.time()
|
||||||
if name:
|
if name:
|
||||||
self._undo = Checkpoint(name=name)
|
self._undo = Checkpoint(name=name)
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -24,7 +24,7 @@ class DBProxy:
|
||||||
|
|
||||||
def __init__(self, backend: anki._backend.RustBackend) -> None:
|
def __init__(self, backend: anki._backend.RustBackend) -> None:
|
||||||
self._backend = backend
|
self._backend = backend
|
||||||
self.mod = False
|
self.modified_in_python = False
|
||||||
self.last_begin_at = 0
|
self.last_begin_at = 0
|
||||||
|
|
||||||
# Transactions
|
# Transactions
|
||||||
|
@ -54,7 +54,7 @@ class DBProxy:
|
||||||
s = sql.strip().lower()
|
s = sql.strip().lower()
|
||||||
for stmt in "insert", "update", "delete":
|
for stmt in "insert", "update", "delete":
|
||||||
if s.startswith(stmt):
|
if s.startswith(stmt):
|
||||||
self.mod = True
|
self.modified_in_python = True
|
||||||
sql, args2 = emulate_named_args(sql, args, kwargs)
|
sql, args2 = emulate_named_args(sql, args, kwargs)
|
||||||
# fetch rows
|
# fetch rows
|
||||||
return self._backend.db_query(sql, args2, first_row_only)
|
return self._backend.db_query(sql, args2, first_row_only)
|
||||||
|
@ -92,7 +92,7 @@ class DBProxy:
|
||||||
################
|
################
|
||||||
|
|
||||||
def executemany(self, sql: str, args: Iterable[Sequence[ValueForDB]]) -> None:
|
def executemany(self, sql: str, args: Iterable[Sequence[ValueForDB]]) -> None:
|
||||||
self.mod = True
|
self.modified_in_python = True
|
||||||
if isinstance(args, list):
|
if isinstance(args, list):
|
||||||
list_args = args
|
list_args = args
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -22,7 +22,7 @@ def test_op():
|
||||||
# it should be listed as undoable
|
# it should be listed as undoable
|
||||||
assert col.undoName() == "studyopts"
|
assert col.undoName() == "studyopts"
|
||||||
# with about 5 minutes until it's clobbered
|
# with about 5 minutes until it's clobbered
|
||||||
assert time.time() - col._lastSave < 1
|
assert time.time() - col._last_checkpoint_at < 1
|
||||||
# undoing should restore the old value
|
# undoing should restore the old value
|
||||||
col.undo()
|
col.undo()
|
||||||
assert not col.undoName()
|
assert not col.undoName()
|
||||||
|
|
|
@ -28,7 +28,7 @@ class ClosableQDialog(QDialog):
|
||||||
def show(mw: aqt.AnkiQt) -> QDialog:
|
def show(mw: aqt.AnkiQt) -> QDialog:
|
||||||
dialog = ClosableQDialog(mw)
|
dialog = ClosableQDialog(mw)
|
||||||
disable_help_button(dialog)
|
disable_help_button(dialog)
|
||||||
mw.setupDialogGC(dialog)
|
mw.garbage_collect_on_dialog_finish(dialog)
|
||||||
abt = aqt.forms.about.Ui_About()
|
abt = aqt.forms.about.Ui_About()
|
||||||
abt.setupUi(dialog)
|
abt.setupUi(dialog)
|
||||||
|
|
||||||
|
|
|
@ -34,7 +34,7 @@ from aqt.utils import (
|
||||||
class AddCards(QDialog):
|
class AddCards(QDialog):
|
||||||
def __init__(self, mw: AnkiQt) -> None:
|
def __init__(self, mw: AnkiQt) -> None:
|
||||||
QDialog.__init__(self, None, Qt.Window)
|
QDialog.__init__(self, None, Qt.Window)
|
||||||
mw.setupDialogGC(self)
|
mw.garbage_collect_on_dialog_finish(self)
|
||||||
self.mw = mw
|
self.mw = mw
|
||||||
self.form = aqt.forms.addcards.Ui_Dialog()
|
self.form = aqt.forms.addcards.Ui_Dialog()
|
||||||
self.form.setupUi(self)
|
self.form.setupUi(self)
|
||||||
|
|
|
@ -575,7 +575,7 @@ class Browser(QMainWindow):
|
||||||
self.mw.maybeReset()
|
self.mw.maybeReset()
|
||||||
aqt.dialogs.markClosed("Browser")
|
aqt.dialogs.markClosed("Browser")
|
||||||
self._closeEventHasCleanedUp = True
|
self._closeEventHasCleanedUp = True
|
||||||
self.mw.gcWindow(self)
|
self.mw.deferred_delete_and_garbage_collect(self)
|
||||||
self.close()
|
self.close()
|
||||||
|
|
||||||
def closeWithCallback(self, onsuccess: Callable) -> None:
|
def closeWithCallback(self, onsuccess: Callable) -> None:
|
||||||
|
@ -1556,7 +1556,7 @@ where id in %s"""
|
||||||
|
|
||||||
def _onFindDupes(self) -> None:
|
def _onFindDupes(self) -> None:
|
||||||
d = QDialog(self)
|
d = QDialog(self)
|
||||||
self.mw.setupDialogGC(d)
|
self.mw.garbage_collect_on_dialog_finish(d)
|
||||||
frm = aqt.forms.finddupes.Ui_Dialog()
|
frm = aqt.forms.finddupes.Ui_Dialog()
|
||||||
frm.setupUi(d)
|
frm.setupUi(d)
|
||||||
restoreGeom(d, "findDupes")
|
restoreGeom(d, "findDupes")
|
||||||
|
|
|
@ -47,7 +47,7 @@ class CardLayout(QDialog):
|
||||||
fill_empty: bool = False,
|
fill_empty: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
QDialog.__init__(self, parent or mw, Qt.Window)
|
QDialog.__init__(self, parent or mw, Qt.Window)
|
||||||
mw.setupDialogGC(self)
|
mw.garbage_collect_on_dialog_finish(self)
|
||||||
self.mw = aqt.mw
|
self.mw = aqt.mw
|
||||||
self.note = note
|
self.note = note
|
||||||
self.ord = ord
|
self.ord = ord
|
||||||
|
|
|
@ -10,7 +10,7 @@ from aqt.utils import TR, disable_help_button, restoreGeom, saveGeom, tooltip, t
|
||||||
class EditCurrent(QDialog):
|
class EditCurrent(QDialog):
|
||||||
def __init__(self, mw: aqt.AnkiQt) -> None:
|
def __init__(self, mw: aqt.AnkiQt) -> None:
|
||||||
QDialog.__init__(self, None, Qt.Window)
|
QDialog.__init__(self, None, Qt.Window)
|
||||||
mw.setupDialogGC(self)
|
mw.garbage_collect_on_dialog_finish(self)
|
||||||
self.mw = mw
|
self.mw = mw
|
||||||
self.form = aqt.forms.editcurrent.Ui_Dialog()
|
self.form = aqt.forms.editcurrent.Ui_Dialog()
|
||||||
self.form.setupUi(self)
|
self.form.setupUi(self)
|
||||||
|
|
|
@ -148,7 +148,7 @@ class AnkiQt(QMainWindow):
|
||||||
def setupUI(self) -> None:
|
def setupUI(self) -> None:
|
||||||
self.col = None
|
self.col = None
|
||||||
self.setupCrashLog()
|
self.setupCrashLog()
|
||||||
self.disableGC()
|
self.disable_automatic_garbage_collection()
|
||||||
self.setupAppMsg()
|
self.setupAppMsg()
|
||||||
self.setupKeys()
|
self.setupKeys()
|
||||||
self.setupThreads()
|
self.setupThreads()
|
||||||
|
@ -1090,10 +1090,8 @@ title="%s" %s>%s</button>""" % (
|
||||||
self.maybeEnableUndo()
|
self.maybeEnableUndo()
|
||||||
|
|
||||||
def autosave(self) -> None:
|
def autosave(self) -> None:
|
||||||
saved = self.col.autosave()
|
self.col.autosave()
|
||||||
self.maybeEnableUndo()
|
self.maybeEnableUndo()
|
||||||
if saved:
|
|
||||||
self.doGC()
|
|
||||||
|
|
||||||
# Other menu operations
|
# Other menu operations
|
||||||
##########################################################################
|
##########################################################################
|
||||||
|
@ -1256,6 +1254,8 @@ title="%s" %s>%s</button>""" % (
|
||||||
self.progress.timer(10 * 60 * 1000, self.onRefreshTimer, True)
|
self.progress.timer(10 * 60 * 1000, self.onRefreshTimer, True)
|
||||||
# check media sync every 5 minutes
|
# check media sync every 5 minutes
|
||||||
self.progress.timer(5 * 60 * 1000, self.on_autosync_timer, True)
|
self.progress.timer(5 * 60 * 1000, self.on_autosync_timer, True)
|
||||||
|
# periodic garbage collection
|
||||||
|
self.progress.timer(15 * 60 * 1000, self.garbage_collect_now, False)
|
||||||
# ensure Python interpreter runs at least once per second, so that
|
# ensure Python interpreter runs at least once per second, so that
|
||||||
# SIGINT/SIGTERM is processed without a long delay
|
# SIGINT/SIGTERM is processed without a long delay
|
||||||
self.progress.timer(1000, lambda: None, True, False)
|
self.progress.timer(1000, lambda: None, True, False)
|
||||||
|
@ -1621,22 +1621,39 @@ title="%s" %s>%s</button>""" % (
|
||||||
|
|
||||||
# GC
|
# GC
|
||||||
##########################################################################
|
##########################################################################
|
||||||
# ensure gc runs in main thread
|
# The default Python garbage collection can trigger on any thread. This can
|
||||||
|
# cause crashes if Qt objects are garbage-collected, as Qt expects access
|
||||||
|
# only on the main thread. So Anki disables the default GC on startup, and
|
||||||
|
# instead runs it on a timer, and after dialog close.
|
||||||
|
# The gc after dialog close is necessary to free up the memory and extra
|
||||||
|
# processes that webviews spawn, as a lot of the GUI code creates ref cycles.
|
||||||
|
|
||||||
def setupDialogGC(self, obj: Any) -> None:
|
def garbage_collect_on_dialog_finish(self, dialog: QDialog) -> None:
|
||||||
qconnect(obj.finished, lambda: self.gcWindow(obj))
|
qconnect(
|
||||||
|
dialog.finished, lambda: self.deferred_delete_and_garbage_collect(dialog)
|
||||||
|
)
|
||||||
|
|
||||||
def gcWindow(self, obj: Any) -> None:
|
def deferred_delete_and_garbage_collect(self, obj: QObject) -> None:
|
||||||
obj.deleteLater()
|
obj.deleteLater()
|
||||||
self.progress.timer(1000, self.doGC, False, requiresCollection=False)
|
self.progress.timer(
|
||||||
|
1000, self.garbage_collect_now, False, requiresCollection=False
|
||||||
|
)
|
||||||
|
|
||||||
def disableGC(self) -> None:
|
def disable_automatic_garbage_collection(self) -> None:
|
||||||
gc.collect()
|
gc.collect()
|
||||||
gc.disable()
|
gc.disable()
|
||||||
|
|
||||||
def doGC(self) -> None:
|
def garbage_collect_now(self) -> None:
|
||||||
|
# gc.collect() has optional arguments that will cause problems if
|
||||||
|
# it's passed directly to a QTimer, and pylint complains if we
|
||||||
|
# wrap it in a lambda, so we use this trivial wrapper
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
||||||
|
# legacy aliases
|
||||||
|
|
||||||
|
setupDialogGC = garbage_collect_on_dialog_finish
|
||||||
|
gcWindow = deferred_delete_and_garbage_collect
|
||||||
|
|
||||||
# Crash log
|
# Crash log
|
||||||
##########################################################################
|
##########################################################################
|
||||||
|
|
||||||
|
|
|
@ -27,7 +27,7 @@ class NewDeckStats(QDialog):
|
||||||
|
|
||||||
def __init__(self, mw: aqt.main.AnkiQt) -> None:
|
def __init__(self, mw: aqt.main.AnkiQt) -> None:
|
||||||
QDialog.__init__(self, mw, Qt.Window)
|
QDialog.__init__(self, mw, Qt.Window)
|
||||||
mw.setupDialogGC(self)
|
mw.garbage_collect_on_dialog_finish(self)
|
||||||
self.mw = mw
|
self.mw = mw
|
||||||
self.name = "deckStats"
|
self.name = "deckStats"
|
||||||
self.period = 0
|
self.period = 0
|
||||||
|
@ -108,7 +108,7 @@ class DeckStats(QDialog):
|
||||||
|
|
||||||
def __init__(self, mw: aqt.main.AnkiQt) -> None:
|
def __init__(self, mw: aqt.main.AnkiQt) -> None:
|
||||||
QDialog.__init__(self, mw, Qt.Window)
|
QDialog.__init__(self, mw, Qt.Window)
|
||||||
mw.setupDialogGC(self)
|
mw.garbage_collect_on_dialog_finish(self)
|
||||||
self.mw = mw
|
self.mw = mw
|
||||||
self.name = "deckStats"
|
self.name = "deckStats"
|
||||||
self.period = 0
|
self.period = 0
|
||||||
|
|
Loading…
Reference in a new issue