Manual Conflict Resolution with CouchDB and Svelte posted Wednesday, December 18, 2024 by Alex
In our previous post, we added automatic conflict resolution to our multi-user, real-time Kanban board. This would silently resolve any conflicts where two users simultaneously modified two different properties of the same Kanban board card, eg. Alice changes the card’s title, and Bob changes the card’s location. While these changes constitute a conflict on a single database record, they don’t actually collide within that record, and thus the conflict can be resolved by a machine.
Regrettably, this doesn’t solve all possible conflicts: two users can still simultaneously modify the same property of the same card, eg. both Alice and Bob edit a card’s title.
Step By Step
The app exists on GitHub, and you can follow along as we add features by checking out different tags. This post covers step 3.
Table of Contents
- Creating Conflicts
- The "Pick A Winner" UI
- Cascading and Nested Conflicts
- The Manual Conflict Resolution UI
- More Flexible Conflict Resolution UIs
- Conclusion
Creating Conflicts
So how exactly do we create a conflict that cannot be resolved automatically, within the system we’ve built so far?
- Alice clicks on a card to edit it, and changes the title.
- Bob clicks on a card to edit it, and also changes the title to something different than Alice.
- Alice completes her edit and saves it to the CouchDB.
- Bob also clicks save, but the CouchDB rejects his change: Alice’s change has updated the card’s
_rev
property, and Bob’s change now references an outdated_rev
. Computer says no. - Our automatic conflict resolution mechanism from the previous post kicks in and throws the base revision, Bob’s version, and Alice’s version into our three way merge function. However, since both changed versions are attempting to change the same property,
title
, the merge function can't determine a resolution, and returns aconflicts
object. So now we know that we need to resolve this conflict manually. This means presenting both changes to a human and asking them to deal with it.
Here’s what we would want to do next:
- Notify Bob that Alice has already made a change that prevents Bob’s change.
- Allow Bob to somehow resolve the conflict.
The simplest manual conflict resolution method is "Pick a Winner". Here, we would show Bob his version and Alice’s version, and ask Bob to pick one. This only requires a very simple UI, and Bob would only need to perform a single click to resolve the issue. It’s not particularly flexible though: since Bob can now see Alice’s change as well as his own, he might now want to compose a completely new version that takes into account this new information. Or, if both parties modified multiple conflicting fields, maybe some fields should contain Alice’s changes, and the others Bob’s. But we're going to keep it simple for now and just build a "Pick a Winner" UI. The basic principle holds true regardless of how many layers of complexity we add later, all we want to do for now is make it possible for our app’s users to resolve conflicts at all.
The "Pick A Winner" UI
This is actually pretty simple to implement since we can already render cards, and we can already store cards in the CouchDB. All we need to do is cobble together a UI that pops up when our three way merge function returns a conflicts
object, displays the two conflicting cards, and stores the one clicked on by the user.
Now, it’s perfectly possible that this write will cause another conflict: while Bob was contemplating his options, Charlie has swooped in, made a change to the same card and already stored it in the CouchDB. Bob’s conflict resolution will now cause a conflict in itself! Gadzooks! Can’t these people just talk to each other? Grumble grumble.
To make sure everything keeps working as expected, we need to make sure the manual conflict resultion UI can also handle a 409
from the CouchDB and trigger the automatic conflict resolution mechanism we built in the previous post. In fact: all writes to the CouchDB must be able to handle conflicts, including the ones triggered by the conflict resolution mechanisms.
Cascading and Nested Conflicts
This sounds like a veritable rat’s nest of complexity, right? Conflicts can cause further conflicts while they're being resolved, and to make matters worse, conflicts can, in some especially spicy cases, even have conflicts themselves1. When will it stooop?
In reality (and that’s what really matters in the end), it’s not that bad though:
- We’ve made our data model pretty granular: each card can be modified without affecting any of the other documents in the database, so we’d actually need multiple humans touching the same property of the same card at the same time.
- Our means of changing data aren’t especially long-running, meaning the window of opportunity for two humans to touch the same fragment of data at the same time is pretty small:
- Moving a card is a pretty short interaction (about a second)
- Deleting a card is a very short interaction (milliseconds)
- Editing a card is in principle a short-ish interaction, since users are only inputting a couple of words. However, they might open the editing UI and then… think. After all, naming things is hard. Or they might simply not complete the editing process, for whatever reason. This will probably be our main source of conflicts.
- All interactions are made by humans and happening at human speed, this further reduces opportunities for collisions
- The number of humans using each instance of the app (a board) is probably going to peak at about a dozen in most cases
- The usage model of the app works in our favour: Kanban boards are mostly (not always) used in one of two ways:
- During a weekly/daily call, where all team members are present and one person is going through the cards/tickets with everyone else, and making all the changes
- Outside of this call, whenever a person is updating the status of the card they’re currently working on. While not currently explicitly encoded in our app, many Kanban cards often belong to a user, so those cards will generally not be touched by anyone else on their way through the columns
Seems like we got pretty lucky with our choice of app. Plus, we can improve the situation further later by adding a proper user system to the app. If we’re honest, if this were a real app, this is the first thing we’d have done. This lets us do a number of nifty things:
- We can add a UI locking feature. When a user starts an interaction with the card, we can lock that card for all other users. This is a huge opportunity to prevent most conflicts, but it’s only available to us because our app doesn’t work offline, which is why we didn’t do this straight out of the gate. Also, locking isn’t a silver bullet either: due to the physical laws of the universe, the fact that a card got locked will not instantly propagate to all other users. While the travel time is generally quite short, it is not zero, and still provides a small window of opportunity for causing conflicts. So we would have needed the automatic and manual conflict handling anyway. Plus, locking always comes with its own issues, but we’ll look at those in the next post. Beyond this, all other measures are subject to diminishing returns:
- We can unblock cards that are in an editing state but have received no changes: if a user clicks on edit, but then doesn’t actually do anything to the card, we can chose to remove that card from the editing state after a while, or after the user has switched to a different tab
- We can give users roles and decide that the changes by users higher in the hierarchy trump the changes of lesser users (I find this problematic here, but it might be a perfectly valid approach in some cases)
The Manual Conflict Resolution UI
As noted, actually building this is pretty simple. Our board gets a state variable that holds the current conflict data (the three inputs to the merge function, base
, ours
and theirs
). If that variable is set, we render our <ConflictResolution>
component. This needs to know about the conflict itself, the columns (because we want to display the human readable column names), two event handlers for the two possible decisions (we win or they win), and a reference to the PouchDB database instance, so the component can fetch things from the DB.
First off, we’ like to know who made each change, so we need the activity log document that belongs to the change made by the other party. We decided that these documents should have a deterministic _id
, so we can compose that now from information we already have: it uses the _id
and _rev
from the second ("their") conflict. This may or may not exist, because it might also have been deleted instead of changed:
const logForTheirs = conflict.theirs
? db.get<ActivityLog>(
`log-${conflict.theirs?._id}-${conflict.theirs?._rev}`
)
: null
We just store the promise and use Svelte’s {#await}
block to wait for it. Once it’s there, we render the two cards, ours and theirs.
Rendering the Cards
We’ve expanded our <Card>
with a boolean isReadOnly
prop, which hides the edit and delete buttons if set, and also makes the card undraggable. Below the card, we display the colum name, since we can’t see the columns in this view, and the cards themselves don’t include the column information. Below that comes the button to pick one or the other card:
It’s also possible that "they" deleted the card, so we need to handle that too:
Note that in this case, the remote document might not exist due to CouchDB cleaning up deleted documents periodically, so we can’t compose an _id
for the log document. The log document exists, there’s just no trivial way for us to get it, since we don’t have the _rev
of the deleted document on hand. In a proper app, we’d search through the changes feed to get the _rev
of the deletion, but for this example, we’ll just display "them" instead of the other user’s actual name.
We don’t need to handle the other direction (we delete, they edit), because that conflict will happen on their side, not ours.
Now all that remains is actually doing the resolution itself.
Handling the User Input
The user only has two choices:
- The current user that caused the conflict wins (In this case, that's Alice, or "we win")
- The other party wins (That would be Bob, or "they win")
Happily, the handler for "they win" is really easy to implement: it just closes the conflict resolution UI, because "their" version is already the last, most up-to-date version of the document in the CouchDB. Bob has already won, we’re just confirming that by leaving his version of the document as it is.
"We win" means composing a new document. To do this, we take the contents of our change and add their change’s _rev
, if it exists:
const myWinningVersion: PouchDB.Core.PostDocument<Card> = {
...conflictToResolve.mine,
// This is a shortcut, we’re stealing the current winning revision’s
// _rev and using it to make our version the winning one. In the real
// world, you might want to merge your changes into the current winning
// revision instead
// If they deleted the card, we want to create it again, which means
// PUTting it without a _rev
_rev: conflictToResolve.theirs?._rev || undefined,
}
This then just gets passed to the tryToPut()
function in the Board component that we’ve already used a couple of times.
More Flexible Conflict Resolution UIs
As previously mentioned, "pick a winner" is the simplest possible conflict resolution UI. Depending on your needs and the size of your documents, you can add all sorts of complexity on top at this point; the underlying logic stays the same. Assuming you’ve got documents with lots of fields, and conflicts can occur on many of them, you might consider
- Giving users the ability to pick a winner per field
- Additionally, giving users the ability to enter completely new data for any field
You can iterate over the added
and updated
objects returned by the merge function to make this a bit easier. Complexity can balloon fairly quickly here though, especially if your inputs are of many different types, this requires keeping track of which key in which document type maps to which input type, rendering them all accordingly, and handling their changes properly. For a feature which is, in principle, a last resort, this may all seem a bit much. But remember, we’re talking about doing this for the kind of user data we simply cannot afford to lose. If the data isn’t that important, you can always fall back to "second write fails", as we did in our initial post of this series. You don’t have to handle conflicts for all data types, and if you do, you don’t have to handle them with equal rigour.
Conclusion
The basic implementation of this was surpisingly painless and allows the app to handle all card-related conflicts. But it is, of course, just a basic implementation for a single data type/UI element. This isn’t a drawback specific to this type of implementation, CRDTs for example also need to be adapted to different data types (and can still produce conflicts that need to be resolved by humans). Having users concurrently modify the same resources is a tricky challenge that requires a lot of thought and foresight, and we hope these posts are helpful in informing the various decisons that have to be made while designing and building such an app.
In any case, thanks for your time, please join us for the next posts, where we’ll deal with:
- UI locking and its trade offs
- More fun topics to flesh out the app, such as audit trails
Footnotes
-
This state is almost impossible to achieve in this app, but as a properly distributable, eventually consistant database that continues to work when instances are occasionally offline, CouchDB can deal with this case. In the end, the most important thing is not losing any data. ↩