Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

725.conflict resolution #752

Open
wants to merge 67 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
45ce11f
WIP: conflict-resolution things from pre-pause
meejah Aug 9, 2023
c7c0e08
match api, if not semantics
meejah Oct 22, 2023
ddb0b68
fix test; refactor setup
meejah Oct 22, 2023
dc582e2
test fix
meejah Oct 22, 2023
a0010b1
test fix
meejah Oct 22, 2023
fda06da
incorrect params
meejah Oct 22, 2023
aacd22d
more participants
meejah Oct 22, 2023
c8dc413
fixes
meejah Oct 22, 2023
ad82e45
update version
meejah Oct 22, 2023
a631344
WIP: basic resolution stuff works
meejah Nov 3, 2023
6f003fa
WIP: fix resolutions
meejah Nov 3, 2023
c71d1f4
cleanup
meejah Nov 3, 2023
75583d2
author -> participant_name
meejah Nov 3, 2023
56b0c89
test api
meejah Nov 3, 2023
0eb6b90
rename
meejah Nov 3, 2023
741662f
author_name -> participant_name
meejah Nov 20, 2023
98ed987
add 'resolve' to the local API helper
meejah Dec 4, 2023
072e0e9
fix test?
meejah Jan 31, 2024
4cca010
add 'reactor' arg to CLI
meejah Feb 13, 2024
6671994
document HTTP API ..../resolve-conflict
meejah Feb 13, 2024
c0ef9ae
begin documenting conflicts / resolution
meejah Feb 13, 2024
343c91b
doc refs
meejah Feb 13, 2024
d0ff317
fix assert
meejah Feb 14, 2024
5300aa4
working 3-party conflict removal
meejah Feb 14, 2024
9f42e58
more docs
meejah Feb 14, 2024
a63c166
unused
meejah Feb 14, 2024
526c32f
ignore dupes for conflicts
meejah Feb 14, 2024
4df9c53
fixup: more places need reactor arg
meejah Feb 14, 2024
b625640
conflicted state tests/notes about handling
meejah Feb 14, 2024
9926ee6
incorrect merge
meejah Feb 16, 2024
ce31aa0
linting
meejah Feb 16, 2024
029575b
not really valid test
meejah Feb 19, 2024
de9bfbf
lint
meejah Feb 19, 2024
fb13dcc
copyedit
meejah Mar 27, 2024
063c627
rebase resolution was wrong
meejah Mar 27, 2024
9fcec34
news
meejah Mar 27, 2024
b5354d3
Windows can't move onto existing file
meejah Mar 27, 2024
d69b3c6
integration test for conflict-resolution
meejah Mar 28, 2024
00b9586
improve documentation
meejah May 6, 2024
17c2cb8
only show individual uploads if there's less than 20 of them
meejah May 6, 2024
bd199b9
test invalid resolution
meejah May 6, 2024
dbeef66
in-memory resolve
meejah May 6, 2024
d4deb91
test conflict cases
meejah May 6, 2024
df9564d
refactor and add error-case test
meejah May 6, 2024
efdf878
test for take/use at once
meejah May 6, 2024
18358e7
more error-case Web API tests
meejah May 6, 2024
b42ca10
cleanup
meejah May 6, 2024
deb0294
test a remote-resolve
meejah May 6, 2024
62979f4
linter
meejah May 6, 2024
a4dedc2
windows cares about tests-names, kind of
meejah May 6, 2024
5e7c4c9
properly overwrite, even on windows
meejah Jun 3, 2024
8610424
fixup; delete the dest not source
meejah Jun 3, 2024
0342420
reword
meejah Jun 3, 2024
3fc14ea
debug
meejah Jun 3, 2024
ba01510
missing await
meejah Jun 3, 2024
d4d1b9a
undo debug
meejah Jun 3, 2024
3adf6c8
spell
meejah Jun 11, 2024
e6f48d5
spell
meejah Jun 11, 2024
35e1996
add a ref
meejah Jun 11, 2024
b0b6505
set up the list
meejah Jun 11, 2024
86cb478
add some notes on error-handling
meejah Jun 11, 2024
be5b04f
redirect
meejah Jun 11, 2024
c3db632
try coveralls
meejah Jun 12, 2024
a35af31
syntax?
meejah Jun 12, 2024
aa69345
syntax
meejah Jun 12, 2024
5bba085
circular-import attrib
meejah Jun 17, 2024
e68849b
dead code
meejah Jun 17, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions .github/workflows/linux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,11 @@ jobs:
name: "unit-test"
path: "eliot*"

- name: Upload coverage report
uses: codecov/codecov-action@v2
- name: Coveralls
uses: coverallsapp/github-action@v2
with:
token: "322d708d-8283-4827-b605-ccf02bfecf70"
file: "./coverage.xml"

parallel: true
flag-name: "unit"


integration-tests:
Expand Down Expand Up @@ -157,8 +156,9 @@ jobs:
name: "integration"
path: "eliot*"

- uses: codecov/codecov-action@v2
- name: Coveralls
uses: coverallsapp/github-action@v2
with:
token: "322d708d-8283-4827-b605-ccf02bfecf70"
file: "./coverage.xml"
flags: "integration"
parallel: true
flag-name: "integration"
parallel-finished: true
133 changes: 133 additions & 0 deletions docs/conflicts.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
.. -*- coding: utf-8 -*-

.. _conflicts:

Magic Folder Conflicts and Resolution
=====================================

When we have two or more participants updating a folder, it can happen that a file is modified "at the same time" by two or more participants.
More correctly, "at the same time" here means "between communications events".

Effectively, participants are speaking a protocol by posting updates to their mutable Capabilities.
Namely, updating which Snapshot object a particular (file) name points to (including adding a new name, or removing one).

We do not know *when* another Participant has updated their notion of the current state.
However, what we *can* notice is when they "commit" to that state (that is, they update publically their current Snapshot pointer).

Each file is considered individually, and is represented by a tree of Snapshots of the contents (plus metadata).
Included in the metadata are one or more "parent" Snapshots (a brand new file has zero parents).

For more on this, the :ref:`datamodel` has a section on :ref:`datamodel_conflicts`.


Communication Between Folders
-----------------------------

Consider a single file named ``"foo"``.
When this file is created (for example on the device called Laptop) that device uploads a Snapshot (``S0``) with no parent Snapshots.

By "upload" we mean to push the content and metadata to the Tahoe-LAFS client, receiving ultimately a Capability for the Snapshot ``S0``.
The file entry for ``"foo"`` is then updated, visualized as: ``foo -> S0``
Here, "updated" means that we use Tahoe-LAFS to edit to contents of our Personal directory to change "foo" to point at Snapshot ``S0``.

Now, all other Participants can (and, eventually, will) notice this update when they poll the magic-folder.

Each of these updates to our Personal directory is a "communication event", so we talk about the regions between these as a cohesive state of that client.
That is, anything that happens during that time is "at the same time" for the purposes of this protocol.

Note that this time interval could be as short as a few seconds or as long as days (or more).

Observe too that the "parents" of a particular Snapshot are a commitment to the state visible by a particular client when creating any new Snapshot.


Detecting Conflicts
-------------------

A new update is communicated to us -- that is, we've downloaded a previously-unknown Snapshot from some other Participant's mutable Capability.

We now look at our own Snapshot for the corresponding file.
Either our Snapshot appears in some parents (including grandparents, etc) of the new Snapshot, or it doesn't.

If it does appear, this is an "update" (and we simply replace the local content with the incoming content).

Instead if our Snapshot does not appear as any ancestor of the incoming Snapshot, a conflict is determined.

This is because when the other device created their Snapshot, they didn't know about ours (or else it would appear as an ancestor) so we have made a change "at the same time".

Note that unlike tools like Git, we do not examine the contents of the file or try to produce differences -- everything is determined from the metadata in the Snapshot.
This means that even if we happened to make the very same edits "at the same time" it would still be a conflict.
meejah marked this conversation as resolved.
Show resolved Hide resolved


Showing Conflicts Locally
-------------------------

Once a conflict is detected, "conflict marker" files are put into the local magic folder location (our local file remains unmodified, and something like ``foo.conflict-desktop`` will appear.
The state database is also updated (conflicts can also be listed via the API and CLI).

meejah marked this conversation as resolved.
Show resolved Hide resolved
Although magic-folder itself doesn't try to examine the contents, you can now use any ``diff`` or similar tools you prefer to look at what is different between your copy and other participant(s) copies.


Resolving Conflicts
-------------------

We cannot "magically" solve a conflict: two devices produced new Snapshots while not communicating with each other.

Thus, it is up to the humans using these devices to determine what happens.
Once appropriate changes are decided upon, a new Snapshot is produced with *two or more parents*: one parent for each of the Snapshots involved in the conflict (we've only talked about one other participant so far, but there could be more).

Such a Snapshot (with two or more parents) indicates to the other clients a particular resoltion to the conflict has been decided.
meejah marked this conversation as resolved.
Show resolved Hide resolved

So there's actually another case when we see an incoming new Snapshot: it may in fact *resolve an existing* conflict.
If this is the case, conflict markers are removed and the local database is updated (i.e. removing the conflict).

It is a human problem if this resolution is not to your particular liking; you can produce an edit again or talk to the human who runs the other computer(s) involved.
The history of these changes *is available* in the parent (or grandparent) Snapshots if the UX you're using can view or restore these.


Resolving via Filesystem
------------------------

Not currently possible (see `https://github.com/tahoe-lafs/magic-folder/issues/754 <Issue 754>`_).


Resolving via the CLI
---------------------

The subcommand ``magic-folder resolve`` may be used to specify a resolution.
It allows you to choose ``--mine`` or ``--theirs`` (if there is only one other conflict).
Otherwise, you must apply the ``--use <name>`` option to specify which version to keep.

Currently there is no API for doing something more complex (e.g. simultaneuously replacing the latest version with new content).
meejah marked this conversation as resolved.
Show resolved Hide resolved
As with everything else on the CLI, the :ref:`http_api` may be used to accomplish the same simple resolution tasks as above.

Complete example:

.. code-block:: console

magic-folder resolve --mine ~/Documents/Magic/foo


Resolving via the HTTP API
--------------------------

See :ref:`api_resolve_conflict`


Future Directions
-----------------

We do not consider the current conflict functionality "done".
There are other features required to make this more robust and have a nicer user experience.
meejah marked this conversation as resolved.
Show resolved Hide resolved
Some of those features are:

*Viewing old data*: While it is currently possible in the datamodel to view past versions of the files, we do not know of any UI that does this (and the CLI currently cannot).

*Restore old version*: Similarly, it is possible to produce a new Snapshot that effectively restores an older version of the same file.
We do not know of any UI that can do this.

*Completely new content*: As hinted above, it might be nice to be able to produce a resolution that is some combination of multiple versions (like one sometimes does with Git conflicts, for example).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this context, I wonder also about pruning old snapshots (e.g., to save costs on metered/ZKAPs-enabled grids) and/or requesting deletion (e.g., in cases where a file might have been published unintentionally).

(These are somewhat of beyond just conflict detection though...)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no deletion API in Tahoe-LAFS.

The closest there is (which would work for "pruning" too) is to simply stop refreshing leases on things you no longer care about.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I think this is more directly related to "history" / "how the datamodel works" than anything about conflicts per se though)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is more directly related to "history" / "how the datamodel works" than anything about conflicts per se though

Yes, agreed (and hence my note/admission above that this is "beyond just conflict detection" -- so no need to really do anything here/now); this was mostly just an observation about something that I -- as a hypothetical/imaginary end user of this software -- might wonder about when encountering such a list of missing features. To that end, a note that there is no way to prune/delete snapshots might be nice here (depending on what level of knowledge the reader is assumed to have) but I also grant that a) reliable "deletion" isn't (ever?) possible, b) if there ever were deletion-like functionality, it would probably be a Tahoe-LAFS thing -- not a Magic-Folder thing, and c) this is more of a general concern (that isn't specific to conflicts). So feel free to ignore/disregard/resolve. :)

While this isn't directly possible currently, you can always take the "closest" one via the existin conflict-resolution API and then immediately produce an edit that has the desired new content.

*Resolution via file manipulation*: Currently, filesystem manipulation is one API (e.g. you just change a file and new Snapshots are produced).
Similarly, conflict-marker files are used to indicate a conflict via the filesystem.
It would be nice if you could use a similar mechanism to *eliminate* conflicts -- one way to design this could be to notice that the user has deleted all the conflict-markers and take this as a sign that the remaining file is in fact the desired resolution.
5 changes: 4 additions & 1 deletion docs/datamodel.rst
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,8 @@ If any Snapshot is different, it is downloaded and acted upon.
For a full discussion of this process, see :ref:`downloader`.

Ultimately, for normal updates or deletes, the change will be reflected (or "acknowledged" if you prefer) by updating our own Personal folder after making local changes.
In case of a "conflict" (e.g. two changes at "the same" time) we will not update the Personal folder until the user resolves the conflict (this part isn't possible yet, see `Issue 102 <https://github.com/LeastAuthority/magic-folder/issues/102>`_).
In case of a "conflict" (e.g. two or more changes at "the same" time) we will not update the Personal folder until some participant resolves the conflict.
See also :ref:`conflicts`

Considered together, an abstract view of a two-Participant example:

Expand All @@ -140,6 +141,8 @@ There is a single file (``grumpy-cat.jpeg``) which has been changed once (the or
We can see that both Participants are up-to-date because both Personal folders point at the latest Snapshot.


.. _datamodel_conflicts:

Conflicts
---------

Expand Down
27 changes: 27 additions & 0 deletions docs/interface.rst
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,17 @@ A mechanism to add deprecation of APIs will be added in a future release.
- **Version 1 (``/v1``)**: initial version of the API (not yet considered 100% stable).


Error Handling
~~~~~~~~~~~~~~

Various sorts of errors may occur while using the API.

Most input-validation errors (e.g. nonsensical argument, missing arugments, parsing errors, etc) will be answered with a "400 Bad Request".
Sometimes, 500-level errors may occur if something actually "internally bad" has happened.
A "502 Bad Gateway" will result from incorrect interactions with Magic Wormhole servers.
A "409 Conflict" results when a conflict-resolution attempt fails.


.. _`daemon configuration`: :ref:`config`

``GET /v1/magic-folder``
Expand Down Expand Up @@ -262,6 +273,22 @@ Each item in the ``dict`` maps a relpath to a list of author-names.
The author-names correspond to the device that conflicts with this file.
There will also be a file named like ``<relpath>.conflict-<author-name>`` in the magic-folder whose contents match those of the conflicting remote file.

.. _api_resolve_conflict:

POST ``/v1/magic-folder/<folder-name>/resolve-conflict``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Ask for a particular resolution to a conflicted file.
The body is a JSON object containing the keys:

* ``relpath``: the file name inside the folder
* ``take``: ``"mine"`` or ``"theirs"`` indicating whose version to take
* ``use``: a Participant name indicating whose version to take

Note that you can use only one of ``take`` or ``use``, and that ``take="theirs"`` only works when there is exactly one other conflict.
meejah marked this conversation as resolved.
Show resolved Hide resolved

Returns a ``dict`` mapping ``relpath`` to a list of all Participant names that conflicted before the resolution.


GET ``/v1/magic-folder/<folder-name>/scan-local``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down
14 changes: 5 additions & 9 deletions integration/test_multiuser.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,17 +206,13 @@ async def test_conflicted_users(request, reactor, temp_filepath, alice, bob, edm
# shouldn't _keep_ trying to download conflicted updates.

# now, wait for updates
all_updates = await DeferredList([
alice.status_monitor(how_long=20),
bob.status_monitor(how_long=20),
edmond.status_monitor(how_long=20),
await DeferredList([
alice.status_monitor(how_long=5),
bob.status_monitor(how_long=5),
edmond.status_monitor(how_long=5),
])
# ensure we don't "keep downloading" when there's a conflict
for st, updates in all_updates:
assert st, "status streaming failed"
assert len([e for e in updates if e["kind"] == "download-queued"]) < 2, "too many downloads queued"

# everyone should have a conflict though...
# everyone should have a conflict...
assert find_conflicts(magic) != [], "alice should have conflicts"
assert find_conflicts(magic_bob) != [], "bob should have conflicts"
assert find_conflicts(magic_ed) != [], "edmond should have conflicts"
Expand Down
101 changes: 101 additions & 0 deletions integration/test_resolve_conflict.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""
Testing synchronizing files between 3 or more participants
"""

from eliot.twisted import (
inline_callbacks,
)
import pytest_twisted

from .util import (
find_conflicts,
)
from twisted.internet.defer import DeferredList


def non_lit_content(s):
# type: (str) -> bytes
"""
Pad the given string so it is long enough to not fit in a tahoe literal
URI.
"""
# The max size of data that will be stored in a literal tahoe cap is 55.
# See allmydata.immutable.upload.Uploader.URI_LIT_SIZE_THRESHOLD
# We don't need to be exactly longer than that threshold, as long as we
# are over it.
return "{} {}\n".format(s, "." * max(55 - len(s), 0)).encode("utf8")


async def perform_invite(request, folder_name, inviter, invitee_name, invitee, invitee_magic_fp, read_only=False):
invitee_magic_fp.makedirs()

code, magic_proto, process_transport = await inviter.invite(folder_name, invitee_name)
await invitee.join(
code,
folder_name,
invitee_magic_fp.path,
invitee_name,
poll_interval=1,
scan_interval=1,
read_only=read_only,
)

def cleanup_invitee():
pytest_twisted.blockon(invitee.leave(folder_name))
request.addfinalizer(cleanup_invitee)

await magic_proto.exited
print(f"{invitee_name} successfully invited to {folder_name}")


@inline_callbacks
@pytest_twisted.ensureDeferred
async def test_resolve_two_users(request, reactor, temp_filepath, alice, bob):
"""
Two users both add the same file at the same time, producing conflicts.

One user resolves the conflict.
"""

magic = temp_filepath.child("magic-alice")
magic.makedirs()

await alice.add(request, "conflict", magic.path)

# invite some friends
magic_bob = temp_filepath.child("magic-bob")
await perform_invite(request, "conflict", alice, "robert", bob, magic_bob)

# add the same file at "the same" time
content0 = non_lit_content("very-secret")
magic.child("summertime.txt").setContent(content0)
magic_bob.child("summertime.txt").setContent(content0)

await DeferredList([
alice.add_snapshot("conflict", "summertime.txt"),
bob.add_snapshot("conflict", "summertime.txt"),
])
# we've added all the files on both participants

# wait for updates
await DeferredList([
alice.status_monitor(how_long=20),
bob.status_monitor(how_long=20),
])

# everyone should have a conflict...
assert find_conflicts(magic) != [], "alice should have conflicts"
assert find_conflicts(magic_bob) != [], "bob should have conflicts"

# resolve the conflict
await alice.resolve("conflict", magic.child("summertime.txt").path, "theirs")

# wait for updates
await DeferredList([
alice.status_monitor(how_long=20),
bob.status_monitor(how_long=20),
])

# no more conflicts
assert find_conflicts(magic) == [], "alice has conflicts"
assert find_conflicts(magic_bob) == [], "bob has conflicts"
16 changes: 16 additions & 0 deletions integration/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,22 @@ def add_snapshot(self, folder_name, relpath):
],
)

def resolve(self, folder_name, magic_file, resolution):
"""
magic-folder resolve
"""
if resolution not in ["theirs", "mine"]:
raise ValueError("Invalid resolution: {}".format(resolution))
return _magic_folder_runner(
self.reactor, self.request, self.name,
[
"--config", self.magic_config_directory,
"resolve",
"--theirs" if resolution == "theirs" else "--mine",
magic_file,
],
)

def scan_folder(self, folder_name):
"""
magic-folder-api scan-folder
Expand Down
1 change: 1 addition & 0 deletions newsfragments/725.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Ability to resolve conflicted files.
Loading
Loading