Optimization story time!
So in DBOS, every workflow is represented by a row in a Postgres workflow status table, which stores the workflow’s status. I was trying to figure out where to store the workflow’s inputs and outputs.
The natural thing to do, and the thing we did originally, was to make them columns of the workflow status table. That way, they’re updated with the rest of the status.
The issue is that workflow inputs and outputs can be large. This is tricky because Postgres does multi-version concurrency control (MVCC), meaning that every time you update any value in a row, Postgres rewrites the entire row. So storing inputs and outputs directly in the workflow status table is dangerous, because they could get rewritten many times by unrelated status changes.
But this isn’t the end of the story, because Postgres has another feature specifically to manage large columns: aptly named “the oversized-attribute storage technique (TOAST)”. What this does is store large column values in their own separate “TOAST tables” both to give them more space and to make sure they aren’t rewritten every time the main row is updated. So storing inputs and outputs in the workflow status table is safe, because Postgres TOASTs them and they aren’t rewritten.
But this also isn’t the end of the story, because we need to be able to efficiently delete workflows after they’re done processing. And if workflow inputs and outputs are TOASTed, they’ll be stored in random locations on disk far from their parent workflow status row. So that means when we do a batch delete of workflows, we have to seek all their TOASTed inputs and outputs, which causes cache thrashing and terrible performance. So storing inputs and outputs directly in the workflow status table is dangerous, because it makes deleting workflows too expensive.
So what we finally ended up doing is normalizing the main workflow status table and pulling workflow inputs and outputs into their own tables. When deleting workflows, we delete from each table separately, so all data is deleted in insertion order, giving us good cache locality and performance.
What’s the takeaway from all this? At enough scale, all abstractions are leaky! None of these details (MVCC, TOAST, cache locality) are in the SQL spec, but you need to understand them to make a Postgres-backed system fast. Ultimately, making any system work well requires deeply understanding how it interacts with everything below it.