One of the really surprising things we've iterated towards with Hatchet is a lock-free, single-threaded scheduler for each tenant. I started reading up on
@TigerBeetleDB recently and it's remarkable how many commonalities there are.
The first one is that this works because the core workload is inherently contented (queues in our case, accounts in the TigerBeetle example). By design, queue items compete for slots on workers, rate limits, and global concurrency rules. Being able to configure these types of "global" scheduling rules are a core part of the developer experience when using Hatchet and compared to queues like Celery.
But the more workers, rate limits, and queues you add, the greater a chance of a collision between queues, which means that running each individual queue in its own goroutine (which we used to do) just leaves you waiting around for >10ms waiting on other queues to schedule. And you need locking in some capacity (or you move the locks to a different coordination layer like messaging), but means lots of threads are just waiting on lock frees.
The single scheduling thread is the "hot path," so obviously you can't be doing things like reading from a database on the hot scheduling path; it adds too many milliseconds of latency. So instead, database writes are batched and sent from the scheduler in chunks. This is very similar to the TigerBeetle architecture of amoritizing the overhead by batching at many layers. It's a similar story for reads; we can parallelize reading from many queues at a time, so we do spawn a pool of goroutines in that case, and pass them into the scheduler over a channel.
The core insight here is that you can easily get to hundreds of thousands of schedules / second on this single thread, to the point where it's not even close to the bottleneck in the system. When we rolled this out, it also massively reduced CPU on our schedulers. And the primary benefit is that you have a single thread which is managing all scheduling state, and you have simple reads and writes on the other side.
There are of course a bunch of differences; we're a Go shop, we're not statically allocated at startup, we use Postgres to store durable state, etc. But this part of the workload in particular has a bunch of similarities.
Some more reading on TigerBeetle here:
github.com/tigerbeetle/tiger…