Ursula

repository·main·Indexed 42 days ago

https://github.com/tonbo-io/ursula

A self-hosted, distributed server for replayable, append-only event timelines. Ursula implements the Durable Streams Protocol over HTTP and SSE, providing S3-backed, quorum-replicated infrastructure for document edits, agent runs, and workflows. It includes a durable streams server, gateway, and event-time indexer, deployable via Helm charts as a static-membership Raft cluster.

Tokens
157.2K
Snippets
419
Records
669
Agent score
54%

What's inside Ursula

  1. What is Ursula?

    main

    Ursula is a self-hosted, distributed server designed for replayable, append-only event timelines. It is used to manage the history behind document edits, agent runs, workflows, and chat sessions.

    Key Characteristics

    • HTTP-native: Uses PUT to create, POST to append, and GET to replay or tail via long-poll/SSE.
    • One timeline per resource: Instead of high-throughput pipelines, Ursula uses a stream per document, session, workflow, room, or agent run.
    • Thread-per-core × multi-Raft: Each stream hashes to a specific Raft group and owner core. This architecture ensures that hot-path requests touch only one core, avoiding cross-core synchronization and preventing slow followers in one group from stalling others.
    • Hot ring + S3 cold tier: Writes are committed at Raft quorum in an in-memory ring (hot tier). Older segments are flushed to S3 (cold tier) in the background. A single GET request transparently spans both tiers.
  2. Overview of the Ursula HTTP API

    main

    Ursula provides a public HTTP API for managing durable, append-only streams. The API is primarily organized around the /{bucket}/{stream} route family, which allows users to interact with specific buckets and streams using their IDs.

    This API implements the Durable Streams Protocol, providing a standardized way to create, append to, and read from streams. Most interactions follow a hierarchical structure where a bucket acts as a container for one or more streams.

  3. Understand the Ursula Codebase Reduction Plan

    main

    The Ursula codebase is undergoing a multi-stage reduction plan to decrease its size by approximately 21-24k lines. This plan is categorized by risk levels, moving from mechanical code deduplication to structural unification, feature retirement, and finally test-surface pruning.

    Reduction Stages Overview

    1. Stage 1: Mechanical Shrink (~8-10k lines) Focuses on deduplication and refactoring with zero behavior or wire-format changes. This includes merging ursula-sim binaries, consolidating HTTP request helpers, and migrating error handling to thiserror.

    2. Stage 2: Structural Unification (~4-5k lines) Changes internal APIs and inter-node wire formats. Key changes include unifying command/response models (e.g., StreamCommand as the canonical type) and using Serde-carried Raft envelopes instead of bespoke proto mirrors. This stage requires full Distributed System Test (DST) corpus validation.

    3. Stage 3: Feature Retirement (~2-3k lines) Involves removing specific capabilities based on product decisions. Examples include dropping the WAL persistence backend, removing S2-compatible API routes, and dropping JSON/YAML config formats in favor of TOML.

    4. Stage 4: Test-Surface Pruning (~4-5k lines) Reduces test redundancy by deleting integration tests covered by existing madsim DST scenarios and parameterizing corruption/failover seed families.

  4. Identify the correct Ursula operational tooling

    main

    Ursula provides two primary surfaces for cluster operations:

    1. ursulactl: The primary tool for day-2 cluster operations over HTTP. Use it for high-level tasks like drain, restart, status, and wait-ready.
    2. Admin Endpoints: Low-level primitives used by ursulactl and custom tooling. These are exposed on the admin plane (defaulting to loopback 127.0.0.1:4438) and are intended for scripting or custom operators. They are not accessible on the public client plane.

    Note: There is currently no Prometheus scrape endpoint or general-purpose orchestrator; custom tooling should interact with the admin endpoints directly.

  5. Understanding the Ursula Codebase Reduction Plan

    main

    The Ursula codebase is undergoing a staged reduction plan to shrink the workspace from ~94k lines of Rust to approximately 70k lines. The primary goal is to reduce size by ~23.5k lines without losing protocol coverage, DST (Deterministic Simulation Testing) determinism, or core functionality.

    Key Development Principles during this phase:

    • Prototype Phase (0.x): The project is in a prototype phase where hard breaking changes are preferred over compatibility shims.
    • Atomic Upgrades: Internal wire-format changes are acceptable because deployments are designed to upgrade atomically.
    • Semantic Commits: Each stage of the reduction is implemented as an independent semantic commit, allowing individual levers to be reverted or deferred if necessary.
  6. What is Ursula and the Durable Streams Protocol?

    main

    Ursula is a distributed implementation of the Durable Streams Protocol. It provides a primitive for managing ordered, append-only sequences of changes (timelines) that are replayable, durable, and live-tailable.

    Key Protocol Operations

    Any standard HTTP client can interact with Ursula using the following methods:

    • PUT: Creates a new stream.
    • POST: Appends bytes to an existing stream.
    • GET: Reads from a specific offset or live-tails the stream using Server-Sent Events (SSE).

    Core Characteristics

    • URL-addressable: Streams are identified by URLs.
    • Opaque Offsets: Offsets are treated as opaque tokens for replay semantics.
    • Distributed Durability: Uses Raft-replicated in-memory hot rings for low-latency writes, with a background flusher to an S3-backed cold tier for long-term storage.
    • Transparent Scaling: Supports quorum-replicated writes and transparent follower forwarding, meaning any node in the cluster can serve requests.
  7. What is the Ursula event-time index?

    main

    The ursula indexer is a rebuildable materialized index for client-supplied event times within Ursula JSON record streams.

    Key Characteristics:

    • Role: It consumes record envelopes over HTTP and writes immutable, sorted Parquet parts to S3.
    • Authority: S3 is the authoritative source for the derived index. Local disk serves only as a bounded, disposable Parquet cache.
    • Architecture: The index exists outside of Ursula's Raft state machine. It uses a stateless worker model where multiple workers claim (stream, record range) tasks. Correctness is maintained via immutable content-addressed parts and ETag compare-and-swap (CAS) operations on the stream's CURRENT manifest.
    • Scalability: In 'Pool Mode', a fixed worker pool schedules ranges across multiple namespaces, allowing workers to be shared between small streams or dedicated to hot streams.
  8. Integrate an external index with Ursula record coordinates

    main

    Because Ursula Record Coordinates v1 does not include a server-side timestamp, you should use an external indexer to enable time-based queries.

    The Indexing Contract:

    1. Source of Truth: Ursula remains the canonical authority for bytes, offsets, record ordinals, retention, and replay.
    2. Extraction: An indexer consumes records in ordinal order and extracts the client timestamp from the application JSON.
    3. Ordering: Event timestamps in the index may be out of order; the indexer must not reorder the source stream. Equal timestamps in query results should be ordered by their record ordinal.
    4. Progress Tracking: The index publishes an indexed_through_record marker, which is the exclusive ordinal through which all retained source records have been processed.
    5. Query Workflow: A time query returns record ordinals or coalesced ordinal ranges. The client then uses these ordinals to read the canonical data directly from Ursula.
    6. Resilience: The index is derived state. It can be deleted and rebuilt from retained Ursula records without affecting the stream itself. If the index is being rebuilt and source retention has advanced beyond the rebuild point, the index must report that its answer is incomplete.
  9. Implement exactly-once writes with Idempotent Producers

    main

    Durable Streams supports Kafka-style idempotent producers to prevent duplicate writes during client retries. This requires providing three headers together:

    • Producer-Id: <string>: A stable, non-empty identifier for the logical producer (e.g., a UUID).
    • Producer-Epoch: <integer>: A non-negative integer (≤ 2^53-1). Increment this on producer restart to fence out old (zombie) sessions.
    • Producer-Seq: <integer>: A monotonically increasing sequence number per epoch, applied per-batch (per HTTP request).

    Producer Lifecycle

    1. Start: Send Producer-Epoch: 0 and Producer-Seq: 0.
    2. Retry: If a request fails, retry with the same Producer-Id, Producer-Epoch, and the next Producer-Seq.
    3. Restart: If the producer process restarts, increment the Producer-Epoch and reset Producer-Seq to 0.

    Error Handling

    • 403 Forbidden: Returned if the Producer-Epoch is stale (a newer epoch has already been established). The response includes the current server Producer-Epoch to help the client recover.
    • 409 Conflict: Returned if a sequence gap is detected. The response includes Producer-Expected-Seq and Producer-Received-Seq.
    POST {stream-url}
    Producer-Id: order-service-1
    Producer-Epoch: 1
    Producer-Seq: 5
    
    [body bytes]
  10. Implement length-prefixed framing for message streams

    main

    Ursula streams are raw byte sequences and do not impose message boundaries. If your stream carries many small, independent messages (such as CRDT operations or agent events), you should implement a length-prefixed framing convention to allow clients to parse messages incrementally.

    Recommended framing format: [4-byte big-endian length][payload bytes]

    Each application-level append should write one framed record. When reading, the client may receive a concatenation of multiple framed records and must parse them accordingly.

    [4-byte big-endian length][payload bytes]
  11. Use Path Affinity for Raft Group Routing

    main

    Path affinity (extension token: path-affinity-v1) allows multiple independent streams to share a single Ursula Raft group. This is useful for ensuring related streams are co-located for performance or consistency.

    URL Structure

    The affinity form of a stream URL is: {base_url}/{bucket_id}/{affinity_key}/{stream_id}

    Example:

    • /workflow/run-42/journal
    • /workflow/run-42/queue

    Both streams above share the affinity_key run-42 and will route to the same Raft group. In contrast, a standard two-segment URL like /workflow/run-42-journal has no affinity key and will be hashed normally.

    Constraints and Behavior

    • affinity_key follows the same UTF-8 and path-safety constraints as stream_id.
    • The complete {bucket_id}/{affinity_key}/{stream_id} identity MUST NOT exceed 122 bytes.
    • Path affinity only affects physical placement/routing; it does not make separate HTTP operations atomic.
    • Certain IDs are reserved under an affinity path: $transaction, append-batch, attrs, bootstrap, retention, and snapshot.
    • Servers MUST advertise path-affinity-v1 in the Stream-Extensions header when responding to an affinity-form URL.