Sudden burst of `conflicted` pages after brief iPhone session (Chrome) — suspect bulk tag script or fast tab closes

Hello folks!

Today, after briefly browsing a few pages on the iPhone, I suddenly saw a bunch of new pages with the conflicted suffix. I immediately noticed because my Top Bar had duplicate buttons from a Space Lua script that adds items there.

The conflicted copies are newer than the originals and don’t contain real content conflicts. In most cases, the only difference is a single extra frontmatter tag that I added using a bulk-tagging Lua script I wrote (it calls space.writePage to save pages).

Many of the affected pages weren’t ever opened on the iPhone. That’s part of why this behavior surprised me.

I’m trying to understand what could have triggered this. One possibility is that the bulk writes via space.writePage happened on my Mac while the iPhone had a stale snapshot, and when the phone briefly came online, it produced conflicts even though its local copies were older.

Another possibility is that I sometimes close SB tabs quickly after editing. I usually wait for the page name to turn from gray to black, but I might have missed it and closed before everything fully synced. Who knows at this point.

Is there any known behavior where space.writePage bulk updates across multiple clients raise the chance of conflicts? On iOS, could backgrounding or short-lived sessions lead to a stale state that creates conflicted copies on reconnect?

I’m happy to share the Lua script (or a minimal repro), but I’m not sure it’s the real culprit, given that some of the conflicts were on pages it never touched.

Any ideas or guidance would be much appreciated. Thanks!

For completeness’ sake, this is the script I mentioned:

command.define {
  name = "Page: Batch add tag to pages under prefix",
  run = function()
    local prefix = editor.prompt("Prefix to bulk add a tag to:")
    if not prefix then
      return
    end
    local foundPages = query[[
      from index.tag "page"
      where name:startsWith(prefix)
    ]]
    local newTag = editor.prompt("Tag to add:")
    if not newTag then
      return
    end
    local confirmed = editor.confirm("Tag " .. newTag .. " will be added to " .. #foundPages .. " pages. Are you sure?")
    if not confirmed then
      return
    end
    for p in foundPages do
      local text = space.readPage(p.name)
      if not tableContains(p.tags, newTag) then
        editor.flashNotification("Adding tag to  " .. p.name)
        local newText = index.patchFrontmatter(text, {{
          op = "set-key", path = "tags", value = {newTag}
        }})
        space.writePage(p.name, newText)
      end
    end
    editor.flashNotification("Ensured tag #" .. newTag .. " existis in all " .. #foundPages .. " pages")
  end,
}

This is indeed a puzzle. Let me “write through” this problem to see if there’s a gap somewhere that could cause this.

A few things:

The sync engine doesn’t care about “old” vs “new”. It simply compares lastModified timestamps and if they’re different it assumes a file changed (locally or remotely). The reason is that I’m going to assume clocks drift, timezone issues etc, so those timestamps are likely not a reliable way to order changes.

A conflicting copy is created when:

  1. Both the lastModified timestamp of the file locally and remotely have changed since the last sync cycle
  2. AND the content is actually different (this will for sure be the case, if you updated frontmatter).

To check what has changed in between sync cycles, a snapshot is kept (as a big map, stored in IndexedDB) with for each file the lastModified timestamp of the file locally and remotely (separately, just in case the remote doesn’t reliably update last modified dates) as is the state when last sync cycle completed.

As of the 2.0 release, the sync engine runs in the service worker which and uses mutexes to make ensure only one sync cycle happens at a given time.

What is a bit unspecified and OS specific is how long a service worker is kept alive when you close tabs, switch apps etc. From what I have found experimentally is that on iOS service workers are even kept running in the background for up to a minute or so even when you switch apps. However, we’re always at the mercy of the browser vendor, they can shoot this process down at any given time. Both on the desktop and on mobile. I just noticed you mentioned you’re using Chrome on iOS, which makes things even more complicated because this isn’t actual Chrome, it’s just a skinned Safari with probably quasi-random Apple-imposed restrictions, so behavior may be slightly different again.

Now what makes your case interesting is that you likely had a batch of files to sync, which could take a bit of time.

The flow is this:

  1. Fetch a list of all local files (with timestamps)
  2. Fetch a list of all remote files (with timestamps)
  3. Fetch the snapshot from storage
  4. Compare local and remote timestamps with what is in the snapshot
  5. For each “mismatch” perform the sensible operations (upload, download, conflict resulution) updating the snapshot as we go along
  6. Save the snapshot back to storage

What could hypothetically happen is that the sync process is killed during step 5, a bunch of files have synced but since we never make it to step 6 this is not reflected in the snapshot in a persistent way.

In this scenario, when the service worker is booted again and a new sync cycle kicks in, it would go through step 1-3. Then when it compares the locally (presumably already updated) files with the snapshot it will conclude: oh, there were changes here, the timestamps don’t match with the snapshot! Then it looks at the remote list, and here too, changes were made.

It would indeed go into conflict resolution mode in this case. However since the content it will find locally and remotely would be the same, it would still not result in conflicts…

So, even in this scenario this shouldn’t result in many conflicting copies.

Still comparing actual content is a bit risky, especially if the remote had already changed again since the last time. So if you have a lot of churn on a bunch of notes a lot, and this “mid sync cancel” happen a lot, you can run into problems occasionally.

What I can do to make this less likely is to save the snapshot more aggressively, for instance after each individual file sync rather than after a full cycle. This comes at some cost, but maybe not a lot.

:thinking:

Hi Zef!

Thank you so much for taking the time to write that detailed explanation. It really helped clarify my thinking around this problem.

I agree that the race condition you described is unlikely to be the culprit in this case.

I’ve now enabled SB_LOG_PUSH on my instance, so hopefully if this happens again I’ll have more diagnostic information to work with.

Whatever caused the issue affected dozens of files at once on a PWA instance running in Chrome on iOS. All conflict resolutions favored the primary (client) version, with each server file becoming a conflicted copy. This suggests that primaryConflictResolver ran for all of them.

For reference, I ran stat on a couple of the affected files on my server.

Here’s an example of a file that was effectively rolled back by the sync:

  File: ClamAV in a container.md
  Size: 2399            Blocks: 8          IO Block: 4096   regular file
Device: 252,0   Inode: 1179864     Links: 1
Access: (0644/-rw-r--r--)  Uid: (    0/    root)   Gid: (    0/ UNKNOWN)
Access: 2025-10-15 18:40:54.321250883 +0000
Modify: 2025-10-15 18:40:32.545024389 +0000
Change: 2025-10-15 18:40:32.545024389 +0000
 Birth: 2025-08-31 20:18:42.456371638 +0000

And here’s the corresponding conflicted copy that was created:

  File: ClamAV in a container.conflicted:1760022009953.md
  Size: 2408            Blocks: 8          IO Block: 4096   regular file
Device: 252,0   Inode: 1180291     Links: 1
Access: (0644/-rw-r--r--)  Uid: (    0/    root)   Gid: (    0/ UNKNOWN)
Access: 2025-10-15 18:40:54.813256000 +0000
Modify: 2025-10-15 18:40:32.513024056 +0000
Change: 2025-10-15 18:40:32.513024056 +0000
 Birth: 2025-10-15 18:40:32.512024046 +0000

If I’m reading this correctly, the number 1760022009953 in the filename represents the timestamp the iOS client received for this file from the secondary (server), which translates to 2025-10-09 12:00:09. Since the conflicted copy was created when the client detected the conflict, the time difference between its “Birth” date and the timestamp indicates how long the iOS client went without syncing: approximately six days.

Over such a long period, I certainly upgraded the SB server container at least a couple of times, adding even more complexity to the situation.

Hoefully, future us will some day read through this again and then it will all make sense. A man can dream. :sweat_smile: