Hex.pm

Note: This library is under active development and the API may change.

AshScylla

An Ash Framework data layer for ScyllaDB/Apache Cassandra

Quick Start • Features • Documentation • Contributing • License


Overview

AshScylla enables you to use ScyllaDB or Apache Cassandra as a persistence layer for your Ash Framework resources. It implements the Ash.DataLayer behaviour using Xandra (a native Elixir CQL driver) to communicate via CQL (Cassandra Query Language).

Current version: 1.12.0

Key Benefits


Quick Start

Prerequisites

Installation

Add ash_scylla to your dependencies in mix.exs:

def deps do
  [
    {:ash_scylla, "~> 1.12"}
  ]
end

Minimal Setup

# 1. Configure a Repo
defmodule MyApp.Repo do
  use AshScylla.Repo, otp_app: :my_app
end

# 2. Configure in config/config.exs
config :my_app, MyApp.Repo,
  nodes: ["127.0.0.1:9042"],
  keyspace: "my_app_dev",
  pool_size: 10

# 3. Add to your supervision tree
# lib/my_app/application.ex
children = [MyApp.Repo, ...]

# 4. Create a resource
defmodule MyApp.User do
  use Ash.Resource,
    data_layer: AshScylla.DataLayer,
    domain: MyApp.Domain


  scylla do
    table "users"
    consistency :quorum
  end

  attributes do
    uuid_primary_key :id
    attribute :name, :string
    attribute :email, :string
  end

  actions do
    defaults [:create, :read, :update, :destroy]
  end
end

# 5. Create a Domain
defmodule MyApp.Domain do
  use Ash.Domain

  resources do
    resource MyApp.User
  end
end

# 6. Create keyspace and run migrations
# mix ash_scylla.setup
# mix ash_scylla.migrate

### Full-Text Search Setup

The search engine needs its own tables. Two options:

1. **Managed lifecycle (recommended):** set `search_enabled: true` on the repo
   config, run `mix ash.codegen` once, and the generated search migration
   creates the tables via idempotent DDL. `mix ash.migrate` then executes it
   like any other migration — no manual step.

       config :my_app, MyApp.Repo,
         nodes: ["127.0.0.1:9042"],
         keyspace: "my_app_dev",
         search_enabled: true

2. **Manual setup:** `AshScylla.Search.create_tables(MyApp.Repo, "my_keyspace")`
   (the generated DDL is `CREATE TABLE IF NOT EXISTS`, so both paths coexist).

For user-facing latency-sensitive searches, pass `fetch_strategy: :fan_out`
to `AshScylla.Search.search/4` to fan posting lookups out as concurrent
per-shard point queries (results are identical to the default
`:multi_partition` strategy).

# 7. Use it
{:ok, user} = Ash.create(MyApp.User, %{name: "John", email: "john@example.com"})
users = MyApp.User |> Ash.read!()

For a complete step-by-step guide, see the Usage Guide.


Features

Core Ash Features

Feature Status Description
Create ✅ Insert records with TTL support
Read ✅ Query with filtering and sorting
Update ✅ Update existing records
Destroy ✅ Delete records
Filter ✅ Powerful filter syntax with CQL WHERE conversion
Sort ✅ ORDER BY on clustering columns (within partition)
Keyset pagination ✅ A cursor identifies a row; the page that follows it holds exactly the rows on the other side of it
Limit ✅ LIMIT is natively supported
Select ✅ Select specific fields
Multitenancy ✅ Tenant isolation by an attribute of the row, by the keyspace of the statement, or through the execution context
Bulk Create ✅ Batch INSERT operations
Upsert ✅ INSERT with lightweight transactions (LWT)
Update Query ✅ Bulk update via filtered queries
Destroy Query ✅ Bulk delete via filtered queries
Distinct ✅ DISTINCT on partition key columns
Calculate ✅ In-memory calculations
Aggregate (count) ✅ Per-partition COUNT
Aggregate (sum/avg/min/max) ✅ SUM, AVG, MIN, MAX aggregates
Query aggregates ✅ Ash.count/2, Ash.sum/2, etc. on queries
Relationship aggregates ✅ aggregates do blocks, computed with a single grouped statement over the related table

ScyllaDB-Specific Features

Feature Description
TTL Auto-expire data after a specified time
Consistency Levels Per-resource or per-action consistency (:one, :quorum, :all, etc.)
Secondary Indexes Query non-primary key columns efficiently
Materialized Views Alternative query patterns with automatic view maintenance
Batch Operations BATCH INSERT/UPDATE/DELETE, including async partition-aware batching
Keyset Pagination A cursor identifies a row; the page that follows it holds exactly the rows on the other side of it
Lightweight Transactions IF NOT EXISTS on create, IF clauses on update
Compression Application-level compression (LZ4, Snappy, Deflate, Zstd)
User Defined Types Full UDT encoding/decoding and CQL generation
Collection Types LIST, SET, MAP with CONTAINS filter support
Prepared Statement Caching ETS-based cache for high-throughput workloads
Full-Text Search Built-in inverted-index search engine (AshScylla.Search) with TF-IDF/BM25 ranking
Search Schema Lifecycle Opt in with search_enabled: true on repo config and the search tables ship with mix ash.codegen + mix ash.migrate
Structured Error Handling Categorized error types with retryability and actionable suggestions
Query Optimization Filter validation prevents ALLOW FILTERING anti-pattern, in-memory sort fallback

Configuration

Resource Configuration

defmodule MyApp.User do
  use Ash.Resource,
    data_layer: AshScylla.DataLayer,
    domain: MyApp.Domain


  scylla do
    table "users"
    consistency :quorum
    ttl 3600
    lwt true

    secondary_index :email
    secondary_index [:name, :age]

    materialized_view :users_by_email,
      primary_key: [:email, :id],
      include_columns: [:name, :age]

    per_action_consistency read: :one, create: :quorum
  end
end

The Physical Key Layout

A table whose primary key is a single column has no clustering column, so ScyllaDB cannot order its rows. Declare the partition key and the clustering key of a table explicitly when a resource has to be paginated by a keyset, or when it has a composite primary key:

scylla do
  table "events"
  partition_key :account_id
  clustering_key :occurred_at
  clustering_key :event_id, order: :desc
end

A resource that declares neither keeps the composite partition key it had before.

Multi-Domain Applications

The domain a resource belongs to is part of its identity: two resources with the same name in different domains resolve to different tables, and each resource is bound to the repository that serves its domain.

# The repository a domain's resources are bound to.
config :my_app, MyApp.GamesDomain, repo: MyApp.GamesRepo

# A resource that declares no repository of its own uses the one configured
# for its domain; a resource that declares one uses its own.
scylla do
  table "events"
end

Migrations are generated and executed per domain: a migration generated for a resource of one domain is applied to the keyspace of that domain, and the run report names the domain each migration belongs to.

Tenant Isolation

A resource isolates its tenants in one of two ways.

# The tenant is a column of the row: a read under a tenant only sees the
# rows of that tenant, and a write under a tenant records it.
multitenancy do
  strategy(:attribute)
  attribute(:org_id)
end

# The tenant selects the keyspace the statement is issued against.
scylla do
  table "events"

  multitenancy(strategy: :keyspace, keyspace_prefix: "tenant_")
end

Repo Configuration

# Single-node
config :my_app, MyApp.Repo,
  nodes: ["127.0.0.1:9042"],
  keyspace: "my_app_dev"

# Multi-node cluster (all nodes must use the same port)
config :my_app, MyApp.Repo,
  nodes: ["scylla-1:9042", "scylla-2:9042", "scylla-3:9042"],
  keyspace: "my_app_prod",
  pool_size: 50

Pool Size Formula: pool_size = num_nodes * num_cores_per_node


Limitations

Limitation Workaround
No JOINs Denormalize or application-side joins
No complex aggregations Materialized views or custom aggregation
No ACID transactions Use LWT for single-partition operations
Limited WHERE without indexes Create secondary indexes or materialized views
No OFFSET Use keyset pagination — an offset request is refused with an explicit error
Cluster requires same port Configure all nodes on the same port, or use single-node connection
Relationship aggregates over a many_to_many Not aggregatable; use denormalization or materialized views
A keyset-paginated read has to restrict the partition key ScyllaDB can only order the rows of a single partition — filter by the partition key, or declare it with partition_key

Observability

Telemetry

AshScylla emits standard :telemetry events for all query and batch operations:

:telemetry.attach(
  "ash_scylla-logger",
  [:ash_scylla, :query, :stop],
  &MyApp.Telemetry.handle_event/4,
  nil
)

Events: [:ash_scylla, :query, :start|stop|exception], [:ash_scylla, :batch, :start|stop]

Prepared Statement Caching

The cache is started automatically with the Repo when possible; manual supervision-tree addition is only needed for advanced setups.

children = [
  AshScylla.PreparedStatementCache,
  # ...
]

Documentation

Document Description
Usage Guide Comprehensive guide: setup, CRUD, querying, data modeling, migrations
Development Guide Dev container setup, testing, type mapping, CQL query building
Production Guide Multi-node cluster deployment, monitoring, backup, rolling upgrades
Implementation Summary Technical architecture and module reference
Error Handling Error types, common scenarios
Changelog Version history and release notes
API Documentation Module documentation (when published)

Common Commands

# ── Testing ──────────────────────────────────────────────────────────────────
mix test --exclude integration              # Unit tests only (no database)
mix test --only integration                 # Integration tests (needs ScyllaDB)
SCYLLA_DIRECT=1 mix test --only integration # Integration tests against local DB
mix test test/integration/cluster_integration_test.exs --only integration  # Cluster tests
mix test --exclude integration --cover      # Unit tests + coverage report

# ── Code Quality ─────────────────────────────────────────────────────────────
mix format --check-formatted                # Check formatting
mix credo --strict                          # Static analysis
mix dialyzer                                # Type checking
mix quality                                 # All three above

# ── Benchmarks ───────────────────────────────────────────────────────────────
mix run benchmarks/run_benchmarks.exs

# ── Database ─────────────────────────────────────────────────────────────────
mix ash_scylla.setup                        # Create keyspace
mix ash_scylla.migrate                      # Run migration files + auto-schema
mix ash_scylla.migrate --migrations-only    # Run only migration files
mix ash_scylla.migrate --schemas-only       # Run only auto-schema migration
mix ash_scylla.migrate --resource MyApp.User # Auto-migrate one resource

# ── Schema Generation ────────────────────────────────────────────────────────
mix ash_scylla.generate_migrations           # Generate CQL from Ash resource DSL

# ── Ash Extension Callbacks ──────────────────────────────────────────────────
# AshScylla.DataLayer doubles as the Ash extension, so the standard Ash tasks
# discover it automatically (no manual `extensions:` wiring required):
mix ash.codegen --dev                       # Generate migrations (via AshScylla.DataLayer)
mix ash.migrate                             # Run migrations
mix ash.setup                                # Create keyspace + run migrations
mix ash.reset                                # Reset database
mix ash.rollback --version 20240101          # Rollback (logs warning)
mix ash.tear_down                           # Drop keyspace

# If your Ash version does not auto-discover the data layer as an extension,
# add it manually to your domain:
#
#     use Ash.Domain, otp_app: :my_app, extensions: [AshScylla.Extension]

Contributing

Contributions are welcome!

  1. Fork the repository
  2. Clone your fork
  3. Create a feature branch: git checkout -b feature/my-feature
  4. Make your changes
  5. Run tests: mix test --exclude integration
  6. Check quality: mix quality
  7. Commit and push
  8. Open a Pull Request

Development Setup

mix deps.get
podman-compose -f podman-compose.yml up -d
mix test

A .devcontainer/devcontainer.json is provided for VS Code Dev Containers.


License

This project is licensed under the Apache License 2.0 - see the LICENSE file for details.


Acknowledgments


Made with ❤️ for the Elixir and Ash communities