designing a query system


on ; by arya dradjica

From the start of this year, I have slowly been going about designing a query system for Krabby. It took five months of sitting still and thinking really hard, but I believe I have a clear grasp of the design now. I’m going to explain why Krabby needs a query system (because I thought it wouldn’t for a while), the special features I wanted, and how everything fits together.

precursor: the push-based architecture

My original vision for Krabby was a push-based architecture where tasks “push” their outputs to later tasks that need them. I thought this would have a lower overhead than a “pull-based” query system. And this felt feasible because (particularly in the earlier stages of compilation) the dependencies between tasks can be known upfront. The compiler could execute many independent tasks of the same type in an easily parallelized fashion.

This greatly influenced the design of my name resolution algorithm. My idea was to (often pre-emptively) parse Rust source files from the target crate’s src/ folder and inject their results into a global database of Rust items. The database would also store pending references to as-yet-undiscovered items, and when those items got added, the references would be resolved.

I was quite happy with this design for a while, but as I got into the weeds of the implementation back in December, I realized the design had some important flaws.

This stopped my name resolution implementation work in its tracks. I began designing the query system at the very start of 2026, and (excluding a two-month tangent to write housekeeping), it’s been my primary focus. Let me tell you: designing a query system is a lot of work!!

my wish list

I am perpetually saddened by the fact that codebases are fundamentally limited by their historic design choices. The way a program is architected specializes it, and sets it down a path it cannot be shaken from easily. I keep seeing features and optimizations blocked by years-old decisions that never considered their possibility. It’s just the way things are, but I find it heartbreaking.

With all my projects, but Krabby in particular, I try to explore the design space as thoroughly as I can—to look five, maybe ten steps ahead. I try to design things to elegantly allow for all the possibilities I can foresee. This is fallible, of course, but I find solace in knowing I tried. That’s my excuse for writing a seven-thousand-word blog post.

Here’s the list of interesting features I thought of for Krabby’s query system. I’m mostly focusing on the ways it departs from rustc’s query system and salsa. I’m not going to try implementing all of these features immediately, but I have tried to integrate their needs in my design.

the design

Tying all of these features together into a single coherent design was really tricky. In particular, concurrency, asynchrony, and batching interact with each other in subtle ways. Their combined complexity is far more than the sum of their parts. But I think (I hope!) my design accounts for everything.

Let’s define some important terms. The query system is responsible for computing units of data, like “the Rust edition of this crate” or “the expansion of foo!()”. A query is a request for a unit of data. The same data can be queried for multiple times. A task computes some requested data. If some data is queried for, a task will be executed to compute it. While executing, a task can query for more data. The defining property of the query system is memoization: even if data is queried for multiple times, it should only be computed once.

The query system is initialized with a top-level task, such as cargo build. It strives to complete the task as soon as possible. Our goal is to minimize latency, the time between the start and end of the task. We can achieve this by parallelizing aggressively and reusing work.

The workload of the query system can be viewed as a graph of tasks and queries, or a stack of function calls. Consider a Cargo package foo with a single file src/main.rs:

fn main() {
  foo();
}

fn foo() {}
A simplified graph of the compilation steps necessary for the described Cargo package. It contains nodes like "load `Cargo.toml`" and "name-resolve the signature of `main()`", showing the dependencies between tasks in good detail. The arrows between certain tasks are highlighted.
A simplified graph of the tasks that would be involved in compiling foo, based on my proposed design. Highlighted edges represent a potential critical path.

That’s already pretty complicated! I’ve tried to model it pretty closely to concrete implementation needs. You can see some of the features I talked about previously: Cargo is handled within Krabby; early steps like parsing are part of the query system; and function calls only require the signature, not the body, of the callee.

In the context of this graph, the query system’s job is to execute tasks as efficiently as possible. Given a finite number of CPUs, it needs to decide schedule tasks to execute across those CPUs over time; its scheduling decisions determine when compilation will finish, and thus the overall latency.

The best possible latency (assuming infinite, perfect parallelism) is bounded by the longest chain of tasks which depend on each other: the critical path. The critical path must consider how long each task takes; in the graph, I’ve highlighted a potential critical path in bold red. The query system must strive to identify critical paths and prioritize their execution.

While this graph may feel quite thorough, it misses a lot. In particular, it doesn’t identify why a task like “find foo()” was executed. It was needed by a later task, “nameres main() body”, but how did we know it was needed before the later task was executed? The later task had to inform the query system that it needed “find foo()”; it must have started executing earlier than shown in the graph. Let’s try a different visualization—a call stack:

cargo build

  • load Cargo.toml
  • compile foo

    • find main()

      • parse src/main.rs
      • nameres main() signature
    • codegen main()

      • typeck main() body

        • typeck main() signature
        • nameres main() body

          • find foo()

            • parse src/main.rs
            • nameres foo() signature
        • typeck foo() signature
      • codegen foo()

        • typeck foo() body

          • nameres foo() body
          • typeck foo() signature
A call stack for the same tasks and queries.

This shows us something very useful: the start and end boundaries for tasks. Tasks like “compile foo” start very early and end very late; they encompass many other tasks. It highlights the causality implicit in the previous graph: tasks can be caused by others without depending on them. The “parse src/main.rs” task doesn’t depend on the “load Cargo.toml” task; but it was only invoked because of the results of the latter.

This call stack could be optimized a bit. “codegen main()” requests “codegen foo()” after “typeck main() body” is complete; but we can figure out foo() is needed earlier. “codegen main()” could directly invoke “nameres main() body”, concurrently to “typeck main()”, and ascertain the need for foo() from that.

This call stack view is not strictly superior to the graph. It shows the “parse src/main.rs” task multiple times. It obscures parallelism: it’s less obvious which tasks can be executed in parallel. I find both visualizations helpful; they have strengths in different contexts.

This section has been pretty generic thus far. Let’s get into the details of Krabby’s design. I’ve organized the following sections as an explanation of the system as a coherent whole, where each section covers a different topic. I would suggest skimming through the sections to get an overview, as if this were a paper.

tasks

A task computes a unit of data. A task is an instance of a class which the query system is aware of. A class is a concrete name for a task implementation, which is a concrete Rust type and associated code. Task metadata is information managed by the query system about the task.

Tasks have a life cycle. Tasks may be enqueued in the task queue, before they are queried for. They will eventually be started, due to the task queue or a query by another task. If they need to wait for pending queries to complete, they become blocked; when all their pending queries are complete, they are resumed. Eventually, they are finished.

A task implementation centers around a concrete Rust type. This type holds the state of the task across its life cycle (from the time it is enqueued / queried for, until it is finished). The type has several properties:

Over their lifecycle, the query system records different information about tasks. Most of this information is stored in the task slot.

Task classes are stateful. They are represented by class handles, through which data (as produced by the tasks) can be queried for. Class handles are reference-counted, and are held as long as more queries can appear. Once they are dropped, any associated data can be dropped too. This makes it possible to deallocate significant chunks of data in the middle of compilation.

A good example (and the main one on my mind) is name resolution. Here’s a simplified idea of a name resolution task for function bodies:

// converts AST -> HIR
struct NameResFnBody {
  slot: Arc<Slot<FnBodyHir>>,
  // a HIR that contains unresolved references
  hir: FnBodyHirBuilder,
  // the scope of the containing module
  scope: Arc<NameResScope>,
}

impl Task for NameResFnBody {
  fn poll(&mut self, handle: &mut QuerySystemHandle) {
    // `uref` is e.g. `foo`, `util::block_on`
    // `uref.base()` is `foo`, `util`
    for uref in self.hir.unresolved_refs() {
      // `path` is e.g. `takeaway{crate#123}::util`
      let path = self.scope.get(uref.base());
      if let Ready(decl) = handle.lookup_decl(path) {
        // may cause macro expansion and reveal new refs
        self.hir.insert(uref.user(), decl);
      }
      // keep going even if a query is blocked
    }

    if !handle.blocked() {
      // all queries finished, the fn is resolved.
      self.slot.write(hir.finished());
    }
  }

  fn priority(&self) -> u32 {
    400 // could also be dynamic
  }
}

queries

A query is a request for some data. Queries are initiated by tasks (their sources), and the data they request is computed by other tasks (their targets), so they can be viewed as links between tasks (as we see in the execution graph above).

Like tasks, queries have a life cycle. First, they are initiated. If the requested data has already been computed (i.e. the target task is finished), they are marked as complete immediately. Otherwise, they are pending; the target task has not yet started, or (in rare cases) is already running. The worker thread responsible for the source task will try to start the query, locking the underlying task slot and starting the target task. If the target task is already running, starting fails, and the query becomes blocked. Blocked queries are registered in the metadata of target tasks so they can resume source tasks upon completion.

Queries can form cycles. If a set of queries depend on each other, they would all block indefinitely. Cycles are not always an error; they can occur beningly during name resolution (e.g. while resolving circular imports), and are a major consideration for trait solving. Query cycles must be broken by reporting to one of the involved tasks that it is part of a cycle. The task can choose how to handle this; it might stop and produce an error, or it might retry the cyclic queries with different arguments. The selection of this task, among those in the cycle, is arbitrary, and results should not depend on it. Note that cycles can be nested and interleaved in complex and unintuitive ways.

There is special support for streaming queries. These are queries whose results are collections (ordered, i.e. Vec, or unordered, i.e. HashMap/HashSet). They can return results incrementally (adding to the collection over time). When a task depends on such a query, it can observe the data collected thus far, even if all results are not yet available. It will be blocked on the query, but will be resumed every time new data is added, as well as when the query completes.

worker threads

Tasks are executed by a fixed number of worker threads. A worker thread holds a set of ongoing tasks, which it is responsible for executing, and a reference to the task queue, through which it can obtain new tasks.

Worker threads try to execute tasks in batches; they organize new and old tasks by their class and execute all available tasks in a class, one class at a time. They use the batch poll functions defined by task implementations. For each known (or recently used) class of tasks, they maintain a pending set: a set of tasks of that class that are ready to start/resume. These sets are picked from arbitrarily and executed; if a set has very few tasks, more (of exactly that class) may be loaded from the task queue first.

If a task is executed, and it queries for data that has not yet been computed, the task will be stashed away locally and the requested tasks will be started (they will be added to the pending sets of their classes, so that they can be batched). The original task is considered their parent. The newly started tasks may get stashed themselves, resulting in a hierarchy of stashed tasks.

The worker thread keeps track of the reason a local task is being executed. It may be executed because it was fetched from the task queue, or because it was queried for by another local task (in which case it stores an identifier for that parent task). When a local task completes, the queries waiting on it (the parent, if any, and others tracked in the task metadata) are unblocked, possibly causing some tasks to be resumed.

caches

Krabby has an in-memory cache and an on-disk cache. Both store pairs of keys and values. Values can be looked up by their keys and new key-value pairs can be inserted. The two caches use different concepts of keys and values. The in-memory cache is essential for compilation—it includes task metadata and output slots. The on-disk cache is only used for incremental compilation, and it records the execution of tasks in greater detail.

The in-memory cache holds per-class and inter-class data. For each task class, it holds a database of task slots. These encompass completed, ongoing, and enqueued tasks. When a query is emitted, the corresponding slot is looked up here (and is added if it does not yet exist).

The on-disk cache augments this, allowing values from previous compilation sessions to be reused. It is more expensive to look into, so it is only used for certain (more expensive) classes of tasks, after the in-memory cache is checked. (In some cases, data may be loaded before it is needed.) It stores task recordings.

A task recording allows re-executing a task incrementally. While executing, the task emitted queries. The task recording stores the key and result of the task, and the keys and results of the emitted queries, in the order they occurred. The task can be replayed by invoking those queries again; if all their results match the stored values, the stored result matches the up-to-date output of the task. Multiple task recordings may be cached for the same key, and they may share the same initial queries.

Data in the on-disk cache is canonicalized. Data irrelevant for a task, such as identifier IDs, can be moved to an external array and replaced by indices into that array. These indices will be used consistently throughout a task recording, so they correspond between the task key and its results. The external array will not be included in the cache key, so queries with different identifier IDs could use the same cache entry.

Large values (e.g. HIR data structures) will appear multiple times in the on-disk cache. The output of a query, which may be such a large value, will be referenced by its dependents, and possibly used as inputs to other queries. For efficiency, these values are interned—they are deduplicated (across the entire on-disk cache) and identified by small numeric IDs. This is relevant to incremental compilation.

The caches can be configured with eviction policies and maximum sizes. This is most important for the on-disk cache, to limit the size of your target folder. I haven’t looked into the theory of eviction policies, but I guess we could start with simple LRU and tune the implementation over time. Applying a maximum size to the in-memory cache is useful in the face of memory pressure.

incremental compilation

Some tasks are impure. They perform I‍/‍O and their results can vary across compilations. The most important such tasks read Rust source files from disk. These tasks don’t emit queries; they are leaves in the query tree. They are the starting point for incremental compilation.

rustc and salsa use the red-green algorithm for incremental compilation. They operate relative to the previous compilation (and do not consider or store data from older compilations). Tasks are considered “green” if their results are the same as from the previous compilation, and “red” if they have changed. The on-disk cache holds task recordings from the previous compilation. Tasks are re-executed (from top-down or bottom-up), eventually leading to the recomputation of the impure leaves. If a task’s result is unchanged, it is marked green. If all a task’s inputs (the results of the queries it emitted) are green, it too is marked green. If any of the task’s inputs are red, it is recomputed; if its result has not changed, it is still marked green.

Krabby extends the red-green algorithm to re-use data from older compilations where possible. It continues to specially cater to the immediately previous compilation; however, when a task needs to be recomputed (i.e. its inputs are red), Krabby first checks the on-disk cache for a matching task recording. Data from the previous compilation is allowed to be missing (to satisfy cache limits), preventing the affected tasks from being marked green. But data for those tasks might be available from older compilations, and data for the dependents of those tasks might still be cached.

In some cases, Krabby can apply heuristics to deviate from this algorithm. It may choose to re-execute some tasks before they are known to be needed, e.g. to pre-emptively check often-used tasks, or to support batching.

cycle detection

Krabby uses three algorithms to detect cycles: an eager, per-thread one, that is fast but reports false negatives; a lazy, cross-thread one, that runs infrequently but reports false positives, and a slow one that can check cycles in a particular task. The eager algorithm is the conventional one for cycle detection (where you check the current thread’s stack of queries). The lazy algorithm is analogous to a deadlock detector, and it relies on an interesting notion of reachability. The slow algorithm walks the dependency graph around a particular task to unambiguously identify cycles.

If a task emits a query for data that is in the process of being computed, the query and the task are blocked. The target task may be running on a different thread (due to concurrency), running on the same thread (due to batching), or a cycle is being formed. In the latter case, the target task will get blocked on the query too; this forms a deadlock. The tasks involved in the cycle will be blocked forever.

A blocked task is blocked on one or more queries. Krabby measures whether the task can be progressed. A task can be progressed if 1) it is executing, 2) it was blocked but is now ready to resume, or 3) if it is blocked right now but one of its dependencies can be progressed. This is a deliberately weak definition: some tasks caught in deadlocks might be misclassified this way.

Suppose that a task is blocked on two queries, one causing a cycle/deadlock, the other being executed. By the above definition, Krabby would assume this task can be progressed. But once the second dependency finishes executing, the task will only be blocked on the cycle causing query; Krabby would recognize that it cannot be progressed. Even if a deadlocked task is misclassified, it will only be misclassified temporarily.

So, Krabby periodically collects all the tasks that definitely can be progressed, from the local ready-to-resume tasks on each worker thread. It looks up the blocked tasks depending on them and marks them as can-be-progressed, recursively. The blocked tasks that do not get marked are parts of cycles. However … some tasks might be misclassified due to race conditions (akin to tearing) while collecting from the worker threads.

The last step, then, is to pick tasks that are likely to be deadlocked, and to explore their dependencies recursively to confirm it. Once a cycle is identified, the task is unblocked, and the cycle will be reported to the task when it resumes.

A few optimizations: cycle detection is skipped for certain classes of tasks, if the user believes they cannot run into cycles. The lazy algorithm only considers blocked tasks that are sufficiently old (e.g. have been blocked for more than 500 microseconds). I expect to find more ways to tune it in the future.

what’s next?

These ideas have been swirling around in my head for more than seven months, and they still make my brain hurt a bit. I think they’re ripe for implementing, though! I’m going to present my query system at EuroRust 2026 in Barcelona, during which I’ll show off query-based implementations of Cargo, one using salsa and one using krabby-query. I’m currently working on the salsa implementation, and getting a good sense of the specific queries I will need; I’m going to start implementing krabby-query soon. I’m super excited!!!

If something feels unclear, you’d like to hear more details about something, or you just want to keep up with Krabby development, join our Zulip!