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:
+18
-12
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user