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 StartFeaturesDocumentationContributingLicense


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.7.2

Key Benefits


Quick Start

Prerequisites

Installation

Add ash_scylla to your dependencies in mix.exs:

def deps do
[
{:ash_scylla, "~> 1.7"}
]
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 migrationno 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 Token-based pagination via paging_state (default mode)
Limit LIMIT is natively supported
Select Select specific fields
Multitenancy Keyspace-based multitenancy
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 with belongs_to relationships

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
Token-Based Pagination Efficient pagination via Xandra's native paging_state
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

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 via paging_state (default mode)
Cluster requires same port Configure all nodes on the same port, or use single-node connection
Relationship aggregates (has_many/many_to_many) Not yet implemented; use denormalization or materialized views

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