There's a couple lies about time. It's actually true of like just about every database that you use. When you call date now, it is not now. And [snorts] very common thing to get wrong in convex. Very common cuz people this causes OCC errors like galore. You you end up having gaps. You end up like actually missing things. I do hope people are following this because this is subtle. This is This is subtle, but it it is really powerful. And is that a future mic problem then? I think that's a future mic problem. So, yeah, I'm committing [music] to it now cuz it's on camera. Yeah. Ian, how's it going? It's going well. Thank you for joining me again today. The whiteboard, the second time that you've done this. Uh, for people who don't know, Ian is one of the absolute go engineers here at Convex. He is responsible for many of the low-level components like the workpool and the workflow components which are probably some of the most used convict components and when we spoke last time we were talking about the workpool component but you felt like there were some maybe missing parts of the platform because I think some of those missing parts of the platform have been filled out but I want to start off with something spicy which is like time and is it like a lie in convex like how does time work Because I've heard that if I call date now multiple places in a convex function it returns like an unexpected value is that or different values values. Yeah. So um time is an interesting thing especially when you deal with databases and transactions and ordering and determinism and there's a lot of like properties that you kind of want in a database and you want in a runtime that is trying to have all the things that convex has. So for instance, when you have a query, we really care about determinism and having not no side effects and the ability to like have these be really well encapsulated and really transactional. So within a query, if date now kept returning different values each time you called it and was kind of like exposing how long the underlying system was taking to run, then every time you ran it, you could get different values um at each place. And that's not great when you want to have uh something that you can run multiple times and get a deterministic output from. And the more that we have determinism, the easier it is for us to have like really robust uh tests and evaluation that you know prove the correctness of a system. So backing out from that like is time a lie? Like in some ways we've introduced a little bit of a lie. When you call data now it is not now. It is it is a time that has been assigned to the transaction at the beginning of the transaction and every time you call it we give you back the same number within queries and mutations actions we say hey you're in a world that is uh less deterministic and has side effects and all that. So we're we're more we're more honest in that in that sense when in an action every time you call date now you'll get the latest time. Yeah. Like in in a normal JavaScript function if I say x= 1 day now uh x= 2 day now those two values are going to be different. Yeah. But if in convex you do this inside of a mutation for example they'll be the same value which is yeah but we need to do that because of to keep it deterministic remove effects from there. But knowing the time is useful uh particularly if you're trying to build a queueing system where you need to be able to uh accurately map the time to be able to go back to select the next batch of things to do. Is that correct? Yeah. Well, there's a couple there's a couple lies about time and this is not just uh specific to Convex. It's actually true of like just about every database that you use. Um, and you know, in some ways I think there's like three times bouncing around in in the in a system and we'll talk about queries and mutations and that context because you know they're the most interesting. So there's like the date now uh time. Um, and then you'll also notice that when you insert documents into the database, you get a creation time field. That's the one that gets added to every single row along with ID. Yeah. Every document that you have will have a dot creation time and a dot ID. And if you insert many documents, um, we actually kind of like move the creation time forward just a little bit so that when you insert them ABC, they show up ordered in the database ABC. Um, so within a transaction, this is somewhat related from date. now but it's sort of kind of like advancing forward. Um but I said there's three times at play and the third time is the databases time. So if you imagine you have many transactions that are all running at the same time when those all commit they are committed in a serial order and convex is kind of you know pretty powerful in this way that a lot of databases don't guarantee you know serializability but because we have serializability we can actually like commit them in a serial order and that serial order has almost nothing to do with these two times. um it is a time that is is kind of like on the database's clock and these are coming from the like what we would say like the wall clock. So let's say your functions running um in some on some machine in our what we call our like function runner uh or funr run is like the the cute name for it. Your mutations running on that machine and it's deriving what the current time is from that machine. But big distributed systems being what they are, not every machine is perfectly um in sync. And when the commits for all of the transactions are happening and being committed serially, the time that is being moved forward there has some nice properties. The all of the transactions that are getting committed are moving forward in time. Whereas if you were to run different functions on different machines, they they might have a little bit of a skew. And if you were to have um multiple transactions happening um in parallel and some are longer than others, I had said before like we we pin the date now or we like assign date now to a fixed value for the whole transaction at the start of the transaction. So this has date now of you know like five. This has date now of like seven. But this one might actually finish earlier. It might just not take that long and it might actually get committed before this one. And as a result, creation time for this document with that's based on time 7 may end up getting inserted before a document that has a time of five. So the the lie that a lot of databases have is that whenever you have a field that is kind of auto assigned at insertion time. So whether that's in Postgress having like um the default be like now or my SQL or you know any any SQL variant or in context with the creation time the fact that you can commit it and read it out before the transaction has finished. I don't know if you've you've noticed this. Yeah. Yeah. I think to make me understand it. So we can say add a new document here. So you go db do insert here and obviously it's not actually inserted it there yet because this this can roll back correct. Um but we still need to give it a time like at that point. Yeah. And you can you can do a database.insert here uh for some document and then you can get it. And so we decided we will expose what the creation time is. But that means assigning it a time stamp uh before we really know when it's going to be committed. So we say that this is you know like 5 you know 01 or something. And here you could insert a document and then retrieve it and you have 7.001 or something like that. And um and that's interesting because you have this like time scale that is not directly related to the insert time. And bringing it back to workpool, the problem that you can run into is you get these what like outof order commits. So you have all of these different transactions inserting documents into a database. Yeah. and you're trying to walk through um all of those documents and you might think that you've processed everything up to creation time 7 7.00001 0001 and you say cool now from now on hopefully I only need to look for documents from their forward but that's not true because this one might commit later and say oh sorry like um sorry I'm late to the game but like I actually have um some older times that I already told them in the transaction like you know we don't want to change this time because like you may have made a decision about uh what to do based on what the time was here or I might be thinking for as a viewer like so what it doesn't matter as long as it's it's contained within here but you can do like schedule you can say schedule something to happen like call another function and you can pass this creation time because you made an assumption that that creation time was actually actually accurate based upon this but it might not be and so you might have scheduled something to happen like based upon a false premise of what time is yeah and I guess that's what work has problems with um because of this yeah well um sort of Yeah. And so the the so the way I said there was three times. Here's the two times. And the third time is, you know, if this was transaction A and this is transaction B, then there's the kind of like database commit time and the database is assigning timestamps in its clock by processing, you know, a bunch of transactions in batch. And so it might process B and then A and then, you know, whatever else comes next. And there's actually, you know, a time that is being tracked here in nanconds, which is different than the date. Now scale. Yeah. This has this nice property of it's actually always going forward. So you know, it's always Yeah. Because there's only one committer and it is um ensuring serializability and it is writing things in a way that just has this nice property that's always moving forward. So, when I said that like it'd be nice to be able to scan these documents in order and never have to worry like was something inserted into the past, this time the like database time, um we'll call it the commit time is this third time that's kind of like behind the scenes. And you know, grand reveal, that time is now exposed in a way that you can actually insert into your documents and you can order your documents by it and you can walk your documents by commit time instead of by whatever wall clock time. So let me just explain that because it is kind of it's mind-blowing thing to think about. So at this point when this mutation finishes Mhm. there is a time we're associated which is the commit time. the time that that lands inside of the log of of everything that happens. But you can use that time inside of the mutation, right? How how can we do that? Like yeah, I mean it's and why why do we do that? I mean, yeah, the why um there's a whole bunch of great things you can build with this. Um you can all of a sudden you can insert documents into the database with essentially uh a monotonically increasing number which allows you to walk documents exactly once in a in it like in an X order. So for instance let's say you had a an updated at field in your in your documents and very common thing to get long convex very common cuz people causes OCC errors like galore. Well, uh yes, that too. For now, let's say that you want an updated field. Yeah. And you want to every time you modify the document in a meaningful way, you'll bump this field and you want to have some background job that is walking all of the updated documents and maybe updating Alolia, your your text search or your it's uh sending those deltas doing something for every new document since it last processed. And when it says, okay, what did I last process? it, you know, wants to be able to say I like got to here within the updated at index. So you might have like a index by updated at and then you say, hey, I'm going to use my updated at field. And the thing is that if you're just using regular time for that, you can have this out of order. If you're saying, hey, I'm going to set updated to date.now, Now then these things can end up being just out of order enough that when you say oh I read up to document X. Oh something actually was updated and inserted just before that right after or right while my transaction was processing. And so because these two are a lie. Yeah. A subtle lie like we're talking like in the order of milliseconds generally right but it could be larger. And because that lie exists because it is not sound. Yeah. That's where you can run into problems with queuing and you can miss things out. And and this is something that uh for people who have like built systems that have seen a lot of scale and they've had to like debug like why isn't this working the way I expected it to? If you have your own updated app field in just about any database and you're like setting it to, you know, uh current time stamp or now or any of these like you know database internals, you you end up having gaps. you end up like actually missing things. Um, and it's actually really difficult to get this correctly unless you're like reading from the databases like write ahead log or that you're hooking into these like core internals that are just, you know, you definitely can't read that from your OM, right? You can't like read that in a way that's like that JavaScript can meaningfully handle. So what we're what we've introduced is like within your database you can have a field updated at and you can the validator for that is v commit ts and this says hey this is going to be a value of when it was committed. So this is a a valid like you could actually use this on your convex database schema. Yeah this and this is like a different type or is this like a number or is this what is this? Yeah, this is a um so we'll say we have like a users table. We're defining our schema. You have like define table and you have you know the name and whatever this update field is a v.getit ts and the underlying type here is a big int which is because we have these times in nanconds like storing that many nconds inside of a number you end up losing precision because JavaScript numbers float 64. Yeah. Not that you know it's still not big enough like it's still not big enough. So big int and which is an int 64 in convex in the underlying representation. It's a big int or it's the commit ts placeholder which is a mouthful but um ah okay so this is how we can use it before it even exists. So but we can't do numerical stuff on it. So we can't compare one is bigger than another. Um can you like yeah great question. So what when you insert a document that is using this updated at with a commit ts and we can actually like you know talk about like what that looks like but you've inserted it for the rest of that transaction it's the placeholder cuz we don't know yet and we don't want to like lie to you and say like and make a guess about what it's going to be but we say oh sorry for the rest of this transaction if you read it back from the database sorry it's still the placeholder um so you can't compare it within the transaction to something else. But after that transaction commits, then it becomes a an actual big int. And that becomes really powerful because like it being an actual big means that you can actually define an index on your table. Let's say like by or ID updated at uh where we have um actually like a multi-dimensional index. So you could say like, oh, like I'm going to have an an index that's first sorted by this user's like org ID and then it's going to be sorted by the updated at um and that means I can like walk the documents for each organization in updated order. Um and I can order it by update. Yeah, just like any other field, it has a concrete value. You can read it. You can do math with it. You can um you can actually insert a raw big inant into here if you if you want. Yeah. Um and you can be sure that they are actually in the order that they're committed and therefore you can no never walk the data the table and miss miss rows. Okay. So the the way that you um that you use it at runtime is, you know, when you're doing like a DB insert into like this users table, uh you have all the other fields like the name and everything or maybe you're patching a document. Um you can set updated at to db.barss bars commit ts. Yeah, technically you could have just put in a regular big int here, but like that's not going to have the prop. Like the fact that it the validator was commit ts doesn't mean that it's always going to get replaced with the commit ts. What says that it's going to get replaced with a commit timestamp is um this kind of magical placeholder. So this has the type of this commit ts placeholder. I see. And when you read documents out, you can you'll see like, oh, updated at is this value. Okay, Emma on the infrastructure team did a lot of the work here and they just like really did a really complete job. So I just want to like brag on their behalf for a bit. Uh like if you were to like write something into the into the users table with this and you were to read it back out, it's the placeholder for within the transaction. If you were to return that document on the client, it actually gets materialized from the placeholder into the commit the actual value from the return. So like if you were to do like a uh try to inspect it like the line before you return from the mutation I see it would show up as the placeholder and then when it printed on the client it actually become the concrete value. Yeah. So like you didn't have to read it from a new transaction like every boundary that it can cross it ends up like magically kind of materializing. Um it may even happen if you do like a console.log where it's like you console.log the value and even though it's a placeholder it will like become the value. I'm not sure because until the end of the transaction the logs are transactional with everything else. Um, we still can print them if it fails, but like uh the logs don't get like sent out as they're as they're produced. And am I right in thinking that this is unique to Convex? Like or this doesn't really exist in other databases? I yeah this is a a a really difficult thing to achieve with other databases like there isn't a natural way to say hey like I want to have a a custom ordering within my table on a field that gets materialized at commit time. Yeah. like that is a very rare property and like very powerful whenever you're trying to have these sorts of like make these ordering guarantees because we're trying to be sound we have to do this sort of thing to be able to build cues on top of convex so I guess naturally the question is is like do users need to care about this like 99% of users even need to yeah crap give a crap about this yeah I mean it's it's definitely uh an advanced feature yeah it is um I don't think it's exclusively useful for kind of like really advanced use cases like you know updated is a is a pretty common one that somebody might want. But if you're only ever looking at if you're not using the updated app field in a way where you need to have these like strict boundaries that have like are totally hermetic then it's not as critical and it's comes out as a big right. it's not as natural to just throw that into like new date and just, you know, um it's not that hard either, but it's definitely oriented more towards when you care about that serialized order. Um you one thing that like is common is you have some sort of updated field and then you have a cron that says, "Hey, um if this thing hasn't been updated for a year, we're just going to delete it." And at that point, you don't need it to be a really like serial order because the thing is so old. It's not going to have an outof order commit. It's not like somebody's gonna be like, "Oh, sorry. I forgot to mention this thing was updated a year and 2 days ago." Once you're past like 5 minutes, there's not any real risk that something's going to get inserted out of order. So, if all of your queries are looking more than 5 minutes in the past, then like these properties aren't as important. It's really important for cases where the order matters like where you're walking through and trying to do batches. I'm trying to think of another example where this might be important. This is you know related to the workpool which uses this um yeah so work pool is convex's queuing infrastructure component um low-level feature. Yeah. But built on top of convex itself. Yeah. You can insert things. You say hey I want it to run at a certain time. It orders those by that time and it walks through all the things that need to start and it starts them. So you can kind of see how being able to walk through all the jobs that need to start in order and not start any of them twice and make sure that not miss any if something was inserted out of order then you you not having to worry about that. So that is one place um that is useful and it's built on top of this other component which is new which is called the batch worker which allows you to um essentially define two things a query and a mutation. The query grabs a bunch of work that needs to get done and the mutation does stuff with it. Yeah. And uh you can kind of signal to it like hey I have no more work to do. Um, and it sounds like a relatively simple thing, but the thing that it uh ensures is that there's only ever one version uh of that query and mutation pair being run. So you can have something that just inserts a bunch of work that needs to get done and just like pings the batch worker. If the batch worker needs to run, it'll make sure that it runs and that um it does a bunch of kind of like subtle things under the hood to ensure that this all like works smoothly uh within convex and doesn't you know have OCC's and doesn't have so dedicated video that another time but the batch worker a lot of times you know what you do in with a batch worker insert a bunch of work somewhere you query that work and then and the mutation deletes that work and then you go back and your query tries to grab the next batch of work that needs to happen and oh all those other documents were deleted so it can skip over them and it grabs another batch of work. Um, and the challenge there that you start running into when when you have like many thousands of documents being deleted every second, say, is that you can have so many deleted documents that even though they're not technically like returned to you by the query, under the hood, like Comics has to scan all of these deleted documents for some period of time because we keep around what are called tombstones. Yeah, this is this is I think we talked about this in the last video. Tombstones were like a real whoa. This is like implementational detail, but it really does become important like when you're trying to deal with scale and and queuing and big batches of things like like you said thousands, tens of thousands of these things. This is yeah it's it's often a preop premature optimization for for most people but when you start running into it you start wanting something like commit ts and and the reason is like maybe you grab a few things and then you mark them as done and then the next query it wants to grab the next couple things and until we've kind of like garbage collected these uh the query is going to like you know take a little bit longer each time it like wants to grab the next thing because it's like starting from the start and so you say oh Actually like what I want to do is if I've processed up to here I just hold on to a cursor or a point in in a like a pointer to the middle and now you want to use commit time because so that becomes your guaranteed time. You insert things using the commit time and you read them in that order and you are guaranteed that if you read something with a commit time of five uh nothing will ever be inserted into the database with a commit time of less than five. I do hope people are following this because this is subtle. This is this is subtle, but it it is really powerful. Yeah, arguably if we had this commit time when comics was first created, would creation time be the commit time? Well, we there was actually a bit of a discussion. How do we want to do this? We could have we had maybe three options. One is the option that we went with. We assign a time and then you can read it back immediately. The second option is you assign a time, you can read it back immediately, but we secretly advance the time when we actually write it to the database. So if you were to read a document and it said like 5.01, and then a later transaction could read it and be like, "Oh, no, that was I'm seeing 7.035." Um, and that we decided was would have been really convenient because we could have given users what they conveniently want in a transaction, which is to know what the time might be. And we get to have this nice property at the cost of like a bunch of potentially subtle problems. Like if what if you store that what if you read that value out and you use that for something or you stored it somewhere else or you know there's all these ways that the incorrectness can kind of leak out. Um, and the third option would have been to do something like this placeholder, but then you have this funny thing where, you know, it's a bit awkward to be dealing with a placeholder because like you read a bunch of documents from a database and some of them might have concrete values, some of them have the placeholder values and so everywhere that you use those documents, you have to do like is the Yeah. You have to be like, is this a number yet or not? For pragmatic reasons, we were like, hey, people mostly care about a regular number that is comparable to date now that is ordered in the order that they inserted it within a transaction. But if two transactions really race, it's like a little bit okay that you know that that happened because most of the time you don't have to worry about it. So that's problem is it when when you start having tens of thousands of documents being written at the second and then you start to want to have operations that that walk through them and you start to run into these other subtle issues. Yeah. Yeah. And there's a bunch of other ways we could have tried to do this. Like with other database systems, you can't store this in a field, but maybe you can extract like, hey, what was um the time that this thing was committed? And you read it from like the write ahead log or you like get it from other subtle places. But I think it was actually like very uh convenient to be able to store this in a field, be able to you could store it in a nested field. You could have and you can decide at assignment time, hey, I'm not going to update my created at field, but I am going to update my updated at field and I'm only going to update it at certain times or I can manually rewrite it. So, it gives you all the flexibility of it being a field, but it uh has this property of being a placeholder other times. So just to reiterate again, do you think that most people should be using creation time for like the updated at or is it more sensible for them to be using, you know, date now for updated at? What should they use if they're building a new convex deployment tomorrow? Yeah, I think if if you want that nice ordering um you can use the v.comit commit ts. But if a lot of what you're doing is you're it's kind of just like an aesthetic property and it's or not super aesthetic but you're just like hey this is mostly a time I want to show it to users. It's like but I'm not this the ordering of that is is less important. Um I think using date now for your updated app field uh feels more natural and it it's comparable to creation time. It's comparable to date. Now you can immediately create a date from it and create like a parsible string. Um you don't have to like translate from nanoseconds to milliseconds everywhere. So I would say you can you know keep using date now for the most of these these situations and you can reach for this when you want to and in the future you could even make it you know a union of you know I was about to say that can I migrate to it later safely? Yeah well it's it's actually something that I've had to do for the workpool. Okay. Um and so the answer is yes. Like the same way that it can be assigned in a field, you can have a votion of a commit ts or a you know float 64 or a v number. Um and you can you know start assigning the the later ones. Okay. And that's a future mic problem. Then that's a future mic problem. There is there's this awkward time where you have both you have the two numbering systems but um you can you know figure out a migration strategy there a yeah [laughter] exactly it's Astra's problem. Yeah. Yeah. So Ian is there anything else coming up with related to time that we could talk about? Um I think you mentioned there might be some new APIs coming that were even more subtle perhaps that we could talk about. Yeah, it's um yes, there's there's a little extra bonus that's um in in flight right now that is essentially saying okay um I have data now I have the wall clock times from when my transaction started and I have the database time for when my current transaction finished and I have the the wall clock time of previous transactions. I can read previous documents and say, "Oh, they they were started at this time." Um, but if I want to say within here like what commits can I actually see? Like if I were to read all of the um documents uh by commit database order, how many of them should I be able to see? You could say like, "Oh, just look at the latest one." Yeah. The way that Convex works is like when you start a transaction, you are assigned like a database snapshot. you say I'm going to read everything in the database from this one snapshot and that snapshot is actually on the database's clock because it is the snapshots's like time stamp and um so at some point in the in the past all of the commits that happened it's like okay when I start this transaction I'm going to start from you know some older commit and all previous things and so what we're introducing is uh the snapshot ts it's like the the time inside the database the TS that my mutation started at. So it's like this date now but actually real no lie but it's yeah but it's not a lie it but it points to something you know it's not assigned like right at that time it's the whatever the previously observable last transaction was. This might actually skew from the wall clock if there haven't been any commits. This will kind of stay pinned in the past as things move forward and ah so is this gonna be another placeholder? It it's not a placeholder and it's not available in queries because if you read it in a query then every time you run the query that number is going to be different so it makes it hard to cache. It is a concrete number but it's a concrete number that points to something in the recent past the like the point the last commit that was observed. And why do I want that? So you want that if you want to say hey by let's say I'm using my updated at example I want to read documents that have already been committed and I don't want to conflict with documents that are being inserted. I just want to grab a batch of documents. So you could say hey read everything where I left off last time. Let's say I worked up to a time stamp like um let's say I like you know you had all these updates to um the users and this one um you processed up to commit time like five and since then there's been one new commit and you say okay I want to read all updates after time five that's fine um but if you read from now until the end of time. Then if anything is inserted while your transaction is like in play, then it will say, "Oh, sorry. Like something's changed since your transaction started. We need to rerun your transaction." So you have an OCC. So what you can do with the snapshot timestamp is you say, "Hey, when I started my transaction, I was only at like database time um six." And you so you can bound it. You can say, "Hey, read everything since I last ran this this update until like now, but now in the databases." I got it. I get it. I get it. So, you say everything from five to six and then your your transaction grabs just this element. It's okay that this thing independently uh committed and it processes. It does not OCC and then when it runs the next time, it starts from six and it can move forward. So it allows you to move forward in time safely without knowing where am I without having to say grab the next 250 milliseconds worth of rows like rather than looking forward in the future you can look forward to an exact known point in the future. Okay. Yeah. But in looking in the past but yeah okay it's it's yeah instead of saying hey start my search from a little bit in the past to try to not have out of order commits you say if I can observe time stamp six nothing will ever be inserted with time stamp six while I'm running can you could you do this by like the update loop calling itself and passing the commit time in as an argument or something is that poss could you is it the same kind of idea or yes except Um the one limitation that we have today with commit time um AC I said across all boundaries it kind of magically materializes. The one place that we don't materialize it right now is arguments to scheduled functions. So if you schedule something and you pass my current commit time it's just a a current gap that like you can't pass placeholder as an argument to a scheduled function. Otherwise you you could have kind of done that in order to kind of like figure out how to how to self progress. This is the syntax that just this morning or just last night uh I sat down with Emma and James and was like, "Hold off the presses. We're gonna we're gonna find a name for this thing." And so, you know, I'm committing to it now because it's on camera. Yeah. ctx.ma get snapshot ctx. TS. Okay. Um and we went through a bunch of different names. observable commit timestamp of previous in there was a bunch of things on the table. We landed here. You're welcome and I'm sorry if anyone to be fair most people probably aren't going to need to use this. Yeah, that's why ctx meta. This is kind of like the you know kind of an advanced like you want to grab some extra details or context about the request. It's probably going to get wrapped up inside of components and then you'll use the component just like there's a whole bunch of stuff in JavaScript that you probably have never heard of, but it's there because library authors want it, you know. Yeah. Exactly. So, um there you have it. That's the that's the new upcoming cherry on top. Fantastic. Thank you. Um, and Jamie did say I think in a tweet recently that this is like one of the four big remaining things in convex like um so this is like a really a fundamental thing. There's something else that that we've been talking about internally for a long time that I'm excited about. But maybe we'll save that for next time we talk. But this has been this has been awesome. This is uh blown my mind. I knew that time was a bit special but I didn't realize exactly why it was special and why it was a problem. But this has been fantastic and thank you very much. Thank you. Cheers. It's a pleasure.
Did you know that when you call Date.now() in a Convex mutation, it's not actually now?
I knew time was a bit special in Convex, but I didn't really understand why, or why it could be a problem. So I got Ian, one of the absolute GOAT engineers here at Convex and the person behind components like Workpool and Workflow, back in front of a whiteboard to explain it to me.
It turns out there are three different times at play, and if you walk through a table using the wrong one you can actually miss rows. This isn't just a Convex thing either, it's true of just about every database you use. Let's take a look.
Is time a lie in Convex?
So in a normal JavaScript function, if you call Date.now() twice you'll get two different values. Inside a Convex mutation you'll get the same value both times:
This is on purpose. Convex really cares about queries and mutations being deterministic, so that you can run them multiple times and get the same output. If Date.now() kept changing then it would be exposing how long the underlying system was taking to run.
So in some ways, as Ian puts it, we've introduced a little bit of a lie. When you call Date.now()it isn't now, it's a time that was assigned to your transaction when it started. Actions are less deterministic anyway, so in an action you do get the real time on every call:
Three times at play
So that's the first time. The second is _creationTime, the field that gets added to every document alongside its _id. It's based on that same start time, and if you insert several documents in one transaction it moves forward just a tiny bit for each one, so they show up in the order you inserted them:
Both of those come from the clock on whichever machine happens to be running your function, and not every machine in a big distributed system is perfectly in sync:
The third time is the database's own. Lots of transactions run at once, but when they commit Convex commits them one at a time, in a serial order. That has its own clock, in nanoseconds, and because there's only one committer it only ever goes forward. Ian calls this the commit time:
Out of order commits
So why does any of this matter?
Well say you have two mutations running at the same time. One starts at time 5 and takes a while, and the other starts at time 7 and is really quick. The quick one commits first, so a document with a _creationTime of 7 lands in the database before a document with a _creationTime of 5.
Now imagine you are walking through all of those documents, and you think you've processed everything up to 7:
So you say cool, from now on I only need to look for documents after 7. But then the slow mutation commits and goes "oh sorry, I'm late to the game, I actually have something at time 5", and you've missed it. It's a subtle lie, generally in the order of milliseconds, but that's enough to skip a row.
This isn't only _creationTime either. Say you have your own updatedAt field and a background job that walks through it, sending each change off to something like Algolia:
If you set that field to Date.now(), these can end up just out of order enough that when you say "I've read up to here", something lands just before where you got to. This happens in pretty much any database if you set a field to the current time, and it's why people who have run these things at scale end up debugging gaps:
Normally getting this right means reading from the database's write-ahead log, which you can't exactly do from your ORM.
Why use commit time?
So here's the grand reveal. Since Convex 1.43 you can put that commit time into your documents, order by it and walk through them with it. You get a number that always goes up, so you can walk your documents exactly once, in the order they were committed.
First you add a field with the v.commitTs() validator, and put an index on it:
Super cool! I did ask the obvious question though. How can you use the commit time inside the mutation, when the mutation hasn't committed yet?
Is it a number or a new type?
Well for the rest of that transaction it's a placeholder. Convex doesn't know the value yet and doesn't want to lie to you and make a guess, so if you read that document back you get a CommitTsPlaceholder, which means you can't compare it to anything until the transaction commits:
Once the transaction commits it turns into a real value, which is a bigint as there are too many nanoseconds for a normal JavaScript number:
From then on it's just like any other field. You can read it, do maths with it and use it in indexes, so you could have an index on an organisation and then updatedAt, and walk each organisation's documents in the order they were committed.
Emma on our infrastructure team did a lot of the work on this and Ian wanted to brag on their behalf, because it's really thorough. If you return that document from your mutation, the client gets the real value even though the line before it was still a placeholder:
I asked if this is unique to Convex and Ian says it's a really difficult thing to achieve with other databases. There isn't a natural way to say "I want a custom ordering on a field that gets filled in at commit time".
Do you need to care about this?
To be honest, probably not most of the time. It's definitely an advanced feature, and Ian says it's often a premature optimization.
Say you have a cron that deletes anything that hasn't been updated for a year. You don't need a strict order for that, as nobody is going to turn up and say "sorry, I forgot to mention this thing was updated a year and two days ago". Ian's rule of thumb is that once you're past about 5 minutes there's no real risk of something landing out of order, so if your queries are only looking further back than that, you're fine:
Where it really matters is when you are walking through a table in batches and the order matters, like building a queue. Workpool uses this, and so does a newer component called Batch Worker, which it's built on.
The other reason you might want it is deletes. If you are inserting and deleting many thousands of documents, then a query that starts from the beginning has to scan past all of those deleted documents (called tombstones) until they get cleaned up, and it gets slower each time:
With a commit timestamp you can just hold on to where you got up to, and you're guaranteed nothing will ever land behind it.
Why isn't creation time commit time?
So naturally I asked, if we'd had this from the start, would _creationTime just be the commit time? Apparently there was a bit of a discussion, and they had maybe three options.
The first is what we have, where you get a time you can read straight away. The second was the same, but secretly changing the time when it actually gets written. So you might read 5.01 inside your transaction and a later one would see 7.035. That would have been really convenient, but that first value can leak out into other documents and all sorts of other places, and now they're all wrong:
The third was to make it a placeholder. But then everywhere you use a document you'd have to check "is this a number yet or not?". So they went with the first option for pragmatic reasons. Most of the time people just want a regular number that's comparable to Date.now(), and most of the time two transactions racing doesn't really matter.
And I think having commit time as a field you opt into turns out really nicely, as you get to choose which fields use it and when.
So should you use Date.now or commit time?
If you want that nice ordering then use v.commitTs(). If it's mostly just a time you want to show to your users, like an updatedAt label, then Ian says to stick with Date.now(), as you can pass it straight into a Date and you don't have to deal with nanoseconds:
You aren't stuck with that decision either. Ian has had to do this for Workpool, and you can use a union so the field accepts both, then start writing commit timestamps from then on:
1updatedAt: v.union(v.commitTs(), v.number()),2
There's a slightly awkward time where you have two numbering systems in there, milliseconds and nanoseconds, but that's a future Mike problem.
The snapshot timestamp
There was one more thing on the way when we filmed this, and it has since shipped in Convex 1.47. This one is even more subtle, so stick with me.
Every transaction reads from a snapshot of the database, and ctx.meta.getSnapshotTs() gives you that snapshot's timestamp, on the same clock as commit time.
Say your job has processed up to commit time 5 and wants everything after that. If you read from 5 to the end of time, and anything gets inserted while your transaction is running, Convex says "sorry, something has changed since you started" and has to rerun your transaction. That's an OCC:
So instead you read from where you got to up until your snapshot. Nothing will ever be written at or below that timestamp, so you don't conflict with anything, and next time you carry on from there:
1exportconst processNewUpdates =mutation({2 args:{ after: v.int64()},3handler:async(ctx,{ after })=>{4const upTo = ctx.meta.getSnapshotTs();5const users =await ctx.db
6.query("users")7.withIndex("by_updatedAt",(q)=>8 q.gt("updatedAt", after).lte("updatedAt", upTo),9)10.collect();1112// do something with those users, like sending them to your search index1314return{ users, upTo };15},16});17
So here the line is your snapshot. You only read up to it, which means that new commit at 8 doesn't bother you:
Noice! You want to keep this one in commit time land tho, as the docs say not to compare it to _creationTime or Date.now().
To be fair, most people probably aren't going to need this. It's more likely to get wrapped up inside components, and you'll just use the component.
Conclusion
So just to recap: there are three times. Date.now() is when your transaction started, _creationTime is based on that too, and neither of them follows the order things actually committed in, which can make you miss rows if you walk through a table with them. Commit time does follow that order, and now you can put it in your documents.
I think what I like most is that Convex gives you the regular number that most people want, and then this is there when you really need it. I knew time was a bit special in Convex, but this kinda blew my mind.
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.