2

We are developing a multi-user web-based application, where the users can join a "room" and a complicated handshake has to be set up between them, to be able to use a library on each frontend side. This session/room logic and the handshake that has to be set up goes through a backend server with WebSocket communication. We are using a WebSocket based RPC library. I am a junior backend dev and was tasked with developing this core backend logic, handling connections and disconnections and supporting the appropriate handshakes.

I developed this backend, but by developing the backend, of course I basically also developed the whole communication protocol: how various method calls should come in order to build up the handshake. I paid a lot of attention to avoiding race conditions by messages coming in wrong order. I even paid a lot of attention to how the frontend library we are using would affect the order of messages. I made some kind of documentation, and explained our protocol to the frontend guy, to let him start work on the frontend part of this communication.

However, he did not really understand it, or even when he claimed to understand my explanations, he could not reason out the answer himself to his questions (i.e. when various WebSocket listeners have to be set up on the frontend, how to set up various data structures based on data from the backend, what the actual arguments of a method call to the server need to be), and in the end I ended up basically instructing him down to the detail how to write his frontend code for this. Part of the reason seems to be that while I have a BSc in Computer Science, so I think about this task in terms of concurrent programming, race conditions, interleaving threads, mutual exclusion, etc. he is a self-taught or bootcamp graduate, so he does not have these mental concepts readily available.

However, this can't go on and he has to be able to reason about our protocol, and he should be able to perform any future changes on the frontend without bringing back concurrency issues that I worked hard to avoid. Additionally, we will probably interface with another team at some point, who will develop a client for another platform. My backend tech lead is going to represent us in these meetings, and he is not completely up to date on the details of my work. I am thinking, how could I help the frontend guy, my tech lead and the other team with more documentation?

Currently, we have the following documentation:

  • A UML sequence diagram detailing the basic happy path of communication between three clients and the server. The diagram shows method names and parameter names, differentiates between method calls and return values, is color coded, is split into named interaction segments, and has a key.
  • A remote method reference for both the server and the client. This currently includes parameter types, and an informal description of the use case of the method. It looks something like this for server methods:

JoinRoom(roomId: string)

You as a user should invoke this method to join the room with the given id, once it is started. The return value is a User[] detailing data of users in the room.

KickOut(roomId: string, userId: guid)

You as an admin should invoke this method to kick out users from the room. The return value is a ResultCode.

SendFoo(foo: object, recipientId: string, senderId: string)

You as a user who joined the room, should invoke this method to start the FooBar handshake with the recipient. The return value is a ResultCode.

SendBar(bar: object, recipientId, string, senderId: string)

You as a user who got the Foo message, should invoke this method to finish the FooBar handshake. The return value is a ResultCode.

SendBaz(baz: object)

Sends the Baz info for all the participants in the room.

And looks something like this for client methods:

UserJoined(user:User)

This method is invoked when a new user joins the room.

UserLeft(userId: string)

This method is invoked when a user leaves the room.

Foo(foo: object, recipientId: string, senderId: string)

This method is invoked when a user starts the FooBar handshake with you. You should answer with the SendBar call.

How can I extend this documentation to be more helpful to everyone else? I realize some things are not covered that lead me to some ideas for extending the material:

  • Since the sequence diagram is a single timeline, complicated error paths are not covered there. Maybe I could extend the diagram to be an interaction overview diagram which would then reference smaller snippets of the sequence diagram. PRO: this would show error paths CON: I think interaction overview diagram could be even more overwhelming than a simple sequence diagram
  • The method reference doesn't actually help much with the race condition aspect: I could spell out preconditions of methods more exactly:

SendBaz(baz: object)

Prerequisites: received Foo, sent SendBar

Sends the Baz info for all the participants in the room.

  • More info on how to interact with the FooLib library we are using on the frontend and how to store the data (even though it is not really my area of responsibility):

SendBaz(baz: object)

Prerequisites: received Foo, called SetFoo in FooLib, saved the Foo to foosByUserId dict, sent SendBar

Frontend implementation notes: this should be called from the onBar event handler of the FooLib. The handler should be set before calling SetFoo

Sends the Baz info for all the participants in the room.

  • Adding a short description of these concurrency issues specific to the app to the intro of the documentation, explaining the issues without assuming everyone knows the technical terms

Which of these ideas would be the most helpful?

4
  • 2
    Protocols of these type are usually kinda final state machines. Sequence diagrams are usually quite bad and unreadable with state machine behavior (especially if it comes to multithreading and concurrency). Commented Dec 17, 2023 at 21:20
  • Seems like you're doing a fairly good job of documenting? But look for opportunities to simplify the design. Did you implement your own RPC protocol on top of WebSockets instead of using something existing (like GraphQL, gRPC, or plain old HTTP)? It seems your protocol is stateful – are there ways to minimize that? Avoid "prerequisites", instead use tokens to establish causal connections. Also consider making a clear distinction between actions that require a response/acknowledgement and events that don't. Event based systems might be more powerful, but can require complicated state machines.
    – amon
    Commented Dec 18, 2023 at 9:22
  • @πάνταῥεῖ Thanks, I did not think of that! state machines might make a lot of sense here!
    – staccato
    Commented Dec 20, 2023 at 20:05
  • @amon Thanks! We are using Microsoft SignalR, so no own RPC protocol there.Yes, minimizing statefulness makes much sense to me. One thing I am not sure we need a token in each call for, is client identity. We pass a token once when we connect to the WebSocket, and the connection has this state from that point. What do you think about that?
    – staccato
    Commented Dec 20, 2023 at 20:51

2 Answers 2

4

UML sequence diagram detailing the basic happy path

Yes! That sounds exactly right.

If you're going for least-common-denominator, "anyone" could follow this, then maybe incorporate step1, step2, step3 as part of the method names? So it will be obvious where we are in the protocol, and obvious if we've left out an important mandatory step.

Now let's talk about the unhappy path. Your computer science responsibility is to make the protocol bullet-proof. You "paid a lot of attention to avoiding race conditions", so maybe the documentation should spell out lock holding responsibilities? That is, "component X must hold a mutex before Y() is called," that sort of thing.

And when an unhappy event does occur, convince yourself that a helpful diagnostic message will be visible.

Consider encouraging developers to crank up the logging level when they're trying to understand what's going on. Provide an annotated example log so they know what to look for.

The reason we request that other folks review our essays and our software modules is because a fresh set of eyes will see things the original author either missed, or wouldn't think is something that needs explaining. It sounds like your interactions with the FE guy are the first external review this code is receiving, and you're learning important details about what needs to be better explained. So the FE guy is helping to improve the product documentation, if only indirectly. He is testing the documentation, verifying that it clearly communicates what you intended.

Recommend you solicit additional code reviews, of the sort which is usually done prior to merging down a PR to main. Additionally, it sounds like documentation is an important part of the product, and you already have evidence that it has been less than completely adequate, so recommend you solicit documentation reviews with stakeholders. It could be e.g. with your boss. Additionally invite an engineer or two to write up brief review remarks.


specific methods

The JoinRoom and KickOut methods seem fine as described. I note that there seems to be some global identity object (which has an identity.isAdmin() predicate). The documentation glosses over that, and maybe that's OK and your audience already has appropriate expectations and knows what to do with identity. OTOH you might possibly want to be more explicit about it in the docs.

Supplying a roomId to JoinRoom impresses me as the most natural way to parameterize this (given implicit global identity). Having read your description, I am left wondering if I should keep re-joining same room to obtain an up-to-date User[] list of guids.

Consider having JoinRoom return a room token. (On the inside it would probably contain details like roomId, identity, timestamp.)

SendFoo(foo: object, recipientId: string, senderId: string)

You as a user who joined the room, should ...

That's not a completely natural way to express the API. Consider demanding that caller supply a valid roomToken parameter. Then it doesn't matter if someone read the "joined the room" part of the documentation. You can't even begin to make the (erroneous) call unless you have a token in hand.

SendBar(bar: object, recipientId, string, senderId: string)

You as a user who got the Foo message, should ...

Same concern. Make me present (evidence of) a valid Foo message. That way I can't even begin to mess things up on my end. We're just verifying that the prerequisites are in place. Think of passing these tokens around as a way of keeping State Machines on both sides in sync.

SendBaz(baz: object)

Sends the Baz info for all the participants in the room.

Same concern. I would love to see a roomToken parameter in there, clarifying which room we're talking about, and demonstrating that caller meets the prerequisites.

UserJoined(user:User)

UserLeft(userId: string)

It's unclear what the FE's interest in "current room population" might be. Maybe we just play a doorSlamsShut.wav file, to alert the user? Or maybe there's other concerns, like we want to display an up-to-date roster of room participants. To address that, consider sending both "Alice left the room" and a User[] list of who's still in the room. It's redundant, it's more bandwidth, but it might make the FE guy's life easier, especially in cases where temporary internet outage made us miss some events.

There seems to be a currentRoomId global which implicitly participates in this Public API. Consider making it explicit.

CON: I think interaction overview diagram could be even more overwhelming than a simple sequence diagram

I am inclined to agree. Plus, sometimes those diagrams don't get updated to match the evolving code.

Worry more about offering good informative diagnostic error messages, which steer folks back to the Happy Path.

More info on how to interact with the FooLib library we are using on the frontend

Consider offering a javascript client Reference Implementation, or at least working code in a fully functional Tutorial.

short description of these concurrency issues

I'm still unclear on the details, but that's fine. What I would look for in the docs is "must hold mutex X" or similar prerequisite, and then in the code maybe X is a parameter, definitely X will be verified, and the diagnostic "you're not holding X!" needs to be helpful. Maybe have it mention the URL of a documentation web page which discusses X details.


You're going to make another pass over the code + docs, good. I'm sure it will improve.

Next step after that? Get one or more people to write up review remarks. Prioritize them and iterate.

1
  • Thanks for your insights! This was exactly the kind of answer I was waiting for, loads of great points there!
    – staccato
    Commented Dec 20, 2023 at 20:49
1

If you go back a few years, there was a technique that almost exactly matches the requirement for having a hypertext protocol between a client server where the application logic is built on complex state.

In it, the responses represent the state of the application, and links in response represent transitions between state.

So if you are worried about 'how various method calls should come in order to build up the handshake' you are worried about 'what are the navigations away from the current application state'.

So you add a _links field or similar to each result and that describes what transitions are available from that application state, something like:

JoinRoom result

  • room._links are to the endpoints for SendFoo
  • room.users[]._links includes KickOut if the current user is an admin

KickOut result is just the status, or redirect to the updated room

SendFoo(foo: object, recipientId: string, senderId: string) - depending on intended semantics the link to SendFoo could be part of the room's links or part of the User's links - result has a link to bar. this is the only way the client gets to the bar endpoint.

SendBar(bar: object, recipientId, string, senderId: string) - The result ResultCode.

SendBaz(baz: object) - would be available in the room's links - result is status code again

This is a traditional REST and the 'web application as a web of links' used to be the difference between REST API and a JSON API *

On the one side, you now don't have to document that you can't call function Z if you haven't called functions X and Y first, as there is no direct link to Z to traverse. On the other hand, you have to switch your thinking from a 'calling methods' to 'traversing state' approach, and which some devs just don't seem to get.

* last time I shared that blog with someone they didn't even know that the author was the Roy Fielding who invented REST, so might be considered an authority on the subject, even if the trend has gone against him

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.