Fix dict mutation race: snapshot data before background save/sync

json.dump in a background thread would crash with "dictionary changed
size during iteration" when the UI mutated data concurrently. Now all
save_json and sync_to_db calls receive a json.loads(json.dumps(data))
snapshot, isolating the serialization from live UI mutations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 09:36:46 +01:00
parent ba330dd208
commit 79e1426036
2 changed files with 30 additions and 20 deletions
+18 -12
View File
@@ -277,9 +277,10 @@ def render_batch_processor(state: AppState):
new_item.pop(k, None) new_item.pop(k, None)
batch_list.append(new_item) batch_list.append(new_item)
data[KEY_BATCH_DATA] = batch_list data[KEY_BATCH_DATA] = batch_list
await asyncio.to_thread(save_json, file_path, data) snapshot = json.loads(json.dumps(data))
await asyncio.to_thread(save_json, file_path, snapshot)
if state.db_enabled and state.current_project and state.db: if state.db_enabled and state.current_project and state.db:
await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, data) await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, snapshot)
render_sequence_list.refresh() render_sequence_list.refresh()
with ui.row().classes('q-mt-sm'): with ui.row().classes('q-mt-sm'):
@@ -315,9 +316,10 @@ def render_batch_processor(state: AppState):
async def sort_by_number(): async def sort_by_number():
batch_list.sort(key=lambda s: int(s.get(KEY_SEQUENCE_NUMBER, 0))) batch_list.sort(key=lambda s: int(s.get(KEY_SEQUENCE_NUMBER, 0)))
data[KEY_BATCH_DATA] = batch_list data[KEY_BATCH_DATA] = batch_list
await asyncio.to_thread(save_json, file_path, data) snapshot = json.loads(json.dumps(data))
await asyncio.to_thread(save_json, file_path, snapshot)
if state.db_enabled and state.current_project and state.db: if state.db_enabled and state.current_project and state.db:
await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, data) await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, snapshot)
ui.notify('Sorted by sequence number!', type='positive') ui.notify('Sorted by sequence number!', type='positive')
render_sequence_list.refresh() render_sequence_list.refresh()
@@ -369,11 +371,12 @@ def render_batch_processor(state: AppState):
return return
data[KEY_HISTORY_TREE] = htree.to_dict() data[KEY_HISTORY_TREE] = htree.to_dict()
t1 = time.perf_counter() t1 = time.perf_counter()
await asyncio.to_thread(save_json, file_path, data) snapshot = json.loads(json.dumps(data))
await asyncio.to_thread(save_json, file_path, snapshot)
logger.info("save_and_snap save_json %.3fs", time.perf_counter() - t1) logger.info("save_and_snap save_json %.3fs", time.perf_counter() - t1)
if state.db_enabled and state.current_project and state.db: if state.db_enabled and state.current_project and state.db:
t1 = time.perf_counter() t1 = time.perf_counter()
await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, data) await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, snapshot)
logger.info("save_and_snap sync_to_db %.3fs", time.perf_counter() - t1) logger.info("save_and_snap sync_to_db %.3fs", time.perf_counter() - t1)
state.restored_indicator = None state.restored_indicator = None
commit_input.set_value('') commit_input.set_value('')
@@ -392,9 +395,10 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
refresh_list): refresh_list):
async def commit(message=None): async def commit(message=None):
data[KEY_BATCH_DATA] = batch_list data[KEY_BATCH_DATA] = batch_list
await asyncio.to_thread(save_json, file_path, data) snapshot = json.loads(json.dumps(data))
await asyncio.to_thread(save_json, file_path, snapshot)
if state.db_enabled and state.current_project and state.db: if state.db_enabled and state.current_project and state.db:
await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, data) await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, snapshot)
if message: if message:
ui.notify(message, type='positive') ui.notify(message, type='positive')
refresh_list.refresh() refresh_list.refresh()
@@ -689,9 +693,10 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, state, refresh_li
batch_list[j].get('frame_to_skip', FRAME_TO_SKIP_DEFAULT), FRAME_TO_SKIP_DEFAULT) + delta batch_list[j].get('frame_to_skip', FRAME_TO_SKIP_DEFAULT), FRAME_TO_SKIP_DEFAULT) + delta
shifted += 1 shifted += 1
data[KEY_BATCH_DATA] = batch_list data[KEY_BATCH_DATA] = batch_list
await asyncio.to_thread(save_json, file_path, data) snapshot = json.loads(json.dumps(data))
await asyncio.to_thread(save_json, file_path, snapshot)
if state.db_enabled and state.current_project and state.db: if state.db_enabled and state.current_project and state.db:
await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, data) await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, snapshot)
ui.notify(f'Shifted {shifted} sequences by {delta:+d}', type='positive') ui.notify(f'Shifted {shifted} sequences by {delta:+d}', type='positive')
refresh_list.refresh() refresh_list.refresh()
@@ -840,9 +845,10 @@ def _render_mass_update(batch_list, data, file_path, state: AppState, refresh_li
ui.notify(f'Mass update failed: {e}', type='negative') ui.notify(f'Mass update failed: {e}', type='negative')
return return
data[KEY_HISTORY_TREE] = htree.to_dict() data[KEY_HISTORY_TREE] = htree.to_dict()
await asyncio.to_thread(save_json, file_path, data) save_snapshot = json.loads(json.dumps(data))
await asyncio.to_thread(save_json, file_path, save_snapshot)
if state.db_enabled and state.current_project and state.db: if state.db_enabled and state.current_project and state.db:
await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, data) await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, save_snapshot)
ui.notify(f'Updated {len(targets)} sequences', type='positive') ui.notify(f'Updated {len(targets)} sequences', type='positive')
if refresh_list: if refresh_list:
refresh_list.refresh() refresh_list.refresh()
+12 -8
View File
@@ -160,9 +160,10 @@ def _render_batch_delete(htree, data, file_path, state, refresh_fn):
async def do_batch_delete(): async def do_batch_delete():
current_valid = state.timeline_selected_nodes & set(htree.nodes.keys()) current_valid = state.timeline_selected_nodes & set(htree.nodes.keys())
_delete_nodes(htree, data, file_path, current_valid) _delete_nodes(htree, data, file_path, current_valid)
await asyncio.to_thread(save_json, file_path, data) snapshot = json.loads(json.dumps(data))
await asyncio.to_thread(save_json, file_path, snapshot)
if state.db_enabled and state.current_project and state.db: if state.db_enabled and state.current_project and state.db:
await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, data) await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, snapshot)
state.timeline_selected_nodes = set() state.timeline_selected_nodes = set()
ui.notify( ui.notify(
f'Deleted {len(current_valid)} node{"s" if len(current_valid) != 1 else ""}!', f'Deleted {len(current_valid)} node{"s" if len(current_valid) != 1 else ""}!',
@@ -327,9 +328,10 @@ def _render_node_manager(all_nodes, htree, data, file_path, restore_fn, refresh_
if sel_id in htree.nodes and rename_input.value: if sel_id in htree.nodes and rename_input.value:
htree.nodes[sel_id]['note'] = rename_input.value htree.nodes[sel_id]['note'] = rename_input.value
data[KEY_HISTORY_TREE] = htree.to_dict() data[KEY_HISTORY_TREE] = htree.to_dict()
await asyncio.to_thread(save_json, file_path, data) snapshot = json.loads(json.dumps(data))
await asyncio.to_thread(save_json, file_path, snapshot)
if state and state.db_enabled and state.current_project and state.db: if state and state.db_enabled and state.current_project and state.db:
await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, data) await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, snapshot)
ui.notify('Label updated', type='positive') ui.notify('Label updated', type='positive')
refresh_fn() refresh_fn()
@@ -343,9 +345,10 @@ def _render_node_manager(all_nodes, htree, data, file_path, restore_fn, refresh_
async def delete_selected(): async def delete_selected():
if sel_id in htree.nodes: if sel_id in htree.nodes:
_delete_nodes(htree, data, file_path, {sel_id}) _delete_nodes(htree, data, file_path, {sel_id})
await asyncio.to_thread(save_json, file_path, data) snapshot = json.loads(json.dumps(data))
await asyncio.to_thread(save_json, file_path, snapshot)
if state and state.db_enabled and state.current_project and state.db: if state and state.db_enabled and state.current_project and state.db:
await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, data) await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, snapshot)
# Reset selection if branch was removed # Reset selection if branch was removed
if selected['branch'] not in htree.branches: if selected['branch'] not in htree.branches:
selected['branch'] = next(iter(htree.branches), None) selected['branch'] = next(iter(htree.branches), None)
@@ -576,9 +579,10 @@ async def _restore_node(data, node, htree, file_path, state: AppState):
data['history_tree_backup'] = preserved_backup data['history_tree_backup'] = preserved_backup
htree.head_id = node['id'] htree.head_id = node['id']
data[KEY_HISTORY_TREE] = htree.to_dict() data[KEY_HISTORY_TREE] = htree.to_dict()
await asyncio.to_thread(save_json, file_path, data) snapshot = json.loads(json.dumps(data))
await asyncio.to_thread(save_json, file_path, snapshot)
if state.db_enabled and state.current_project and state.db: if state.db_enabled and state.current_project and state.db:
await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, data) await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, snapshot)
label = f"{node.get('note', 'Step')} ({node['id'][:4]})" label = f"{node.get('note', 'Step')} ({node['id'][:4]})"
state.restored_indicator = label state.restored_indicator = label
logger.info("_restore_node END (%.3fs)", time.perf_counter() - t0) logger.info("_restore_node END (%.3fs)", time.perf_counter() - t0)