home / skills / tursodatabase / turso / async-io-model

async-io-model skill

/.claude/skills/async-io-model

This skill enforces cooperative IO patterns in core Rust to reliably manage asynchronous operations with explicit state machines for correctness and

npx playbooks add skill tursodatabase/turso --skill async-io-model

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
5.7 KB
---
name: async-io-model
description: Explanations of common asynchronous patterns used in tursodb. Involves IOResult, state machines, re-entrancy pitfalls, CompletionGroup. Always use these patterns in `core` when doing anything IO
---
# Async I/O Model Guide

Turso uses cooperative yielding with explicit state machines instead of Rust async/await.

## Core Types

```rust
pub enum IOCompletions {
    Single(Completion),
}

#[must_use]
pub enum IOResult<T> {
    Done(T),      // Operation complete, here's the result
    IO(IOCompletions),  // Need I/O, call me again after completions finish
}
```

Functions returning `IOResult` must be called repeatedly until `Done`.

## Completion and CompletionGroup

A `Completion` tracks a single I/O operation:

```rust
pub struct Completion { /* ... */ }

impl Completion {
    pub fn finished(&self) -> bool;
    pub fn succeeded(&self) -> bool;
    pub fn get_error(&self) -> Option<CompletionError>;
}
```

To wait for multiple I/O operations, use `CompletionGroup`:

```rust
let mut group = CompletionGroup::new(|_| {});

// Add individual completions
group.add(&completion1);
group.add(&completion2);

// Build into single completion that finishes when all complete
let combined = group.build();
io_yield_one!(combined);
```

`CompletionGroup` features:
- Aggregates multiple completions into one
- Calls callback when all complete (or any errors)
- Can nest groups (add a group's completion to another group)
- Cancellable via `group.cancel()`

## Helper Macros

### `return_if_io!`
Unwraps `IOResult`, propagates IO variant up the call stack:
```rust
let result = return_if_io!(some_io_operation());
// Only reaches here if operation returned Done
```

### `io_yield_one!`
Yields a single completion:
```rust
io_yield_one!(completion);  // Returns Ok(IOResult::IO(Single(completion)))
```

## State Machine Pattern

Operations that may yield use explicit state enums:

```rust
enum MyOperationState {
    Start,
    WaitingForRead { page: PageRef },
    Processing { data: Vec<u8> },
    Done,
}
```

The function loops, matching on state and transitioning:

```rust
fn my_operation(&mut self) -> Result<IOResult<Output>> {
    loop {
        match &mut self.state {
            MyOperationState::Start => {
                let (page, completion) = start_read();
                self.state = MyOperationState::WaitingForRead { page };
                io_yield_one!(completion);
            }
            MyOperationState::WaitingForRead { page } => {
                let data = page.get_contents();
                self.state = MyOperationState::Processing { data: data.to_vec() };
                // No yield, continue loop
            }
            MyOperationState::Processing { data } => {
                let result = process(data);
                self.state = MyOperationState::Done;
                return Ok(IOResult::Done(result));
            }
            MyOperationState::Done => unreachable!(),
        }
    }
}
```

## Re-Entrancy: The Critical Pitfall

**State mutations before yield points cause bugs on re-entry.**

### Wrong
```rust
fn bad_example(&mut self) -> Result<IOResult<()>> {
    self.counter += 1;  // Mutates state
    return_if_io!(something_that_might_yield());  // If yields, re-entry will increment again!
    Ok(IOResult::Done(()))
}
```

If `something_that_might_yield()` returns `IO`, caller waits for completion, then calls `bad_example()` again. `counter` gets incremented twice (or more).

### Correct: Mutate After Yield
```rust
fn good_example(&mut self) -> Result<IOResult<()>> {
    return_if_io!(something_that_might_yield());
    self.counter += 1;  // Only reached once, after IO completes
    Ok(IOResult::Done(()))
}
```

### Correct: Use State Machine
```rust
enum State { Start, AfterIO }

fn good_example(&mut self) -> Result<IOResult<()>> {
    loop {
        match self.state {
            State::Start => {
                // Don't mutate shared state here
                self.state = State::AfterIO;
                return_if_io!(something_that_might_yield());
            }
            State::AfterIO => {
                self.counter += 1;  // Safe: only entered once
                return Ok(IOResult::Done(()));
            }
        }
    }
}
```

## Common Re-Entrancy Bugs

| Pattern | Problem |
|---------|---------|
| `vec.push(x); return_if_io!(...)` | Vec grows on each re-entry |
| `idx += 1; return_if_io!(...)` | Index advances multiple times |
| `map.insert(k,v); return_if_io!(...)` | Duplicate inserts or overwrites |
| `flag = true; return_if_io!(...)` | Usually ok, but check logic |

## State Enum Design

Encode progress in state variants:

```rust
// Good: index is part of state, preserved across yields
enum ProcessState {
    Start,
    ProcessingItem { idx: usize, items: Vec<Item> },
    Done,
}

// Loop advances idx only when transitioning states
ProcessingItem { idx, items } => {
    return_if_io!(process_item(&items[idx]));
    if idx + 1 < items.len() {
        self.state = ProcessingItem { idx: idx + 1, items };
    } else {
        self.state = Done;
    }
}
```

## Turso Implementation

Key files:
- `core/types.rs` - `IOResult`, `IOCompletions`, `return_if_io!`, `return_and_restore_if_io!`
- `core/io/completions.rs` - `Completion`, `CompletionGroup`
- `core/util.rs` - `io_yield_one!` macro
- `core/state_machine.rs` - Generic `StateMachine` wrapper
- `core/storage/btree.rs` - Many state machine examples
- `core/storage/pager.rs` - `CompletionGroup` usage examples

## Testing Async Code

Re-entrancy bugs often only manifest under specific IO timing. Use:
- Deterministic simulation (`testing/simulator/`)
- Whopper concurrent DST (`testing/concurrent-simulator/`)
- Fault injection to force yields at different points

## References

- `docs/manual.md` section on I/O

Overview

This skill explains the cooperative async I/O model and common patterns used in Turso core code. It covers IOResult/IOCompletions, Completion and CompletionGroup, explicit state machines, re-entrancy pitfalls, and helper macros you should use whenever performing I/O in core. The goal is to make IO code deterministic, cancelable, and safe against re-entry bugs.

How this skill works

Functions that perform I/O return IOResult<T> which is either Done(T) or IO(completions). When IO is returned the caller must yield and call the function again after the provided completions finish. CompletionGroup aggregates multiple completions into a single waitable completion and can nest or be cancelled. The recommended pattern is an explicit enum state machine that advances states only after yields to avoid mutating shared state across re-entrancy.

When to use it

  • Any I/O inside core modules instead of Rust async/await
  • When you need to wait for one or more filesystem/network completions
  • When operations can be interrupted and re-entered by the scheduler
  • When you need deterministic testing of I/O timing and fault injection
  • When cancellation or aggregated completion callbacks are required

Best practices

  • Always return IOResult and call the function repeatedly until Done
  • Wrap progress in an explicit state enum; transition state before yielding, apply mutations after re-entry
  • Use return_if_io! and io_yield_one! to propagate yields cleanly and reduce boilerplate
  • Use CompletionGroup to wait for multiple operations and to compose/nest waits
  • Avoid mutating shared counters, containers, or flags immediately before a yield — mutate after the corresponding state resumes

Example use cases

  • Reading a page then processing it: start read, set state WaitingForRead, yield on its completion, then process contents
  • Parallel reads: add individual completions to a CompletionGroup, build combined completion, yield once
  • Long-running btree operations implemented as state machines that yield on IO between steps
  • Testing re-entrancy: use deterministic simulator and fault injection to force yields and validate state transitions
  • Cancelable background flush: build a CompletionGroup and call cancel() when shutdown begins

FAQ

Why not use async/await here?

The cooperative IOResult/state-machine model gives explicit yields and deterministic control over re-entrancy, which simplifies testing, cancellation, and fine-grained control in an in-process DB.

How do I avoid re-entrancy bugs when mutating state?

Never mutate shared progress/state immediately before a yield. Either mutate after the yield returns or encode progress into the state enum so transitions occur once and are preserved across re-entry.

When should I use CompletionGroup vs a single Completion?

Use CompletionGroup when you need to wait for multiple concurrent IO operations or to combine nested groups; use a single Completion for one-off IO waits.