architecture 2026-08-26

this is only tangentially related to clojure, so bear with me. i maintain a turn-based web game written in clojure. we keep all info about lobbies and active games in a single atom. this has worked well over the years but we see some lag and cpu spikes when there's more players/activity (for example, during tournaments). we've tried using threads pools to split the work, but i think the source of our issues is contention on the atom. even tho most updates are pretty small, they're bigger than a single swap! call to inc. how do other games/active-connection systems handle this? how do you store data about what's active? what do you do to alleviate contention?

💡 1

I thought this sounded familiar, https://clojurians.slack.com/archives/C03S1KBA2/p1745539828509539. Did you try any of the advice from that thread?

☝️ 1

i completely forgot about that, let me check before i delete the whole thread lol

i tried refs/agebts and those didn't help much

Ok just wanted to say I did something with agents in a situation like this. But I see you already tried

we rely almost entirely on websockets (using sente) and i found switching to agents made it harder to send reply messages in a timely fashion

have you profiled?

yes, let me see if i can pull up a flame graph

if it really is contention on the atom, you could keep the atom but reduce contention by forcing single threading in someway (locking, throwing all the work on a single thread executor, etc). feels weird, but if it is the contention it would eliminate it

➕ 1

very similar in some ways to the agent suggestion, but maybe less code changes and restructuring required'

a classic approach to reduce contention would be sharding in some way, which is sort of a formal mechanical way of turning 1 mutable bit of state into N mutable bits if state

👍 1

many of these suggestions are also in the linked thread

a more radically different architecture I like for chat server kinds of things is something more erlang like, instead of having mutable state you have processes communicating and keeping their own state, which can look like a kind of sharding, so a process per connection, per user, and per room.

another approach to reducing contention is that if you have update functions that only modify parts of a nested structure is to cache the results of updates on the substructure. This isn't the best implementation of the idea, but should still be effective:

(swap! atm
       (fn [m]
         (update-in m [:my :nested :path] (memoize f) arg1 arg2 arg3)))

This can speed up retries for slow functions on nested data where there isn't much contention on different parts of the nested data.

that is really interesting, it sort of bespoke shards by using memoization

I use this technique for my IDE and it made a big difference. I had a problem where there were many frequent quick updates and a few slow updates. The quick updates would always beat the slow updates and the slow updates were stuck in a loop recalculating over and over.

👍 1

a similar kind of thing would be to use delays to make changes fast and cheap but you pay for that on reads, but only reads of those sections

👍 1

> we rely almost entirely on websockets (using sente) and i found switching to agents made it harder to send reply messages in a timely fashion @nbtheduke do you mean it was harder because of the async nature of agents? In my case I also used it in combination with promises. So something like

(let [prom (promise)]
  (send my-agent (fn [v]
                   (let [ret (do-change v)]
                     (deliver prom ret)
                     ret)))
  @prom)

time to write everything as core.async.flow.

my irc server (https://git.sr.ht/~hiredman/resderelictae) does the process based state management using flow (old version, and I do some gnarly extensions)

hmm @jeroenvandijk that's probably a better approach than what i did when i tried.

(await won't get the value at that point in time, but it depends if you are the results of operations or just syncing the current state if you need that)

i can't find the flame graphs anymore (slack free tier ate them a while back)

i'll see if i can get the dev with server access to generate them again

late to the party, but i'd second lock and volatile. i used that technique to reduce overhead for circuit breakers in fusebox.

👍 1

done right it's conceptually similar to an atom. you're only using the lock to reduce cpu cycles.

👍 1

i've decided to try agents again, and so far it's looking reasonable. (it's very nice to treat the app-state as immutable data inside all handlers.) i'm confused about send vs send-off. is sending a sente websocket message blocking? or hitting mongodb? we're not using core.async or other async systems

apis should either not return until an operation is complete, or return some kind of promise like thing you can block on until the operation completes or takes a callback to call when the operation completes, the first is blocking, the second is non-blocking.

i'm not sure how that applies so maybe i'm misunderstanding something. server-side sente gets a message from a client and passes it to our handler (a multimethod). certain methods call the sente functions to send new websocket messages to specific users. these methods also update the app-state as appropriate.

It worries me a little bit that it sounds like you're picking a solution to improve performance without an explanation for why it's slow in the first place.

for example, the :lobby/join method adds the given user to a lobby, and then sends a message to all users who are looking at the lobby list. the first part is an update to app-state, and the second is a "side effect"

well, for better or worse, no one who works on the app has access to prod, or any analytics.

the guy who owns the server has refused to give up control so we ping him when we want to update the server to the new code lol

Can you run the app locally?

yeah and we can't recreate it

Could you replace all usages of the atom with an in memory db?

Something like sqlite