iOSFlickconcurrencyreliability

Deletion Committed, Spinner Still Running: Flick's Refresh Boundary

A deletion was waiting for a whole-library group rebuild. Flick now separates the commit receipt, bookkeeping, snapshot refresh and background work, while rejecting stale results and preserving unrelated caches.

An October fix in Flick shortened an unnecessarily long dependency chain. PhotoKit had finished deleting the selected items, but the pending-bin screen still waited for a whole-library group rebuild before clearing its “deleting” state.

The cause was visible in the call sequence: deletion ended with await coordinator.rebuildGroups(). That call could wait for an existing rebuild and a follow-up pass requested by this operation. Deleting a handful of photos was therefore coupled to analysis and grouping elsewhere in the library.

This is a development note based on commit f069da8 (October 12, 2026) and the subsequent implementation. It describes repository behavior; it does not establish which App Store release contains it.

Four different meanings of finished

The deletion path crosses several state boundaries:

Stage Required fact
System commit PHPhotoLibrary.performChanges has succeeded
Local bookkeeping Allowances, pending items and progress reflect the committed subset
Snapshot refresh Affected fetch results have changed, so older group results can be rejected
Group rebuild Library-wide derived results have been recomputed from the new snapshots

The first three belong to completing this operation. The fourth can run afterward, while the UI removes confirmed deletions from its existing groups.

There is also a storage boundary: a successful PhotoKit deletion does not promise immediately reclaimed disk space. Recently Deleted and iCloud synchronization have their own lifecycles. Media-size totals are not a measurement of bytes instantly returned to the filesystem.

Account for a receipt, not a request

The pending bin contains a snapshot of an earlier selection. By commit time, another device might already have removed an item, or an item may no longer be visible. Charging an allowance or increasing progress from the original selection would count requested work as successful work.

PhotoStore.delete resolves the resources again and validates the allowance. Inside PhotoKit’s changes block, it fetches the IDs once more and selects the resources that still exist and are not hidden. CleanupDeletionReceipt records that subset. Only after the transaction succeeds does didCommit receive its summary and committedIDs.

The same receipt drives each downstream update:

  • charge the allowance for the committed subset;
  • remove only committed IDs from the pending selection;
  • add only committed content bytes to progress;
  • prune those same IDs from displayed groups.

The deletion permit also covers bookkeeping. Every window shares the store, so releasing the permit immediately after PhotoKit returned would let another window evaluate an allowance before the first window had charged it.

Moving work to the background needs a stale-result fence

Replacing the final await with an arbitrary background task would remove the wait, but leave a race. An older rebuild may already be computing from a pre-deletion snapshot. If it publishes after the UI has pruned the deleted photos, those photos can reappear in the group cards.

The implementation first refreshes the affected fetch snapshots, then prunes the UI and schedules the rebuild. Publishing groups checks both the photo and video revisions:

guard !Task.isCancelled,
      store.revision == revision,
      store.videoRevision == videoRevision else { return }

This is the simplified guard from applyGroups. If either revision differs from the one captured for the rebuild, that result is discarded. Old computation may finish; it no longer has permission to change the current view.

The post-commit snapshot refresh runs in an independent task, and deletion awaits it. That independence is deliberate: once the system transaction has succeeded, cancellation of a page’s caller must not abandon necessary state synchronization. The wait now covers the snapshots needed to identify stale work, rather than a complete regrouping of the library.

An empty loaded page is not an empty group

Updating the UI before recomputation requires a correct local pruning operation. PhotoGroup has both complete memberIDs and materialized photos, which may contain only one page.

Consider a group with A, B and C where only A is loaded. Deleting A empties photos, but B and C still belong to the group. Dropping the group because its loaded page is empty would hide valid remaining content.

removingAssets(withIDs:) therefore uses complete membership to decide whether a group survives. Duplicate, similar and burst groups need at least two remaining members. Screenshot, blurry, video and all-items groups can survive with one. The loaded page, title and suggested-deletion set are updated together; group identity stays stable while its content revision changes.

A deleted keeper needs additional care. The code clears the keeper, its confidence, its reasons and the existing deletion suggestions rather than choosing an arbitrary replacement. Otherwise every survivor might still be marked for deletion, even though the assumption behind those suggestions—one photo is being kept—no longer holds.

Scheduling and waiting are separate contracts

CoalescingWork already offered run: the caller waits for a pass that begins after its request. That contract makes sense for an entry point that needs confirmation of a completed rebuild. It is too strong for post-deletion background maintenance.

The added schedule operation enqueues work without waiting for completion. Both entry points share one serial worker:

  • the current pass finishes without overlapping another pass;
  • requests arriving during that pass replace the pending closure with the latest one;
  • replacing a pending closure retains every run waiter;
  • those waiters resume only after their corresponding follow-up pass completes.

Coalescing execution must not lose completion obligations. Nor should a schedule request cause a waiting run caller to resume before the work it needs has finished.

The pending-bin screen now calls refreshAfterDeletion(removing:). That operation prunes groups and its derived sections together on MainActor, then calls schedule for a complete rebuild. The rebuild no longer extends the deletion button’s waiting state.

Invalidate the media that changed

The same fix addressed an avoidable cache invalidation. Photo deletion previously used the default full reload, refreshing the video snapshot as well. The video-group cache is keyed by videoRevision, so deleting a photo invalidated already measured video sizes even when no video had changed.

The receipt now determines the refresh scope: photos only, videos only, or both. Standalone video refreshes and full reloads share a gate, preventing an earlier bootstrap load from publishing an older fetch result after the deletion refresh.

This narrows invalidation without skipping correctness checks. “The library changed” becomes a more useful statement: which media snapshot actually changed?

Check boundaries instead of relying on a fast demo

Repository regressions cover the fragile cases: pruning an entire loaded page while unloaded members survive; clearing suggestions after deleting the keeper; retaining group identity; allowing schedule to return while existing work is blocked; preventing overlapping passes; and resuming every run waiter at the right point.

Deletion logging also distinguishes permit acquisition, preparation, PhotoKit commit, bookkeeping and snapshot refresh. It records counts and timings, plus error domains and codes for failures, without photo content or asset IDs. A future report that deletion is slow can then be separated into system-transaction time and application post-processing time.

This writing pass did not rerun deletion against a device library or produce a new timing comparison. What the implementation and regression cases establish is narrower: whole-library rebuilding has left the deletion wait chain; one committed subset drives bookkeeping and pruning; and revision checks reject stale group publication. Real libraries, system confirmation dialogs and iCloud behavior still require device verification.

The relevant sources are PhotoStore.swift, CleanupDeletionPlan.swift, AnalysisCoordinator.swift, CoalescingWork.swift and PhotoGroup.swift, with regressions in test-async-lifecycle.swift and test-video-group.swift. The useful change was assigning each wait a precise fact it must guarantee.

✦

Comments

  • Loading…

Comments are reviewed before publishing; email is visible only to me.