ParseSwift SDK: Observe LiveQuery WebSocket status

How could I use the ParseLiveQuery class in swift to observe the status of LiveQuery connection? I found that I can get connection status by accessing isConnected in the default client:

let status = ParseLiveQuery.getDefault()?.isConnected

But I would like some notification on when the connection is lost or reconnected, so that the client could refetch objects and get up to date.

Thank you!

As long as I understand, you can use handleSubscribe. In the case the connection is lost, the client will try to reconnect and fire it once it gets reconnected passing isNew false.

I am using the network link conditioner to block traffic for testing purpose and it seems that if the connection is lost for a long time I get neither disconnection nor reconnection from the following code:

subscription!.handleSubscribe { subscribedQuery, isNew in
     //: You can check this subscription is for this query
     if isNew {
          print("Successfully subscribed to new query \(subscribedQuery)")
     } else {
          print("Successfully updated subscription to new query \(subscribedQuery)")
     }
}

if I do hold the traffic blocked for a short time, even after the app comes from background, the liveQuery (with noticeable delay of 10s+) catches up and receive even an update that I made in database during the blockade.

I am looking for robust way to detect when I can rely on liveQuery and when I should rather refresh the device state by fetching actual state. I found this (for me well informative) link on how to handle connectivity and unfortunately I can’t set the session config in ParseSwift as far I know:

let config = URLSessionConfiguration.default
config.waitsForConnectivity = true
config.timeoutIntervalForResource = 300

I will try to implement NWPathMonitor() but this should be started rather after the client finds out that LiveQuery is lost. And for that I have not find a good trigger.

let monitor = NWPathMonitor()
monitor.start(queue: .global())
monitor.pathUpdateHandler = { path in
    if path.status == .satisfied {
        // Indicate network status, e.g., back to online
    } else {
        // Indicate network status, e.g., offline mode
    }
}

Perhaps one more question. Is it a good practice to:

  1. unsubscribe all queries on when app is about to resign active and then subscribe againe when it becomes active?

  2. to unsubscribe all queries when app will be terminated - to close websocket and release server load?

To be very honest, I am not so familiar with the Swift SDK implementation for Live Query, but, talking about the server side, I think it would be a good practice just make sure that the live query connection is closed when you don’t need it anymore.

I tend to let the OS decide when to close the web socket as ParseLiveQuery by default will only open one connection for all of your subscriptions. When ParseLiveQuery was added to ParseSwift, I did a small test and mentioned the OS let the web socket stay open over night, LiveQuery Support by cbaker6 ¡ Pull Request #45 ¡ parse-community/Parse-Swift ¡ GitHub

Ultimately, I think it’s up to the needs of your application and if you don’t need the connection anymore you should do what @davimacedo mentioned and unsubscribe or even close the connection.

Whet the application gets terminated in the background by the iOs, then there is no reason to keep any connections open, am I right? As there would be app launch again, where the application would start the connections again.

So it somehow feels correct to call

ParseLiveQuery.getDefault()?.close()

in

func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>)

I am just not sure if that is enough to close everything as I see after trying it in sceneDidEnterBackground

testing LiveQuery isConnected: Optional(false)
testing LiveQuery isConnecting: Optional(false)
testing LiveQuery isSocketEstablished: Optional(true) ← this has any effect or is the connection closed on server side with no hanging resources?
testing LiveQuery isSubscribed: Optional(true) ← this is probably due to cached queries and has to effect
testing LiveQuery isPendingSubscription: Optional(false)

Of do I again understand it wrong and the application actually do not open a new connection on the next launch if there is previous connection still available? I am confused if this might happen as the application gets terminated.

And as I tried also without terminating application, just putting in background and back active, I can’t somehow re-open the connection:

  1. ParseLiveQuery.getDefault()?.close()
  2. testing LiveQuery isConnected: Optional(false)
  3. ParseLiveQuery.getDefault().open(completion: { error in
    print(“error while opening webSocket: (error?.localizedDescription)”)
    })
  4. testing LiveQuery isConnected: Optional(false)

isConnected stays false even the .open() do not throw any error

I was thinking about following strategy to handle bad connectivity:

  • on app launch fetch all data to get up-to-date and subscribe LiveQuery
  • to detect bad connectivity
    1. on 3x failed .save() of .fetch() inform user with manual “retry” button and when there is success after manual trigger later the application should check if LiveQuery is still life. If not, try to restart like a fresh app launch
    2. sending Date() value in .save() context and reject the save in beforeSave cloud code if object’s updatedAt on server is newer than value of the pending save → this prevents outdated saves and inform client that there was some updates missed → if data were missed, client app would again fetch and restart LiveQuery

But here I am not sure how to restart or close LiveQuery in a clean way as the above isConnected stays false and isSocketEstablished stays true

What I am trying to achieve is to primary not drain server resources through some zombie webSockets opened, because outdated state on device is not critical as the server can reject outdated save operation

Is there a chance that not watched error in the afterSave trigger of the cloud code could cause a delay and therefore influence the LiveQuery? I become .success() from the .save() calls but I now noticed following error in the server log related to my ElasticSearch cloud function:

[2021-06-10T09:03:38.680Z]
(node:21) UnhandledPromiseRejectionWarning: TimeoutError: Request timed out
at ClientRequest.onTimeout (/usr/src/app/data/cloud/node_modules/@elastic/elasticsearch/lib/Connection.js:109:16)
at ClientRequest.emit (events.js:310:20)
at ClientRequest.EventEmitter.emit (domain.js:482:12)
at Socket.emitRequestTimeout (_http_client.js:709:9)
at Object.onceWrapper (events.js:416:28)
at Socket.emit (events.js:322:22)
at Socket.EventEmitter.emit (domain.js:482:12)
at Socket._onTimeout (net.js:479:8)
at listOnTimeout (internal/timers.js:549:17)
at processTimers (internal/timers.js:492:7)

[2021-06-10T09:03:38.680Z]
(node:21) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag --unhandled-rejections=strict (see Command-line options | Node.js v16.3.0 Documentation). (rejection id: 2)

[2021-06-10T09:02:40.774Z]
(node:21) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.

As this function might delay finishing the afterSave I can imagine that it also affects liveQuery triggers. Is that a possibility?

I suppose closing the connection formerly wouldn’t hurt, but the OS will close it anyways. My sample app uses the Swift SDK LiveQuery to keep distributed databases in sync:

If you look at the console output when the app is logged in, you will see websocket connection is opened/closed when app moves to the foreground/background. I never formerly close the websocket and it always subscribes and updates database changes

Thank you for the example I will have a deep look no that. I must be doing something wrong indeed as I see so many “Server starts running” lines in the log that seems to be multiplied for one trigger:

2021-06-10T11:27:56.279Z - Parse LiveQuery Server starts running
2021-06-10T11:27:55.739Z - Parse LiveQuery Server starts running
2021-06-10T11:27:55.737Z - Parse LiveQuery Server starts running
2021-06-10T11:27:55.627Z - Parse LiveQuery Server starts running

I suspect there is something in my Cloud Code that is affecting the behaviour. Also (for me a new observation) as soon as I upload new cloud code files, it close the connection. What actually brought me on the idea that my javaScript syntax is somewhere blocking something…

Did you resolve this?

Unfortunately not, even I went through your example and it make sense. I mean in my case the LiveQuery works also fine if there is always a connection and app active, otherwise I get it unreliable and isConnected == true even there seems to be no connection (example, new cloud code pushed while app running) and I still could not figure out how to detect that there is “deaf” connection.

For example nicely reproducible is the case where I push new cloud code when having app running:

Successfully subscribed to new query Query(method: “GET”, limit: 100, skip: 0, keys: nil, include: nil, order: nil, isCount: nil, explain: nil, hint: nil, where: ParseSwift.QueryWhere(constraints: [“objectId”: [ParseSwift.QueryConstraint(key: “objectId”, value: “b2CP6LViu6”, comparator: nil)]]), excludeKeys: nil, readPreference: nil, includeReadPreference: nil, subqueryReadPreference: nil, distinct: nil, pipeline: nil, fields: nil)

changing data in dashboard notifies the client:

LQ Updated: PrsProfile ({"…truncated properties…"})

same as throttling the connection with Network Link Conditioner, pushing a new cloud code triggers most of the time this errors (but sometimes goes also without):

2021-06-11 12:36:59.206205+0200 Felse[9329:887688] Connection 3: missing error, so heuristics synthesized error(1:53)
2021-06-11 12:36:59.206525+0200 Felse[9329:887688] Connection 3: encountered error(1:53)

When I print the LiveQueryClient variables right after that I see no change and there are unfortunately no updates coming anymore, liveQuery goes deaf:

TODO: check if fetch is needed (LQ disconnected or appLaunch) and eventually pass fetched objects or flag that fetch is needed
testing subscription: Optional(ParseSwift.SubscriptionCallback<Felse.PrsProfile>)
testing LiveQuery isConnected: Optional(true)
testing LiveQuery isConnecting: Optional(false)
testing LiveQuery isSocketEstablished: Optional(true)
testing LiveQuery isSubscribed: Optional(true)
testing LiveQuery isPendingSubscription: Optional(false)

In cloud code logs I can see entry Client disconnect matching the time when I terminated the app, so that works as wanted, just the logs appear with a weird delay. But I also noticed other cloud code logs that are always 2x even the function on the client side is being called only once… Further I noticed that beforeSave trigger is getting called when login in… So I contacted Back4App support to see what is happening first, to clarify that my cloud code is not blocking/holding anything and from there I will have to see.

Otherwise I am neither sure what to do so that the client notice that LiveQuery is deaf, nor how to reset LiveQuery if I would even detect such event as combination of .close() and .open() does not seems to behave well in my case… I see clear Client disconnect in the log after .close() , but nothing happens after .open()

Have you tried unsubscribing/subscribing instead of closing/opening? I would guess most devs don’t necessarily need to worry about opening/closing the connection directly, more just subscribing/unsubscribing to queries

For this I have tried a simple testing function that I trigger while client app is running:

parseService.unsubscribeAllQueries { error in
  print("error when unsubscribeAllQueries: \(error)")
  if error == nil {
     self.parseService.testLiveQueryOnProfile()
  }
}

unsubscribing with this code:

    func unsubscribeAllQueries(completion: @escaping (Error?) -> Void) {
        print("TODO: closing just a testing query subscription: \(subscription!.query)")
        do {
            try subscription!.query.unsubscribe()
            completion(nil)
        } catch {
            completion(error)
        }
        
    }

subscribing with the playground example:

   func testLiveQueryOnProfile() {
        print("testing live query for objectId: \(String(describing: PrsUser.current?.objectId))")
        //testing LiveQuery only
        if let objectId = PrsUser.current?.objectId {
            //: Create a query just as you normally would.
            let query = PrsProfile.query("objectId" == objectId)
            //: This is how you subscribe to your created query using callbacks.
            subscription = query.subscribeCallback!
            print("cached subscription: \(String(describing: subscription))")
            //: This is how you receive notifications about the success
            //: of your subscription.
            subscription!.handleSubscribe { subscribedQuery, isNew in
                //: You can check this subscription is for this query
                if isNew {
                    print("Successfully subscribed to new query \(subscribedQuery)")
                } else {
                    print("Successfully updated subscription to new query \(subscribedQuery)")
                }
            }
            
            //: This is how you register to receive notificaitons of events related to your LiveQuery.
            subscription!.handleEvent { _, event in
                switch event {

                case .entered(let object):
                    print("LQ Entered: \(object)")
                case .left(let object):
                    print("LQ Left: \(object)")
                case .created(let object):
                    print("LQ Created: \(object)")
                case .updated(let object):
                    print("LQ Updated: \(object)")
                case .deleted(let object):
                    print("LQ Deleted: \(object)")
                }
            }
            
            //: This is how you register to receive notificaitons about being unsubscribed.
            subscription!.handleUnsubscribe { query in
                print("Unsubscribed from \(query)")
            }
        }
    }

I tried following procedure with observing the debug console…

  1. app launch, successful subscribe, LQ updates received… so I call the testing function. Console shows that it unsubscribe and subscribe successfully and updates are indeed received. Printing LiveQuery Client shows expected booleans also:

    TODO: closing just a testing query subscription: Query(method: “GET”, limit: 100, skip: 0, keys: nil, include: nil, order: nil, isCount: nil, explain: nil, hint: nil, where: ParseSwift.QueryWhere(constraints: [“objectId”: [ParseSwift.QueryConstraint(key: “objectId”, value: “49HihwaYep”, comparator: nil)]]), excludeKeys: nil, readPreference: nil, includeReadPreference: nil, subqueryReadPreference: nil, distinct: nil, pipeline: nil, fields: nil)

    error when unsubscribeAllQueries: nil

    testing live query for objectId: Optional(“49HihwaYep”)

    cached subscription: Optional(ParseSwift.SubscriptionCallback<Felse.PrsProfile>)

    Unsubscribed from Query(method: “GET”, limit: 100, skip: 0, keys: nil, include: nil, order: nil, isCount: nil, explain: nil, hint: nil, where: ParseSwift.QueryWhere(constraints: [“objectId”: [ParseSwift.QueryConstraint(key: “objectId”, value: “49HihwaYep”, comparator: nil)]]), excludeKeys: nil, readPreference: nil, includeReadPreference: nil, subqueryReadPreference: nil, distinct: nil, pipeline: nil, fields: nil)

    Successfully subscribed to new query Query(method: “GET”, limit: 100, skip: 0, keys: nil, include: nil, order: nil, isCount: nil, explain: nil, hint: nil, where: ParseSwift.QueryWhere(constraints: [“objectId”: [ParseSwift.QueryConstraint(key: “objectId”, value: “49HihwaYep”, comparator: nil)]]), excludeKeys: nil, readPreference: nil, includeReadPreference: nil, subqueryReadPreference: nil, distinct: nil, pipeline: nil, fields: nil)

    testing subscription: Optional(ParseSwift.SubscriptionCallback<Felse.PrsProfile>)
    testing LiveQuery isConnected: Optional(true)
    testing LiveQuery isConnecting: Optional(false)
    testing LiveQuery isSocketEstablished: Optional(true)
    testing LiveQuery isSubscribed: Optional(true)
    testing LiveQuery isPendingSubscription: Optional(false)

  2. To trigger server reset I simply upload new cloud code on the back4app server and the client app prints in the debug immediately errors bellow that do not change LiveQuery booleans. LiveQuery updates are not received anymore:

    2021-06-22 09:54:19.085451+0200 Felse[3471:143373] Connection 3: missing error, so heuristics synthesized error(1:53)
    2021-06-22 09:54:19.085793+0200 Felse[3471:143373] Connection 3: encountered error(1:53)

    testing subscription: Optional(ParseSwift.SubscriptionCallback<Felse.PrsProfile>)
    testing LiveQuery isConnected: Optional(true)
    testing LiveQuery isConnecting: Optional(false)
    testing LiveQuery isSocketEstablished: Optional(true)
    testing LiveQuery isSubscribed: Optional(true)
    testing LiveQuery isPendingSubscription: Optional(false)

  3. calling query.unsubscribe() and subscribe again does not print any handler and LiveQuery booleans change the isPendingSubscription:

    TODO: closing just a testing query subscription: Query(method: “GET”, limit: 100, skip: 0, keys: nil, include: nil, order: nil, isCount: nil, explain: nil, hint: nil, where: ParseSwift.QueryWhere(constraints: [“objectId”: [ParseSwift.QueryConstraint(key: “objectId”, value: “49HihwaYep”, comparator: nil)]]), excludeKeys: nil, readPreference: nil, includeReadPreference: nil, subqueryReadPreference: nil, distinct: nil, pipeline: nil, fields: nil)

    error when unsubscribeAllQueries: nil

    testing live query for objectId: Optional(“49HihwaYep”)

    cached subscription: Optional(ParseSwift.SubscriptionCallback<Felse.PrsProfile>)

    testing subscription: Optional(ParseSwift.SubscriptionCallback<Felse.PrsProfile>)
    testing LiveQuery isConnected: Optional(true)
    testing LiveQuery isConnecting: Optional(false)
    testing LiveQuery isSocketEstablished: Optional(true)
    testing LiveQuery isSubscribed: Optional(true)
    testing LiveQuery isPendingSubscription: Optional(true)

Hard reset on the server side (through cloud code upload) might be different than loosing LiveQuery through throttling of the internet connection, but as it is well reproducible I tried to understand the LQ behaviour on that scenario first. And unfortunately I still miss the understanding of the LiveQueryClient class behaviour. Mainly because the unsubscribe/subscribe functions does not recieve any handler when tried after server disconnection - the same as following open function did not anything even when no error were thrown:

        let client = ParseLiveQuery.getDefault()
        print("client: \(client)")
        client?.open(completion: { error in
            print("error while opening webSocket: \(error?.localizedDescription)")
        }) 

I recommend trying to debug this by adding print statements to the SDK itself. If you find a bug, you can submit a PR for review.

Particularly, this should fire when a disconnection occurs (I recommend starting here):

This is called because of:

Note that if for some reason URLSessionWebSocketDelegate doesn’t call didCloseWith, there’s no way the SDK will know that the socket was closed.

Putting breakpoints in the SDK revealed that this is not being called in neither in device or simulator in any of the scenarios (connection throttling, cloud code upload or even server reset via dashboard)

    func urlSession(_ session: URLSession,
                    webSocketTask: URLSessionWebSocketTask,
                    didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
                    reason: Data?) {
        self.delegates.forEach { (_, value) -> Void in
            value.status(.closed)
        }
    }

the didOpenWithProtocol method in extension LiveQuerySocket: URLSessionWebSocketDelegate is being called properly, so the delegate uses it’s methods.

I was reading in the documentation and even there is nil in delegateQueue, it should work:

override init() {
   super.init()
   session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
}

So I wonder, if anyone else observed it and it is normal or it might be related to back4app environment…?

The documentation also says, Tells the delegate that the WebSocket task received a close frame from the server endpoint, optionally including a close code and reason from the server. So if no close frame is being sent from the server, this shouldn’t be expected to fire. On the client side, when you exit the app, the client properly deinits sending a close frame to the server which is why those connections show as “close” in the console

Hm, it makes sense and I guess that will be the same in case of restart or connection throttling. So if there is no way how to handle connection problems through that delegate method the clients should detect connection problems in some other way. One Idea would be to ignore connectivity at all and just react on the response from object .save() that could be rejected by cloud code function if the object in server database is newer. In other words, if the server would reject a .save() request, the client app would realise that the data in device might be obsolete and would try to refetch actual state and restart LiveQuery. But here I stuck again.

First I tried:

        let client = ParseLiveQuery.getDefault()
        client?.close()
        client?.open(completion: { error in
            print("error opening LQ: \(error)")
        })

the client?.close() seems to close the task, but keeps client?.isSocketEstablished = true what might be correct behaviour. Although I would expect that in this case it would set to false (at least by URLSessionWebSocketDelegate, but there the method didCloseWith does not get called also, even the connection to server is live). Is there a reason, why there is not set the status(.closed) here? Or should there be any URL Session invalidation in there?

Because when the client?.open(completion:...) tries to open the client again, the task will receive and error on line 59:

Optional

  • some : Error Domain=NSURLErrorDomain Code=-999 “cancelled” UserInfo={NSErrorFailingURLStringKey=https://felse.b4a.io/, NSLocalizedDescription=cancelled, NSErrorFailingURLKey=https://felse.b4a.io/}

This error does not occur on the fresh LQ connection during app launch and as there is no error handling after line 506, the code just fall through without setting isConnecting

on fresh app start are the task values the same as on the later open attempt

Printing description of task:
LocalWebSocketTask <9A3BC5D5-…7F05C1967>.<1>

Printing description of encodedAsString:
“{“op”:“connect”,“applicationId”:“coYfu…Ug4p”,“clientKey”:“L8PDq…87gl”,“sessionToken”:“r:5a…baf8”,“installationId”:“7ec2…c9ca”}”

Perhaps the .close() does not let the server know that the socket is closed and that’s why it is being canceled during later .open()…? As I am not experienced enough I am not sure if that is desired or e bug.

Even if I would not worry about .close() and .open() and would just unsubscribe and subscribe again with

try subscription!.query.unsubscribe()

the .send() function receives an error for both unsubscribe and subscribe on line 73/77:

Optional

  • some : Error Domain=NSPOSIXErrorDomain Code=57 “Socket is not connected” UserInfo={NSErrorFailingURLStringKey=https://felse.b4a.io/, NSErrorFailingURLKey=https://felse.b4a.io/}

as if has no error handling, this again falls through:

Printing client booleans shows as previously that socket is established:

testing subscription: Optional(ParseSwift.SubscriptionCallback<Felse.PrsProfile>)
testing LiveQuery isConnected: Optional(true)
testing LiveQuery isConnecting: Optional(false)
testing LiveQuery isSocketEstablished: Optional(true)
testing LiveQuery isSubscribed: Optional(true)
testing LiveQuery isPendingSubscription: Optional(true)

That’s why I believe there is no other way around than solving the socket status. And when the URLSessionWebSocketDelegate doesn’t call didCloseWith (what I understood it cannot when connection is dead or server down) then only manual reset would help, right? In that case I would need to clarify if here bellow line 130 should not be a manual status change to closed or invalidation of the URL:

There can be multiple LiveQuery connections through one socket, closing 1 connection shouldn’t close the socket itself.

The following PR may help with determining the status of a parse server being available:

LiveQuery ping pong will be a great feature to confirm the connection status. Nevertheless without resolving the issue of not being able to restart the LiveQuery/subscription I cannot take much advantage of ping-pong.

I noticed one more thing in Xcode debug navigator… When I launched the app first time today it had one active connection that sends/receives few kB each 10sec. When I uploaded the cloud code, the debug console prints an error and the active connection disappears immediately with send/receive kBs also. Still no notification in the URLSessionWebSocketDelegate that connection was closed:

2021-06-24 11:27:58.758572+0200 Felse[2054:92561] Connection 3: encountered error(1:53)

next launch opens 3 connection where only one is sending/receiving traffic:

When I reset the server / upload cloud code only that one active connection disappeared immediately again with sending/receiving few kBs:

A few seconds later also the two other connections closed with only sending traffic:

So I tried to launch the app third time and again 3 connections were open. After about 95sec of letting the app do nothing the two connections closed again with only sending traffic and the only one active LiveQuery connection remained:

For now I ignore the fact that there are 3 connections active, before the back4app support comes back to me, but I wonder… Should not the URLSessionWebSocketDelegate call didCloseWith when it seems that Xcode knows that the connection was closed?