Multi-user Design: Record Locking


#1

This is a general multi-user, RealmCloud design question.

Suppose we have a multi-user app that stores notes for each user. Users have access to other users notes and can add to them, edit them or delete them.

User A creates a note and saves it to RealmCloud. User B is observing notes and sees User A added a note. User B opens the note and adds some data.

While User B is adding data, User A decides to delete that note. Now there’s a conflict between a note that is ‘in-use’ and it being deleted. What should happen is User A is notified that User B has the note ‘in-use’ and prevent deletion until it’s no longer in-use.

While a flag could be used, if User B d/c’s while working on the note, it will be forever locked.

With a number of other databases (SQL) there is record or row locking so that while User B has a note ‘in-use’, if User A tries to delete it, they will be notified the record/row is locked.

On the server side of things (SQL), if User B disconnects during the editing process, the server will unlock that record so work can continue.

Other databases don’t offer record locking however, the server can perform actions upon disconnect (Firebase) and ‘unlock’ records in those cases by means of setting/resetting an ‘in-use’ flag on a child node.

In the case of Realm, it doesn’t appear there’s any built-in functionality to handle record locking or tracking if an object is ‘in use’ or taking action upon a user disconnecting.

How are you handling multi-user workflow and environments like this?


#2

I don’t see how this feature could be built with record-blocking. You definitely build your own custom logic for that.

In my opinion, the best use case would be this:

  • User A create a note
  • User B opens it and starts editing
  • User A tries to delete while someone is editing

User A got a notification, that he is trying to delete a note that is currently edited by XY. Also, User A has the choice to force-delete it or wait till the edit finished.
If its force-deleted, then UserB got a notification that the note is deleted, but he can save it to his own notes.

I think that this is the best user-scenario, both users are happy.


#3

Thanks for the response.

Ah. But therein lies the issue.

User A got a notification, that he is trying to delete a note that is currently edited by XY.

There would have to be some logic to indicate the note is ‘in use’ for the user to know it’s being edited by XY - to implement that logic one option would be to add a ‘in-use’ property that’s set when it’s in use (along the lines of record locking). However, if that user d/c’s it remains ‘in-use’ forever!


#4

it remains ‘in-use’ forever

I dont get it why. If a user opens a note then you set the inUse property to true. If the user navigates from it then you just set it to false. Same in force quit etc.


#5

Suppose the inUse property is set to true on that note and the user disconnects, or force quits the app while they are editing the document. Then they don’t log back in for a week. That flag would continue to be set for the week and nobody else could delete or edit that note. There would be nothing to re-set that flag during the time that user is away.

This is a common use case with multi-user environments which is why SQL, Oracle and other databases have server side logic that will handle these cases (in-use server flags, record & row locking etc).

It really comes down to conforming to ACID in a multi-user databaes in which the ‘I’ is:

Isolation – requires that a transaction that is still running and did not commit all data yet, must stay isolated from all other transactions

It appears that if an object it being modified during a write transaction, that same object can be deleted before the transaction completes, which means the object is not isolated.

Proof of concept is pretty easy, and duplicatable; observe an object, begin a write transaction, modify object properties and then on a different computer, delete that object before .commitWrite() is called. So code wise

let realm = try! Realm()
realm.beginWrite()
person.name = "Henry" //delete the person on a different computer here
try! realm.commitWrite()

and during the write transaction run this on a different computer

let henrysKey = "1234" //Henry's key
if let personToDelete = realm.object(ofType: Person.self, forPrimaryKey: henrysKey) {
    try! realm.write {
        realm.delete(personToDelete)
    }
}

Perhaps I am overlooking something but at this point, in multi-user environments it appears objects can be deleted, edited etc while they are being deleted, edited etc by other users?

Is anyone using Realm in a multi-user writeable shared data environment? If so, how is this addressed?

There is discussion in the Realm documentation Designing a Database: Realm Threading Deep Dive but that’s from 2016 and really talks about ACID compliance with threads and doesn’t really address cases of objects being modified (updates, deletes etc) by simultaneous multiple users.


#6

That scenario does not occur. Changes made on remote computers are not applied to the local Realm while the local Realm is inside a write transaction. When inside a write transaction, you are operating on an isolated but potentially stale view of the Realm, and then changes are merged only after you commit the write transaction. This means that if person is valid when you begin the write, it will remain valid until you commit the write even if someone else deletes the object at the same time (and then your modifications will be discarded by the server since the object has been deleted).

That article is still largely applicable when multiple machines are involved. There is still a (local to the machine) write lock which allows concurrent reads, and the data synchronization is implemented on top of that, with modifications from remote machines only applied to the local Realm while the local write lock is held.


#7

How about put dateTime as a flag, and after some time the inUse is not valid. On the client (if the app is open) you can update the flag if actual editing is happening.


#8

@tgoyne I would agree with that description for a Query-based synchronized Realm but the discussion is really for Full synchronization with ROS, not a local Realm. I may be misunderstanding your comment though.

Either way though, the issue I am referencing is that you shouldn’t be able to delete a person object while another user is in the process of writing to it.

The bigger picture is there’s no built in way to know if an object is in the process of a write transaction or not. User A could open an object and solve the mystery of the universe within a write transaction and User B could haphazardly delete the object while it’s being ‘edited’.

I was really looking for some functionality that either indicates the object is being modified, or some way that, if we roll our own with a ‘being_modified’ flag, that it could be reset automatically.

Let me pose this example from Firebase.

Firebase does not have record locking or other ways to know when an object is being modified. So given a document ‘object’ JSON

documents
    document_0
      document_text: "some document text"
      being_modified: false

What happens here is when a user opens document_0 we set (through code) the being_modified flag to true.

When editing is complete, the flag is set to false. Pretty straightforward but, as mentioned previously, when a flag it set to true (being_modified) and the user disconnects and never comes back, it remains set.

To eliminate that issue, Firebase has a onDisconnect function the takes action at the server level when a user disconnects. In the case we set the onDisconnect function to set that flag to false upon disconnect.

That solves both issues

  1. If an object is being modified (flag set to true) another user would not be able to modify or delete that object

  2. If an object is being modified, and the user modifying the object d/c’s, the flag automatically resets to false.


#9

There’s no difference between full sync and query-based sync here.

Firebase and Realm approach a similar position from opposite ends. Realm is a full client-side database with optional data synchronization, while Firebase is a library for accessing a server-side database with optional local caching of data. The relevant difference here is that with Firebase writing data is inherently an online operation. Transactional writes cannot be performed at all without a network connection, and non-transactional writes are simply queued for until the device can connect. With Realm, a write transaction is instead a purely local operation, and then after the write completes the changeset is sent to the server. There’s no concept of another user being inside a write transaction because the server is not aware of write transaction at all.

It’s awkward, but an object lease concept for advisory locking could be implemented in Realm using a server-side event handler. The basic idea would be something like the following on the client:

On the client:

class LeaseRequest: Object {
    enum Status: Int {
        case pending = 0
        case granted = 1
        case rejected = 2
    }

    @objc dynamic var creationTime = Date()
    @objc dynamic var state = Status.pending.rawValue
    @objc dynamic var object: MyObjectType?
}

let lease = realm.create(LeaseRequest.self, value: ["object": myObject])
let token = lease.observe(\.state) { _, _ in
    switch LeaseRequest.Status(rawValue: lease.state) {
        case .pending: return
        case .granted:
            try! realm.write {
                // object is locked, proceed to do things
                // delete the lease as part of the write transaction
                realm.delete(lease)
            }
            break
        case .rejected:
            // someone else has already locked the object
            break
    }
}

And then on the server:

Realm.Sync.addListener(SERVER_URL, '.*', 'change', (changeEvent) => {
    const changedLeases = changeEvent.LeaseRequest;
    if (!changedLeases || changedLeases.insertions.length === 0) {
        return;
    }
    const realm = changeEvent.realm;
    const leases = realm.objects('LeaseRequest')
    realm.write(() => {
        for (let i of changedLeases.insertions) {
            const requestedLease = leases[i];
            if (requestedLease.state !== 0) {
                // Deletions can produce spurious insertion notifications, so ignore already-processed objects
                continue;
            }
            // If there is an existing lease request object created in the last hour which has been granted the lease, reject this request
            if (leases.filtered('object = %1 AND creationTime > %2 AND state == 1', requestedLease.object, Date.now() - 60 * 60 * 1000).length)
                requestedLease.state = 2;
            else // Otherwise grant it
                requestedLease.state = 1;
        }
    });
});

In place of an unlock when the client disconnects this simply makes leases expire after an hour. The use of the server-side handler is just to ensure that two clients cannot simultaneously think that they have locked the same object by having a single source of truth.


#10

This is great information, thank you. Still wrapping my head around implementing LeaseRequest into our app.

So the bottom line is that all data is written locally first. Then at some point in time after, it’s synchronized to the server. Therefore when a write transaction is opened, it’s opened locally and the server has no knowledge that an object is in a write transaction.


#11

Maybe the better approach than record locking is to implement custom conflict resolution. Realm internally does not modify data and maintains a version tree to do simple conflict resolution. You might resolve conflict of deleted record using that tree, however, there is no Realm API for iterating the previous version (see also this post).
Consider modelling the version history in your model scheme … note that it would duplicate the internal Realm version tree and result in increasing of DB size.