Like let's say a ton of web hooks come in from some third party like you get all these notifications that emails have been delivered. All of a sudden your user interactions waiting behind a 100 tasks that are not you know mission critical. How exactly on convex is theuler of a component different from theuler of your whole app. Totally. Um that's a good question. So don't just take a read dependency on the individual documents that you read. You actually take a read dependency on the range of documents you read. The workflow is probably one of the more fundamental components and yeah, arguably should be built into convex. I think it's actually an interesting question of like what does it mean to be built in? Like what is the platform and what is the API for convex? It feels like every time you work on a compil, what about that? What about that? Before I know it, like we're reinventing CFKA or some massive piece of technology that's got, you know, hundreds of people are working on it for like years and years and years. Luckily, some of my pain can can help other people avoid these sort of situations in the future. Truth is one of those minefields that could sneak up on you as you start to get to that absolute critical part of your business. You're starting to reach traction and it might not show up until that point and you're like, "Uh-oh, this would not be obvious cuz this is low level. This is below convex." Ian, how's it going? It's going well. Good. Thanks. Thanks for joining me today. Um, I want to do a video on a deep dive of a topic that is very interesting to me, which is the workpool, which essentially allows us to have queuing based mechanics on Convex. But before we go into that, I would like to first explain who you are, what you do at Convex, how long you've been here, sure, and a bit of backtory on yourself. Yeah, I've been convex for a number of years. My name is Ian McCartney. uh I joined uh in this kind of interesting role of like uh developer experience which you know my role now exactly yeah um and so I wasn't actually building the core product I was kind of like one level above it's like how do people use it what are the kind of like what is the you know user experience design when your users are developers anyways fast forwarding um I'm now yeah like working on a mix of things uh there's like the libraries uh like convex helpers that that sits on top of convex as well as components which are built on top of the primitives which work pool as I want to talk to you today. Yeah. Let's talk about that workpool then. So I think to begin with maybe let's just kind of like explain what the workpool is and why it's needed. Sure. Yeah. Yeah. So um for you know people when they get started with convex they get started with just queries, mutations, actions and a lot of times you drive that from the client and by driving I mean you send up the like use the use action use mutation use query hooks. Um and when you want to start having these asynchronous processes uh theuler is the kind of primitive that you that you have available to you and you just say scheduler.run run at a specific time or run after zero is the kind of like run this next and that allows you to escape out of mutations and run effective code or something. Yeah, like uh kind of good it hand off from a mutation to an action or um a mutation to another mutation when you do like recursive chains and stuff. But the the issue is that um the scheduleuler is just kind of one big Q, right? And um cues are great for handling bursts of load uh but they're not great for um like having one big queue is not great for for user latency. So if you have um one big uh que, right? And you're you know and let's say one of the things that you're doing is a user sending in a message and you want to generate an LLM response. Yep. Right. And so um for the user they have a kind of like you know it's like thinking kind of like chat bubble um and they're like this is very much in the uh perceived latency of the um navocation. And so even though you're kind of like you're doing a mutation where you're saving a message and then you encue something onto the stack um and then this ends up like running an action uh which writes to the database um which you know creates the response through a query. uh this act this you know works really well and and and generally like goes quickly but the problem is when you have all of a sudden a burst of um load like let's say a ton of web hooks come in from some third party like you get all these notifications that emails have been delivered because you sent out you know 100 emails to some background job all of a sudden your user interaction is waiting behind a hundred um tasks that are not you know mission critical. Yes. And so the workpool is in a sense saying, "Hey, can I just make a bounded bucket where I put um certain types of tasks so that the user interaction can stay?" Yes. So that things that are in the critical path um don't get bombarded with a cue that could potentially become pretty like unbounded just like um so you know it's a async throw it on the pile and it'll get done abstraction. Are you familiar with like CFKA channels? Uh is it kind of like the same thing? Can I have like a particular channel of a stream that I can push stuff into and it will it will it will then guarantee that that if there's something in that it will then get executed as opposed to being on one giant great big list. Is it the same sort of idea? Maybe we want to get underneath the covers a little bit like because these are running in components like how exactly on convex is the scheduler of a component different from theuler of your whole app. Totally. Um that's a good question. So um components isolated environments uh functions are executed um and they have their own database they have their own file storage and they have their own scheduler. Um but that scheduler you know the thing is that you know nothing is uh there's always some sort of constraint or always some sort of bound. So they have their own scheduler but you know they the scheduleuler they're all competing for the same resources which is you know these these um isolates these you know kind of little virtual uh machines that run. It's got to run a computer somewhere runs the code and there's there's uh limits to how many isolates can be running at once for a given deployment. Yes. Um, and you know there's different limits for uh the free tier and pro and you know the fancier your plan gets the more kind of like dedicated and like increased resources you are available. Um but just to be so you know you have your component it is can use theuler in with the same API um and it is isolated in that it can only read its own uh and cancel its own scheduled jobs uh but it's it's very much the same going into the same queue and so uh I guess we can get into a little bit like what you know what does work pool actually do to make this possible which in some ways is is a is implementing um theuler Um but the the principal um principal piece here is that the work pool collects the work into a table and has a configuration for the max parallelism and it just had it makes its responsibility to go through and be uh starting tasks and uh marking them as completed and kind of handling the life cycle and making its way through the queue. Uh but it's very much like the scheduler with the added with a couple added features. So one um feature is that you can set the parallelism limit. So um and you can just say like hey I only want to have 10 of these things running at once. Uh which you know I I don't expect to need more than 10 for all my like email uh inbound handling. And if there's a huge queue I'm happy to do them just just wait longer. Yeah. Yeah. Um and another uh is this kind of like onmplete handler. So call backs. Yeah. So the scheduleuler you schedule something but you can't tell it what to do once that thing is done and you don't you know you have to be querying to see what what the status of it is. Um versus this allows you to specify a function that is guaranteed to be run. um it's not you know maybe your function failed uh but it will at least you'll at least find out that either it failed or it's succeeded um and get some context that you can use to it's almost like a workaround triggers or something like that isn't it by having this ability to listen for something being done. Yeah. Yeah. And it's actually so the workpool is the um layer that the workflow component is built on top of. We should talk about that because the the workpool is probably one of the more fundamental components and arguably should be built into convex itself. Well, it's I think it's actually an interesting question of like what is it uh what does it mean to be built in and what is what does it mean to like what is the platform and what is the API for convex? I guess the question is like is workpool uh part of the platform? Is it part of the convex kind of like core offer? OS or something, right? And it's like um is it a core primitive that is, you know, uh hidden behind an abstraction that you can't modify like um no, it's it's not it's not built in. The scheduleuler is the thing that's built in. Um but I think it's actually like um it's been a really like provoking question of like how much of the platform can be built using components and libraries on top of the core primitives. Um, so that files, vectors, yeah. Yeah. Could potentially have been components. Have you had components at the time? Totally. Like file storage. Um, yeah, absolutely. Could be a component, right? You you write to it. It stores it in S3. It stores some metadata in a table. In fact, the system tables that we have for storage and and scheduled functions, um, both of those could be component tables, right? like theuler with a couple primitives that we've been like kicking around about like how how would how would we build theuler as a component and how would we build file storage as a component and um I think it's like it's interesting the the direction that some companies go of trying to absorb more features like behind their kind of core API um versus be very like library oriented and it's like on top of this API there's you know many different ways of doing things right like Um the way that migrations are managed and run um for many uh like mature database platforms is that you you run some script or some collection of utilities that some other company has made. Um and in the same way that our like the migrations component that I like made a while ago I made a version of it before we had components but now there's the component version. Um, it's absolutely something that you could just clone and you could make yourself and there could be a version of it that you prefer because of your workflow. And the fact that you can build it as a layer on top, I think is actually like really great versus being like, oh no, it ought to be we ought to bake it in so that it's, you know, kind of sealed forever sort of thing. And it's important to note that this is a conscious choice by convex to do it this way as opposed to let's say the more batteries included firebased way where you've got orth and you've got you know push notifications you've got everything all baked into one single API. ComX is making the choice to have these components do a lot of those extra bits on the side. And so their question is is the scheduleuler, how much of auler would you need um could you put into a component? So for me it's like the workpool component is is almost like a name spaceuler. It literally could be except for a couple of other bits and pieces like you said. Yeah. Um before we actually go there, um does the workpool actually that problem that you showed before about say you have a whole bunch of web hooks come in, does the workpool solve that then? So if you have a 100 web hooks come in, does it solve that chat dot dot dot issue? Will it be able to execute its stuff before executing those web hooks? Yeah. Yeah. Yeah. So the um Yes. Yeah. Yeah, I guess to to really close the loop on that. Um the yeah, the nice thing is that you can have a a work pool that is dedicated for that asynchronous batch processing of emails. Um and you can also have a work pool for, you know, that is doing your all of your LLM related things and you can size that one differently. uh and by doing so when the emails come in they just aren't getting processed as fast as they would have been in order to save the headroom so that your application always has available um isolates. So how does convex do that? Does it does it say that components get priority over applicate like and how does it how do you prioritize which components go first? This is just where the uh um it's it's just the abstraction. So instead of actually using theuler uh whenever when all these email web hooks come in we don't say heyuler like start all these things and then you know have the priority handled that way. We actually save that into its own uh the the workflow has its own table of work that needs to get done and it itself says I'm not going to be I'm not going to have more than 10 things in flight. I'm going to wait until one of them is done before I start the next one. And so this from the scheduler's point of view um it's only seeing 10 work items at any given time uh coming from this sort of application. Uh and so it doesn't need to kind of have um a sense of priority. It just executes everything that's on its plate if that makes sense. Okay. So you'd have to rearchitect your web hooks to be using a another workpool. Yeah. Yeah. Yeah. using a and this is where um I have ideas for for ways to more efficiently use a single work pool with keyed cues and and all that all that jazz but right now you can have multiple instances of a component. So you could have multiple instances of this work pool where one of them is handling these async tasks and that thing can just have a huge backlog and it can be like oh I've got a two-hour backlog and then you can have another work pool that is you know um you only are using for like the low latency things and you never expect to be at capacity unless there's some you know big issue and then you can make decisions about how long uh it's is it still worth doing this request. Um and so there's no mechanism at the platform level for for a workpool. There's no secret API the workpool saying hey I've got a higher priority than you other workpool. It's up to you as the developer to say okay your max parallelism is 10 and the next component's max parallelism is 10 and it's up to you the developer to add those up. Okay you've got a maximum of 10 here maximum 10 here maximum 10 here. Therefore you got a maximum of 30. Yeah. And therefore that should be enough for your current convex plan to handle. That's the way that the philosophy is supposed to work. Yeah. Exactly. Okay. And um to you know just get excited about u some of the things that I've been I've been thinking about here is like um I think it would be really valuable for a single work pool to be able to handle a lot of cues within it so that instead of you saying oh well I you know I I these all have separate limits and I just hope that they all stay within um some limit so that I don't you know run out uh making it so a little bit more structured uh within one workpool. So you could say hey I have a global limit right for you know and these are limits that are published in the docs it's like you know a thousand node actions can be in flight at a given time and it can have some of these global limits in mind and set a lower threshold so that there's always some available to the rest of the application uh and it can manage the kind of like global limits and a per Q throttling rate limiting yeah and and you know like it makes it a lot easier to in to incorporate things like priorities and to I mean because in some ways that's what's that's what you really want right for some of these things it's like you know can it just be one cue but have some things just have higher priority and the background stuff only happens when that's not not happening and in order to do that it's much easier when that's just in a single component that is kind of doing the the multipplexing uh instead of having many components that then need to somehow be aware of each And I've said it before, it feels like every time you work on a component, I'm always trying to go, "What about what are this? What about that? What about that?" Before I know it, like we're reinventing CFKA. Uhhuh. Or we're, you know, we're reinventing some massive piece of technology that's got, you know, hundreds of people are working on it for like years and years and years. So, I guess one thing to to just to point out here is that we're trying to cover the most common use cases for the most common types of apps, but there's always actions and there's always third party services. So, you could use some cloud-based version of CFKA or something like that. You could run it in your own Docker container and call into that from an action if you want like some super powerful cues. It's still all possible to do on convex through a third party service. It's just we're trying to cover like the most common use cases. Yeah, totally. There's nothing that prevents you from from an action hitting any thing, right? You can you can use all the technologies that you're familiar with and uh and there are certain things there are certain like convex superpowers that make it really valuable when we can provide like convex native versions of that. And so for instance like with uh the workpool with workflow with these things where there's asynchronous processing happening um it's really valuable to be able to have a reactive query on this data to say hey what is the status like I I started this job but like what's going on is it running is it not running did it fail did it um and the fact that within convex when queries call queries there's like this you know cascade of subscriptions that happens that such that the front end can just be like reactively updating to you know what is the state of that AI chat kind of like progression and all that and all of that can just be going along without you know everyone needing to know about everyone to emitting events and having to listen for those events have to unlisten from those events blah blah blah blah blah which to be fair is still possible with external systems right you call out to an external system that then calls back in with a mutation it uses the API it writes in and then that is the reactive layer. Some people like use convex as the kind of reactive glue between their asynchronous processing happening on whatever platform and their UI which wants to um kind of have a materialized view of of the data with authentication and all all that jazz. Um but we've yeah we could we could talk about the the philosophy for a while. Um I want to like answer your question of like how it actually works. Yeah, let's do that. Yeah. So the current existing this is the current this is the current um architecture. Yeah. So there's a number of tables. Um the when you when you incue something into um the component, it goes into the work table. Uh and this has you know the function the args the you know onmplete the work it's like the work table is in some ways just in the ideal case it's just kind of written once it's written at the beginning. Um and then there are these other tables which basically represent cues. Um so there's a table for uh called pending start. Um, and this kind of keeps track of like when I think I can't remember what it's called like maybe like run at uh which is like some number uh and it has the work ID and um some other things but the when you're going to add something um you're not actually doing the work up front. All you're really doing is you're you're adding it you're appending it to the table and you're specifically you're inserting it at the time where you want it to be run so you can schedule things far out in the future. That's what exactly what I was going to ask is like if I was to do this cuz I'm an idiot. I would do it very naively. I would say I would just have a single table that is work and I would just have a status on there. Is it status pending? Is it status complete? Is it status retrying? Why have we got a separate table pending start? Um the well it's a yeah it's a good question. It's one of the um one of the little optimizations here is that uh the way that convex works um is you have all of your documents and when they're written as a row every time you modify it it's written as a new copy of the document is it's you know multi version. So the way that convex handles that is there is uh when you do a write or an update in convex, it copies a new copy of that document, right? Then with all the the fields and um that allows us to do kind of like uh snapshot based um multi-version history where you you can have reads and queries that are looking at you know an older snapshot not conflicting with new writes that are coming in. M um which is why queries never conflict which is kind of an interesting a lot of people and that's that's what allows comx to have that illusion of single threadedness um but it's actually can run multiple mutations serializability yeah multiple mutations simultaneously. Yeah. But so what that means is, you know, when you have um really big arguments to a function, it'd be really nice to not be rewriting those arguments every time it's, you know, moving its way through the queue. And if you have really big arguments, if you have like, you know, hundreds of kilobytes, uh, you know, there's a limit of of one megabyte, which is, you know, worth calling out because the document size per convex is one megabyte. And I haven't done anything to, you know, split up documents and save them as multiple documents and all that jazz. But like, um, so if you're passing a massive great passing thing, wronged arguability, right, for it to be like, oh, I'm just trying to move it from it's about to start to it's running and now I have to worry about how many of these things can I read because each of them could be like these like time bombs of like 500k, right? And I I don't want to be like reading more, you know, than 8 megabytes, you know, in a transaction. Yeah. Am I skipping ahead a little bit? Does it work? Does the workpool work within an action or within a mutation when it grabs like a chunk says, "Okay, which ones do I need to execute next?" Yeah, cuz that's what it needs to do. Does it do that in an action and with a mutation? assumingly like like you said if they're like a megabyte each like you got a massive big system prompt in there you're going to blow up that mutation max size or the max size and that's that's one of the um constraints here uh that is um being worked on it and the current architecture is using a mutation y it is reading a chunk of work and then it is going and trying to kick off that work uh and ideally it can kick off that work with just the work ID and there's a currently like a wrapper mutation or wrapper action that will you know like hydrate all the like big arguments and pass those in and get the return value and call the uncomplete with the big return value so it doesn't have to store that in you know uh storage and pass that through the system as much as possible um and so the pend all these other Q tables are in a sense just ways of not having to rewrite the work table they're in these cheap little documents that you read, write, delete. Um, and they are just conveniently uh small for you know performance and cost reasons. Okay. Okay. So, it's nothing to do with OCC in this case then. So, OCC being like the currency control issues that we tend to run into there. Yeah. Is that Sorry. Go on. That that's actually that's another um big consideration here is just like um these tables also represent um ownership like write and read ownership and like um by being really precise around who are the people who would possibly be writing into this and who are the people who would be reading and what might invalidate their their reads. Um, it makes it easier to reason about the optimistic concurrency control sort of issues to be like, hey, I know that this is being written to by random mutations from all over, so I need to be careful about reading from it. Um, but I can safely read from the work table because it's never being updated. Mhm. um I can read from it uh you know within the workflow and I I know that it's not going to be changed and then it's not going to cause some like um database conflict sort of like invalidation. I think I'm not quite 100% following you there on like who's reading and writing but maybe it be easier to give me an example of like say okay what's the core loop of let's start from like I put a piece of work in yeah and then how does it actually get executed just take me through that journey. Sure. Yeah. So the um the client has a call to you know NQ. Yeah. And this is a this calls into a mutation within the component um that basically writes it into the work table uh adds an entry into pending start and then it has to do something which is is a bit nuanced and is is something that actually like I'm I'm looking to improve is it has to decide who starts the work pool, right? like is it running or do I need to start it and what is the state of it? Um so let's say that you know it's not running yet then the ENQ will also kick off uh the kind of like main loop for the work pool. Yeah, because this is all serverless. It's not like we have some demon running on a Docker container somewhere, right? You know, we can expect that it's running. This is serverless. So, yeah, we've basically got a piece of function somewhere running or not running somewhere. So, you now need to work out whether it is currently running. Yeah. And what you and like So, I'll let you carry on. So, there's the like, you know, we'll call it the loop. What's it? Yeah. Call it the loop. Yeah. What is it reading? So, what is it looking for? it what it uh what's this loop doing? The loop will read from things that have started, things that have finished, things that have been like the pending start, pending canceled, and pending completion um tables are kind of its you know it it reads from those to see what has changed about the world and then it has its own kind of like state about what all is in flight. Y subtraction, right? It's like, oh, I have this many opened. Okay, we'll take care of the completed ones. That means I have a bit more space. I can like take a few things off the queue and I can start them. Like all that's pretty pretty straightforward, right? Um, and so there's so if there's something in the pending start, it will grab that piece of work, put it in theuler, to execute that whatever the user gave you, whatever function it gave you with that big args potentially. If there's something in the pending completion, it will then look into the work again, look to see if there's an oncomplete handler and call that uncomplete back to the user and say, "Hey, this piece of work finished." Yeah. Yeah. And then pending pending deletion, it would then delete it out of the work. Um there there isn't a pending deletion. There's a pending cancelled if you so when you cancel something you that there's another where like who are you know what's the race? Are you if you're trying to cancel something and something's being tried like trying to run like uh are you going to be in a uh like a a fight of like if you're trying to cancel a bunch of things and it's trying to run a bunch of things, are you just going to be fighting each other? And so the cancellation is also this cue where it puts in like hey like a request to cancel and then it the the loop can kind of be the decider of like does this thing go forward and not either I'm going to see that it was observe that it was canceled or observe that it was completed um and have a you know make some guarantees that like it's not going to say that it was canceled but it actually ran and like that. Does the loop handle retries as well? That's yeah. So the when a a task is executed right now they're executed inside of these like wrappers. So the wrapper mutation uh or wrapper action uh that you may see in the the logs is is you know just like running your function inside of a try catch uh so that it if it fails it can immediately call the uncomplete with the failure and you don't have to like wait to observe that okay for later. Okay. And also when it succeeds it can call your uncomplete um you know very quickly like and for mutations it's not in the same transaction somewhat intentionally but um arguably maybe it it should be but like it can call it with the return value without passing that return value back through the loop. Okay, so the pending and then the pending completion table is in some ways just saying by the way you don't have to worry about me anymore. you can count me as completed, but it doesn't want to go in and mess with the loop's state of saying this thing is done because then if the loop if everyone's telling, you know, writing to uh the internal state that it's done while the loop is trying to read that state and modify it itself, then the loop's just never going to make progress. Um because the, you know, the the TLDDR on on these serializabilities. Yeah. Yeah. If you if you have a mutation that is reading documents Yeah. or a range of documents and those documents are written by somewhere else then it will uh not go through. It'll be a a transaction conflict and it will be retrieded if it's it'll be retrieded for some number of times um if you're running from an action to a mutation and it will be retrieded indefinitely if it's being run within theuler. um for better, for worse, but you really don't want those conflicts because that's just wasted work and then you have to start all over and then all of a sudden you're not sure why the loop hasn't run for a little while because everyone's contending to everyone's so eager to to update the status and so in some ways you need to add these these cues and these buffers as ways to collect the work, handle it in batches and then um complete it. If we had an ability to um segment only write a part of the data and only read a part of the data with like a select statement or like only write like a couple of fields, it would negate having to have these separate tables, wouldn't it? Just as a complete tangent, but has convex ever thought about doing this like having like I did do a whole video on select. Yeah. Yeah. But like if we could we wouldn't need to to jump through these hoops of of splitting your fields up effectively if we only had to. Yeah. Yeah. There's I think there's there's optimizations you could make to really have like really fine grain um invalidation logic for like uh only select these fields so that you can guarantee they didn't observe the other fields that did change. Um and then you also need to track on the change side what changed and what is you know what does change mean when you like submit a document but a field didn't change you know is that did they happen to not write that or did they intentionally not write that uh or do they write the same value you know very intentionally so um it's the you know it's one of these you know uh like affordance things where like um yes like in some ways is you know because you can do it in user space it could be done right like um but the that it's just it's just a performance thing of like the tracking on that granularity you need a really efficient algorithm in order to do the intersection and that intersection needs to be really fast because it's kind of the uh the thing in the critical path of uh committing like the final committer I was about to say that it will slow down the committer and that committer is single threaded so yeah the committer is is the like um the oracle and that's it blesses the transactions. Again, I'll link to my how convex works video where I go into that in detail, but um that is single threaded and so if you try and make that thing do more work then effectively you're going to slow down your throughput of the entire system. Yeah. Yeah. Yeah. So you want that to be efficient and the way you do that is you segment your your reads and your rights uh using tables or other or other mechanisms. Um which actually you know one of the a couple of the kind of like key insights here of that uh were not present in the initial prototype of the work pool uh and that we've kind of like developed over time um is kind of around how you how you handle these contentious read sets. So you could imagine uh let's say you had like you know uh five things added to the pending start table and you're in the loop and you're trying to decide oh I can start 10 things right and so you um you make a query right you might like naively let's say like um you know at the time of the mutation like uh you you say hey from from um give me all of the latest documents ments, right? And you like you are looking back and you're like, "Okay, cool. I found five." And then you go and you start try to start five. And that that all sounds great. But while you're running, if something had inserted a sixth, um, this is like a a kind of a cool princ like a cool aspect of the convex serializability is that like you don't just take a read dependency on the individual documents that you read. You actually take a read dependency on the range of documents you read. And so because you said give me all the latest, this is now one of the latest. So it's like, oh fail. Yeah. So then oh now, sorry, we're going to run that again. And you're like, okay, great. Now I've got six. And then while you're doing that, like a seventh coming. So that loop mine ever and then you're you basically like you have to wait until there's all there's 10, right? And then you read 10 and it says, "Oh, cool." like um you know and the so one thing you know obviously you you actually want to start from what is the earliest one going forward right and so you say um give me the first 10 items to do right you wouldn't you probably wouldn't unless it's like you know uh uh first in last out you know you usually want 5O for your for your cues so you say give me the first 10 so then you know once there's 11 or 12 you can safely read the first 10 and it's going to go through but That really sucks cuz then it's like there the work pool is basically idle until the maximum number of jobs is available. Yeah. So, um, how do you solve this? So, that's like one of the one of the cute tricks here. And this is, you know, where some of the hopefully some of my pain can can help other people avoid these sort of situations in the future. This is one of those minefields that could sneak up on you as you start to get to that absolute critical part of your business. you're starting to reach traction and it might not show up until that point and you're like uh-oh it goes down on a Friday night or it slows to an absolute crawl for reasons that you just don't know and it's like some subtle little thing like this selecting in the wrong way or selecting only five and yeah you know that kind of thing. So how do you solve this this problem then? Yeah. Um, so there's a few Yeah, there's a few tricks that we employ uh here and some and actually like you know like some of these tricks I would love to uh get rid of and there's there's both things that we can do at the platform level to get rid of these tricks and there's um patterns that can actually bypass some of these tricks but we maybe let's first talk about like the messy solution magic the where we live in today Um so one thing you can do is when the loops when you start running the loop um you can pass it hey by the way like this is the time for which I expect you to be um looking right and so it can bound that so it doesn't say like hey give me it up until now right it says give me up up until sometime oh so give me give me the next 30 seconds worth of things that come um yeah like up until it could even say you know Like one thing you can do is when you incue which we do a little bit is like when you in Q you write it now um and you have the loop run in like 100 milliseconds right um and or 200 milliseconds 250 milliseconds then you ask the loop to kind of like look backwards to to like to say hey actually like um the thing that you're looking for is already going to be you know observable um and it's less likely that a right is going to be coming in, right? So once you bound the time then um you know let's say you had five oh and like a six snuck in that was like that got inserted within the range that you were reading. Bummer. you retry but that seventh if that seventh is being inserted at a time later then when you retry like it won't included in that range right it's not like you didn't you couldn't have read it because it's not in the range and so the transaction does not conflict as a result so and the the important thing is every time you retry you don't want to have this be relative to now you want this to be a stable part so every so if it did happen to keep getting like um conflicts it's slowly moving backwards in history and eventually it's like no one's writing things you know that far back right so how do you do that then um so it just you pass the to the argument to the loop you say to have this is the segment that I expect you to so the loop has to be an inner mutation and the loop is a mutation that the loop is a mutation that has an argument about which which segment and the segments are just like 250 millisecond uh chunks of time. Yes. Um and it says which segment are you like responsible for? You're responsible for that and previous. So there's an outer loop and an inner loop. Is that right? No one loop. So when you call NQ it's going to call loop and so responsibility passes in time. It says like hey um this is now this is the time. Yeah, this is now. And the loop keeps rerunning and it says like okay this is this is now. even though um that it it kind of knows that it's falling further and further behind in time and it can actually like log these things to be like hey I'm I'm like you know I'm kind of falling behind here because this is the actually I haven't covered in any of my videos yet is is the what is now because I always say that queries mutations are non-effectual but you can still use date now so uhoh you've now brought in an effectual piece of thing into the code so then how do you handle that well the the gotcha with that is is date now is the same no matter where you call it within your mutation. That's right. But if it retries that date now does change. Y so then that's this problem manifesting right here. Yeah. So you can basically pin date now and you and you look backwards and you know part of this like what is now like when you insert something into the database it's inserted with a creation time of your transaction uh date. And that transactions date.now now is marks when the transaction started, right? Not when the transaction finished. Yeah. And so if you have transactions of of varying length um and you know we we put some bounds on how long a transaction can be um although we measure it in CPU cycles so that you know there isn't like some um you don't randomly run into this. Ideally it's deterministic. You're always going to hit it or you won't. Um that means that you know if you have something that's like takes 800 milliseconds and it writes um it could be writing something a little out of order right like when I read five things um something could then be inserted like back in history potentially at the very beginning of the queue um and that's a bit of a challenge because then you don't really know like when can I you know when can I stop and there's there's some hard limits of like you know we uh you can't commit something more than like 4 minutes in the past and like that's basically impossible to achieve anyways but like how would you do that? you I yeah it's um I don't think it's cuz what's the maximum I mean like maximum run time of the mutation is what 1 second it's 1 second but it's the way we measure it um allows it to like you know that you don't pay for that time where we're starting isolates and we're like going doing like component boundary stuff when we're uh like database latency um certain things um so anyways so the fact that things can be written out of order is kind of a pain here. Um, and the loop will just retry. The loop retries, but that's why that's not the end of the world as long as it doesn't happen continually. Yeah. And so the the, you know, heruristic here is that you you start the loop in the future and you have it look like, you know, um, in the past, you have it look like a little staggered in the past. So that there's going to be a little you like eat a little bit of latency in terms of like the fastest like an action could make its way through the system, but what you get is that you have a lot fewer retries in the loop um because it's it's a lot more stable. Yeah. Um so that's uh one of the tricks. That's that's really cool. That's and we I mean we're already quite a long time into this video, but that's really cool. Um, should we talk about another trick or should we uh talk about potential future improvements? Um I guess well let's let's talk a little bit about just like the uh maybe like one pattern uh that comes up more often and like that this is kind of solving that I think like is is generally applicable to everyone sounds which is you know um just this whole like when you have many things all trying to contend on resources all trying to update a document or all trying to um have some you know like an example is like when you're tracking costs right and you have a you know have a bunch of things that are all trying to update like what is the total cost right then you know the the solution often is you don't have them all do the mutation you don't have them all do the change within within that mutation right you you have them all write the operation that they want to have performed into a table and then you have some asynchronous thing like the workpool kind of like pulling them up. And so um by instead of like you know when you're in queuing something decrementing the count of available jobs you just you just write it and you find one dedicated um entity to do the reading and writing of those of those kind of like batch entries. Um so that's that's one important piece. Um, and the other uh little little gem of wisdom that is is both it like can get a little tricky is when you have a table that you're reading and writing that you're writing and deleting from frequently uh you can end up with uh an accumulation of tombstones. I thought you were going to say tombstones. Yeah. Yeah. So um is this is this a storage level issue? Is this like a MySQL Postgressy underlying issue? not necessarily a convex issue. Uh you know you could argue about like what sorry explain the issue first. Yeah. Yeah. So the issue is when you know in the same way that you every time you write a new version of a document you write a um a new row right you say um in the same way when you have a deletion um you write a row that says this document is now deleted. So yes, this is a common thing with databases. You might not be aware of in Postgress at least. Um when you actually say delete, you're not actually deleting that row. You're actually just putting a little flag on it that just says I've now been deleted. And Postgress will then hide that row from you. Save Convex will just hide that row from you and just imagine it's deleted. It hasn't actually deleted yet because deleting that is actually a potentially um expensive operation just like garbage collecting is in JavaScript for example. you might end up with holes in your memory, for example, that can um just cause frag fragmentation and stuff. So, usually they have an operation at the end that goes through and cleans through and that's I think you're going to get two tomb to tombstones. Yeah. Yeah. So, the tombstones, you know, and they're somewhat of a uh temporary problem, right? Like we only need to keep them around for like um 4 minutes. Yeah. Um but that and the the you know the the bounds here is any transaction that is open that might be trying to like when a transaction starts like a query it has like a snapshot timestamp and that might be before the tombstone happened and so from its perspective that document still exists at a specific version and it it needs to have some guarantees that it will be able to read the correct version of history at that time stamp and so we say you I think like four minutes is it used to be like 15 minutes or something. But you know you have this window in which old indexes and old like just you know document contents themselves need to be present as if they still existed. Um so you end up with this these tombstones. So, but when you have a lot of these rights happening really fast within, you know, this like 4minute window, you can have a lot of these kind of like this space in the table that convex isn't charging you for, but it's having to skip over. It's having to just like blow by all these to be like h like no tombstone, where are you? Okay. Oh, there's something, right? It's got to do sequential kind of like Yeah. Yeah. And so, when you structure your query, you could say start at time equals zero. Um, and then you might just be like reading a whole bunch of time, you know, tombstones. Especially when you have these insert at the end and delete from the start and read from the start. Then it's just like the degenerate case of that. And so, uh, the trick you can do is you can say, "Hey, um, the last known thing I pulled out was at this time, right? So only read from there forward." And, uh, oh, and occasional. So you have a bound where you say only read from there and only read until now and then that's a lot tighter. You skip all those tombstones and then you have to suffer the reality that there could be an out of order right. So occasionally you need to do scan all the tombstones just to see if there's anything you know that you missed. Wow. This would not be obvious. Yeah. Yeah. That's the like cuz this is low level. This is below convex. Yeah. stuff. I think it would not be obvious to me as a developer if I if I started encountering these issues. Geez. Yeah. And and you don't you don't see it in like a big way, but you're like, man, that that query that was only fetching a few documents. Why did that take 400 500 millonds? I would have no idea. You're like, must be rainy day. No. Um you know, it's like I must be cursed. Okay. Well, keep going. Um so that's the that's the why the secret. Um and this is not a purely academic thing. This actually happens to customer. We have customers who have experienced this. They have such high load on their workpool that this happens. Um well it was happening to me when I was developing and and load testing. I was like this is not great. Uh so I that's so like I added this optimization in and you know and there's other ones that have come from you know just like stress testing and and using with with customers. Um and there's there's you know ultimately anything that's designed to handle the kind of like big like use this when the scheduler lets you down like it's going to run into all the edge cases because it's like it's kind of handling the longtail and there's yeah there's some improvements. Um, but I know there were, you know, we've kind of been all over the map here. Uh, oh, this is so deeply interesting and it's going to be an interesting one to edit this, but um, I guess maybe so you've got a bunch of tricks in here and you want to do a second version. That's kind of yeah, the whole reason for doing this video, you want to do a second version, you want to do some upgrades. Um, is it to get rid of those tricks? What's I think one of the things you wanted to do was get rid of make that loop instead of a mutation make it an action. What's the reasoning behind that for example? Yeah. So um so one of the things that this is really just like struggling with uh which is why you know the the reading needs to be bounded and like these these things that we have to do um is the fact that we can't have like what's we refer to as like you know stale query like which you know is like we have to put a special qualifier on like read something that you don't care if it changed where you know that's like the default for every other database, right? It's like um you know, read committed. Yes. Right. It's like uh so we want to be able, you know, like the challenge is how do you read a document where you say actually and like don't invalidate it if I don't care if it's a little bit old. Yeah. Yeah. Or if or if something new was added, right? And so there's one, you know, there's primitives that we've been going with about adding. Um but turns out like you can achieve that today and it is yeah with an action calling a query because as we talked about like queries execute at a time stamp they don't conflict. If you do a query and then something was added in it's like cool well the query was still valid as of the time stamp that it was like responsible for. So like no harm no foul. It's not like lying to you. It's telling you the truth at the point that you asked it to. So an action that calls a query can fetch a bunch of work. Yeah. And then it could go and dispatch all of that work and those can just do point deletes and point mutations and they don't have any sort of like no one else is updating those rows so they're not going to conflict. And um so to kind of get around that stale query kind of like missing piece uh you can do this this action loop. So that's one kind of like one issue I just thought of. Yeah. if that that would would be harder to do if you did an action rather than mutation is cancelling. So if it's a the mutation way of doing the loop, you can cancel something. If it's in the middle of the loop and you cancel something, it will just redo the loop and that won't be in there because it will have picked that up and it would have changed. Yeah. But if you do it with an action, it could still get executed even though you've canceled it. Right. Well, you Yeah. It moves where the critical section is of of am I starting this thing or not. Yeah. It used to be in the same place you could read and write and now that you've separated a query from a later mutation, you've essentially like introduced the the possibility of inconsistency. And so you have to push the responsibility down into the uh rapper thing. The rapper. Yeah. Hey, I'm going to start this. Yeah, but at that point you say, has it been cancelled? Has it been cancelled? It still be somehow you put the guarantees in there and because that is still, you know, a serializable um kind of like transaction region, you're you're good. But you do have to be just more careful about it. That makes sense. That makes sense. Yeah. Yeah. And you know, but one of the big um changes it's like the the action loop is is um in some ways just like make the core unit um a little cleaner and like you know clean up some of this um some of these tricks that we have to do. Um but then there's like all these like features and capabilities that I think are just like feel like they'd be really valuable to to bring in. So like you know right now you can make multiple work pools uh in order to have different cues but within one workpool if you could have that one workpool which has its global concurrency limit have multiple cues within it then what that allows you to do is to have dynamically generated cues. So instead of saying I have an email handler and an agent handler, you say cool, I've got a max concurrency of something so that my system overall won't get overloaded and I will have, you know, available for live user site stuff. Um, but can I say that per user I don't have more than X in flight so that one user can't like noisy neighbor or you could have a user who's got on a pro plan. Yeah. And you know, you're you're developing an app and they've suddenly paid for a higher plan and so therefore you give them a higher rate of throughput than another user. Yep. Yeah. It's like, hey, I only want 10 LLM things in flight uh or a hundred or a thousand, but I I um want to prioritize my pro customers. Yeah. Yeah. And like they're you know, there's no current way of doing that. Yeah. It's like you have to Well, you you have to make like a a pro customer work pool and a free customer work pool. And but even within the pro one, you can't say like, oh, but like try not to give all the bandwidth to just one user. And you know, there's just like it's um it's an awkward world. Um and it's hard to make it when you have many individual cues that you're managing um that all have their own levels. Mhm. And you're trying to respect a global level. It's really hard to get high like utilization out of that system because you essentially need to be running all either you set them all to be a level such that when you add them all up it doesn't exceed and then you just have to like do your best to try to utilize everyone to the max or you give them all a little bit more and you just hope that probabilistically they're not all going to stack up at the same time. And it's much easier when you have one big um shared pool and you can set those finer limits but they can still be respected within you know kind of like a global state. It's one of the big, you know, insights when I was doing like preview uh generation stuff at at Dropbox was like, you know, if you have if you're trying to like spread load across many machines and if they all have really short cues and you're pushing things onto short cues, um you it it just um it it's a lot easier when you have um more powerful machines with bigger cues um in order to kind of like have a more consistent throughput. The same way as like if you go to uh uh Denver International Airport, I don't know if you've ever flown through DIA, it they you know there's some airports where there's like five lines to go through security. Okay. And then there's some airports where there's one line and the one line, you know, it it's just moving like five times as fast, but everyone gets the same latency, right? It's a psychological thing as well. That is to some degree. You feel like, oh, the Q's moving, I actually feel good, you know? Yeah. Yeah. But like when you have the five Q's, one of them could just have a really slow person and then everything behind it just gets blown up and your, you know, P95 latency is is totally beholden to, you know, these outliers. Um, anyways, um, so you're going to bring in multiple cues inside of the work pool, the ability to effectively name namespace or whatever. Presumably that comes with other complications like you want to rate limit or tokenbased limiting or throttling or some of that. My god, Ian, I'm glad you're the one that's doing this. No, thank you. It's I mean yeah but again it's like if we can we have the benefit of of being able to make um a version of this that we believe in that solves a problem that people are asking for and in an open- source repo that if you want it to work differently like please fork it and if you want to improve it please up like send a PR to upstream it like um by building on this layer above um the core primitive It just like means that you know you're not beholden to my choices when designing this thing. Um and uh that's the great thing about open source is I mean you don't like it, you can write your own components now. So you could just go ahead fork it, stick it in your own directory and off you go and change whatever you want. Yeah. Yeah. Yeah. It's really easy with v with with components to to just vendor them in. Um, maybe we should even do a little segment on on, you know, what it looks like when you don't want to publish a package, but you do want to fork the behavior, stuff like that. Yeah, we probably should at some point. Yeah. Um, but yeah, I think we'll probably we'll probably call it. All right. Thanks, Sam. Cheers.
Say a third-party service fires off a burst of webhooks and a hundred "email delivered" notifications land in your app at once. None of them are mission-critical. But if your app schedules them the same way it schedules everything else, they can pile up in front of the one task that actually is time-sensitive, the chat response your user is sitting there waiting on.
That collision between "throughput" work and "latency" work is the problem the Workpool component was built to solve.
The scheduler is one big queue
Anyone getting started with Convex reaches for queries, mutations, and actions, usually wired up from the client through hooks. The moment you need something asynchronous, the primitive on offer is Convex's scheduler: runAfter or runAt. Those let you escape a mutation and hand off to an action, or chain one mutation into another.
Queues are great at absorbing bursts. The trouble is that Convex's scheduler gives you serverless queues out of the box, but it's just one queue for the whole deployment, and one big shared queue is rough on user latency. Say a user sends a chat message and you're generating an LLM response. That's a "thinking" bubble on screen, and the perceived latency matters. Normally you save the message in a mutation, enqueue an action to write the response back, and it's fast. But once a hundred webhook-triggered tasks land in that same queue, the user's response is stuck behind all of them. The scheduler has no concept of which tasks matter more.
Workpool exists to give you a bounded bucket for a given category of task. Think of it as carving that one big queue into several purpose-built serverless queues. Low-priority throughput work can't crowd out latency-sensitive work just by showing up in volume.
Why isolation at the component level doesn't fix this by itself
You might wonder whether Convex's component isolation already solves this. Components run in their own sandboxed environment, each with its own database, file storage, and even its own scheduler. But those per-component schedulers all still compete for the same underlying resource: the pool of isolates that execute your code. That pool is capped per deployment and varies by pricing tier. A component's scheduler can only read and cancel its own scheduled jobs, but every component's jobs ultimately land in the same execution queue. Isolation doesn't buy you prioritization.
Workpool solves this differently. Instead of asking the platform's scheduler to run many things and hoping it sorts out priority, Workpool collects work into its own table. Then it enforces a hard cap on how many of its tasks can be in flight at once. The underlying scheduler never sees more than that number of items from a given Workpool at any time. It doesn't need to understand priority, because Workpool has already throttled what it hands over.
There's no platform-level prioritization between components today. If you want the webhook-processing queue to stay out of the chat queue's way, you stand up two separate serverless queues as Workpools, size them independently, and route the webhook path to enqueue through one of them. A large backlog on the email Workpool doesn't touch the headroom reserved for the LLM Workpool, because they're enforcing separate concurrency limits against separate queues.
Workpool adds two things on top of what the raw scheduler gives you: a parallelism limit (cap it at ten concurrent tasks, for instance) and an onComplete handler. The scheduler will run your function, but it won't tell you anything about how it went without you polling for it. Workpool guarantees a callback that fires whether the task succeeded or failed, with enough context to know which.
The Workflow component, which handles durable multi-step processes, is itself built on top of Workpool, and it's probably one of the more fundamental components in that sense. But what does "built in" mean in convex, and where's the line between the core platform and the libraries built on top of it? Convex's answer leans, deliberately, toward the library model rather than a Firebase-style batteries-included one.
The scheduler is the primitive. Workpool, Workflow, rate limiting, and similar concerns are components layered on top, open source and forkable if you want different behavior.
What happens to a task when you enqueue it
Under the hood, enqueueing work writes into a work table. That table holds the function reference, its arguments, and its onComplete handler. Alongside it sit separate pending_start, pending_completion, and pending_cancelled tables that represent queue state.
Workpool Task Lifecycle
That split is deliberate. Convex stores documents as multi-version records, so writing to a document means writing a whole new copy of it. Documents also have a one-megabyte cap. If a status field lived directly on the work table, every status change would rewrite the entire document, arguments and all. That gets expensive fast when the payload is large. Keeping the pending-state tables separate and small lets the loop read, write, and delete them cheaply. It also narrows exactly who's reading and writing what, which matters for avoiding conflicts. The work table itself is written once, so it can be read without contention.
Enqueue also has to decide who kicks off the Workpool's core loop, since there's no daemon sitting around in a serverless environment. The loop reads from the pending tables to see what's changed and tracks its own state about what's in flight. An entry in pending_start gets grabbed and handed to the scheduler to run the user's function. An entry in pending_completion triggers a lookup of the original work entry and a call to its onComplete handler, if one exists. Cancellation gets its own pending_cancelled queue. A cancel request can race against a task that's already running, so the loop has to decide, correctly, whether to honor the cancel or admit the task already finished.
Every task runs inside a wrapper, a mutation or action that executes your function in a try/catch. Failure calls onComplete with the failure immediately. Success calls it with the return value. That's also why the design leans on pending_completion as a cue rather than a direct state mutation. It lets the wrapper say "you don't need to track me anymore" without touching the loop's internal state and risking a conflict.
Why the loop has to fight its own database
This is the part of Workpool's design that generalizes well beyond queuing.
Convex's serializability guarantee is stronger than a lot of engineers expect from optimistic concurrency control. A query doesn't just take a read dependency on the individual documents it returns; it takes a dependency on the range it queried. Ask for "the latest N pending tasks." If another process inserts a new task into that range while your transaction is still open, your transaction conflicts, even though it never touched that specific document. For a loop processing a busy queue, a naive "give me the newest N" query can conflict over and over as new items keep arriving. That stalls the loop instead of letting it make progress.
OCC Conflict Retry
The fix is to bound the time range the loop is responsible for on each run. Rather than asking "what's pending right now," the loop gets handed a fixed window, something like a 250-millisecond slice. It only reads within that slice. New writes landing outside the window can't invalidate the transaction, because the query never claimed a dependency on them. The mutation also pins its own notion of "now" so that retries hit the same segment instead of drifting to a new timestamp each time. Convex mutations have a maximum runtime and a bounded commit window. Starting the loop's window slightly in the future and looking slightly backward trades a bit of latency for far fewer retries.
The same pattern generalizes past queues. Say many producers all want to update one shared aggregate, a running total cost. Having each one mutate that aggregate directly means constant contention over the same document. The fix is the same shape. Producers write the operation they want applied into a table, and a single asynchronous worker (a Workpool, in practice) reads those operations and applies them serially. One writer owns the aggregate; everyone else just queues an intent.
Tombstones are a real, if obscure, tax on high-churn tables
With multi-version storage, deleting a document doesn't erase it outright. It leaves a tombstone, retained for roughly a four-minute window so older snapshots can still be read consistently. On a table where documents are inserted and deleted constantly, like Workpool's pending-state tables under load, tombstones accumulate inside that window. A scan starting from time=0 has to skip over all of them, which slows down as churn increases.
Tombstone Bounded Scan
The workaround is to remember the last timestamp you successfully read to and query forward from there, rather than re-scanning from the beginning each time. That skips the accumulated tombstones. You still need an occasional full scan to catch anything that arrived out of order. But bounded reads avoid the degenerate case where a hot queue's read performance degrades as it processes more items.
This is exactly the kind of problem that's invisible at low traffic and shows up right when an app starts gaining real usage. It lives below Convex, not something your application code would normally have to reason about.
What might change next
One direction under consideration is converting the loop from a mutation into an action. A query executes at a fixed timestamp, so it doesn't conflict with newer writes, and that's the motivation. An action that calls a query to figure out what work is pending could read without triggering the same OCC pressure the mutation-based loop manages around today. The action would then dispatch individual point-update mutations rather than one mutation contending over a shared range.
The tradeoff is that queries and mutations are no longer atomic with each other once you split them that way. A mutation-based loop can read and change state in the same transaction; an action-based loop can't, which opens a window for races, particularly around cancellation. The responsibility for checking "was this canceled?" would move into the wrapper that starts a task, right before it runs. That check still sits inside a transaction small enough to stay serializable, just no longer the same transaction that decided what to run.
The other direction on the table is support for multiple queues inside a single Workpool: one global concurrency limit with per-key or per-user limits nested inside it. Today, getting that kind of fairness (per-user caps, protecting against one noisy tenant, giving certain customers more throughput) means standing up separate Workpools for each case. That makes it hard to guarantee a single global limit while keeping the pool fully used. A shared pool with finer-grained limits inside it would behave more like a single well-managed line at an airport than several separate lines that go idle or overloaded on their own.
Rate limiting and token-based throttling are on the same wishlist. Think the existing Rate Limiter component, but folded into Workpool's own concurrency model, covering most cases by default while staying open source enough to fork if it doesn't fit yours.
None of this requires abandoning Convex's serverless queues for asynchronous work at scale, either. You can still reach for an external system, a managed queue or your own service called from an action, and use Convex purely as reactive glue between that system and your UI. But that gives up something specific. Convex's reactive queries mean subscriptions cascade automatically, so a frontend tracking an async job's progress updates without you wiring up any eventing yourself. For asynchronous work tied to UI state, that's the payoff a plain external queue doesn't hand you for free.
All gas, no breakages
Convex is the reactive backend platform that keeps up with you and your agents. Database, functions, workflow, sync, search, file storage, and more. All TypeScript, zero glue.