Complete guide to using AshScylla with ScyllaDB/Apache Cassandra
Table of Contents
- Quick Start
- Resource Configuration
- Generating Resources
- CRUD Operations
- Querying
- Data Modeling Best Practices
- ScyllaDB Features
- Migrations
- Performance Tips
- Common Patterns
- Troubleshooting
Quick Start
Complete Setup Example
1. Create a Repo (lib/my_app/repo.ex):
defmodule MyApp.Repo do
use AshScylla.Repo,
otp_app: :my_app
end2. Configure the Repo (config/config.exs):
config :my_app, MyApp.Repo,
nodes: ["127.0.0.1:9042"],
keyspace: "my_app_dev",
pool_size: 10,
request_timeout: 120_0003. Create a Domain (lib/my_app/domain.ex):
defmodule MyApp.Domain do
use Ash.Domain
resources do
resource MyApp.User
resource MyApp.Post
end
end- Generate a Resource Template:
mix ash_scylla.new_template User user_id:uuid, name:string, age:int
This creates lib/my_app/resources/user.ex with a starter template. Then customize it:
Or define resources manually (lib/my_app/resources/user.ex):
defmodule MyApp.User do
use Ash.Resource,
data_layer: AshScylla.DataLayer,
domain: MyApp.Domain
attributes do
uuid_primary_key :id
attribute :name, :string
attribute :email, :string
attribute :age, :integer
attribute :status, :string, constraints: [one_of: ["active", "inactive"]]
end
actions do
defaults [:create, :read, :update, :destroy]
end
end5. Initialize Database:
# Create keyspace
MyApp.Repo.create_keyspace()
# Run migrations
AshScylla.Migrator.run!(MyApp.Repo.nodes(), [
AshScylla.Migration.create_table_cql(MyApp.User),
"CREATE INDEX IF NOT EXISTS idx_users_email ON users (email)"
])Resource Configuration
Basic Resource with All Options
defmodule MyApp.Product do
use Ash.Resource,
data_layer: AshScylla.DataLayer,
domain: MyApp.Domain
ash_scylla do
table "products" # Custom table name
keyspace "my_keyspace" # Custom keyspace
consistency :quorum # Consistency level
ttl 7200 # TTL in seconds
# Secondary indexes
secondary_index :sku
secondary_index [:category, :brand]
# Materialized views
materialized_view :products_by_category,
primary_key: [:category, :id],
include_columns: [:name, :price, :brand]
end
attributes do
uuid_primary_key :id
attribute :name, :string
attribute :sku, :string
attribute :price, :decimal
attribute :category, :string
attribute :brand, :string
end
actions do
defaults [:create, :read, :update, :destroy]
end
endComposite Primary Keys
defmodule MyApp.OrderItem do
use Ash.Resource,
data_layer: AshScylla.DataLayer
attributes do
attribute :order_id, :uuid, primary_key?: true
attribute :product_id, :uuid, primary_key?: true
attribute :quantity, :integer
attribute :price, :decimal
end
endGenerating Resources
AshScylla includes a Mix task to quickly scaffold a new resource:
mix ash_scylla.new_template MyResource user_id:uuid, name:string, age:int
This generates a file at lib/<app>/resources/my_resource.ex containing:
defmodule MyApp.User do
use Ash.Resource,
data_layer: AshScylla.DataLayer,
repo: MyApp.Repo
attributes do
uuid_primary_key :id
attribute :user_id, :uuid
attribute :name, :string
attribute :age, :integer
end
actions do
defaults [:create, :read, :update, :destroy]
end
endCommand Format
mix ash_scylla.new_template <ResourceName> <attr1>:<type1>, <attr2>:<type2>, ...
- ResourceName — an Elixir module alias (e.g.
User,Blog.Post) - Attributes — comma-separated
name:typepairs
Options
| Flag | Description |
|---|---|
--domain <Module> | Domain module to include. Auto-prefixes the resource name (e.g. --domain MyApp.Domain with name User produces MyApp.Domain.User). |
--resource <Module> | Fully-qualified resource module name. Overrides the positional name argument entirely. |
Supported Types
Any valid Ash type is accepted. Common choices:
| Type | CQL mapping |
|---|---|
:uuid | UUID |
:string | TEXT |
:integer (or :int) | BIGINT |
:boolean | BOOLEAN |
:utc_datetime | TIMESTAMP |
:date | DATE |
:float | DOUBLE |
:decimal | DECIMAL |
Examples
# Simple resource (no domain)
mix ash_scylla.new_template User email:string, name:string, age:int
# Resource with domain — module becomes MyApp.Domain.User
mix ash_scylla.new_template User name:string --domain MyApp.Domain
# Resource with fully-qualified name — module becomes MyApp.Games.User
mix ash_scylla.new_template User name:string --resource MyApp.Games.User
# With module nesting
mix ash_scylla.new_template Blog.Post title:string, body:string, published:boolean
# Many attributes
mix ash_scylla.new_template Sensor sensor_id:uuid, temperature:float, location:string, recorded_at:utc_datetime
Generated Output with --domain
When --domain is provided, the generated resource includes the domain option:
defmodule MyApp.Domain.User do
use Ash.Resource,
data_layer: AshScylla.DataLayer,
repo: MyApp.Repo,
domain: MyApp.Domain
attributes do
uuid_primary_key :id
attribute :name, :string
end
actions do
defaults [:create, :read, :update, :destroy]
end
endAfter generating, add the resource to your domain and customize with primary keys, actions, and ScyllaDB-specific options:
# Already configured with domain when using --domain flag:
defmodule MyApp.Domain do
use Ash.Domain
resources do
resource MyApp.Domain.User
end
endCRUD Operations
Create
# Simple create
{:ok, user} = MyApp.User
|> Ash.Changeset.for_create(:create, %{
name: "John Doe",
email: "john@example.com",
age: 30
})
|> Ash.create()
# Bulk create (uses BATCH internally)
users_data = [
%{name: "Alice", email: "alice@example.com"},
%{name: "Bob", email: "bob@example.com"}
]
{:ok, users} = users_data
|> Enum.map(fn attrs -> Ash.Changeset.for_create(MyApp.User, :create, attrs) end)
|> Ash.bulk_create(MyApp.User, :create)Read
# Read all
users = MyApp.User |> Ash.read()
# Read one by primary key
{:ok, user} = MyApp.User
|> Ash.Query.filter(id == "some-uuid")
|> Ash.read_one()
# Read with filters
active_users = MyApp.User
|> Ash.Query.filter(status == "active" and age >= 18)
|> Ash.read()
# Select specific fields
names = MyApp.User
|> Ash.Query.select([:name, :email])
|> Ash.read()Update
{:ok, updated_user} = user
|> Ash.Changeset.for_update(:update, %{
name: "John Smith",
age: 31
})
|> Ash.update()Delete
:ok = user |> Ash.destroy()Querying
Filter Operators
| Operator | Description | Example |
|---|---|---|
== | Equality | age == 30 |
!= | Not equal | status != "inactive" |
> | Greater than | age > 18 |
>= | Greater or equal | age >= 21 |
< | Less than | price < 100 |
<= | Less or equal | price <= 50 |
in | In list | status in ["active", "pending"] |
Combining Filters
# AND conditions
users = MyApp.User
|> Ash.Query.filter(status == "active" and age >= 18)
|> Ash.read()
# OR conditions (use multiple queries for complex cases)
active_or_admin = MyApp.User
|> Ash.Query.filter(status == "active" or role == "admin")
|> Ash.read()Sorting and Pagination
# Sort by single field
users = MyApp.User
|> Ash.Query.sort(:name)
|> Ash.read()
# Sort by multiple fields
users = MyApp.User
|> Ash.Query.sort([:status, :name])
|> Ash.read()
# Limit results
recent_users = MyApp.User
|> Ash.Query.sort(inserted_at: :desc)
|> Ash.Query.limit(10)
|> Ash.read()Data Modeling Best Practices
1. Query-First Design 🎯
Design tables around your queries, not the other way around:
# Query: "Get all posts by author"
defmodule MyApp.Post do
attributes do
attribute :author_id, :uuid, primary_key?: true # Partition key
attribute :post_id, :uuid, primary_key?: true # Clustering key
attribute :title, :string
attribute :content, :string
end
end
# Efficient query by partition key
posts = MyApp.Post
|> Ash.Query.filter(author_id == "author-uuid")
|> Ash.read()2. Denormalization is Normal 📦
Duplicate data to support different query patterns:
# Table 1: Posts by author
defmodule MyApp.PostByAuthor do
attributes do
attribute :author_id, :uuid, primary_key?: true
attribute :post_id, :uuid, primary_key?: true
attribute :title, :string
attribute :author_name, :string # Denormalized
end
end
# Table 2: Posts by date (different query pattern)
defmodule MyApp.PostByDate do
attributes do
attribute :date, :date, primary_key?: true
attribute :post_id, :uuid, primary_key?: true
attribute :title, :string
attribute :author_name, :string
end
end3. Choosing Partition Keys 🔑
Good partition keys:
- High cardinality (many unique values)
- Evenly distributed
- Match your query patterns
# Good: UUID has high cardinality
attribute :user_id, :uuid, primary_key?: true
# Good: email is unique and high cardinality
attribute :email, :string, primary_key?: trueAvoid:
- Low cardinality (status, type, boolean)
- Timestamps (creates hotspots)
ScyllaDB Features
Consistency Levels
defmodule MyApp.CriticalData do
ash_scylla do
consistency :quorum # Strong consistency
end
end
defmodule MyApp.CachedData do
ash_scylla do
consistency :one # Fast, eventual consistency
end
endConsistency Level Guide:
| Level | Description | Use Case |
|---|---|---|
:any | Any node response | Fastest, lowest consistency |
:one | At least one replica | Fast reads/writes |
:quorum | Majority of replicas | Balanced speed/consistency |
:all | All replicas | Strongest consistency, slowest |
TTL (Time To Live)
defmodule MyApp.Session do
use Ash.Resource,
data_layer: AshScylla.DataLayer
ash_scylla do
ttl 3600 # Expire after 1 hour (in seconds)
end
attributes do
uuid_primary_key :id
attribute :token, :string
attribute :user_id, :uuid
end
endCollections
defmodule MyApp.User do
attributes do
uuid_primary_key :id
attribute :name, :string
attribute :tags, {:array, :string} # LIST type
attribute :metadata, :map # MAP type
end
endSecondary Indexes
defmodule MyApp.User do
ash_scylla do
# Single column index
secondary_index :email
# Composite index
secondary_index [:name, :age]
# Custom index name
secondary_index :status, name: "idx_user_status"
end
endImportant Notes:
- Best for low-cardinality columns
- Equality checks only (
==) - Adds overhead to writes
Materialized Views
defmodule MyApp.User do
ash_scylla do
materialized_view :users_by_email,
primary_key: [:email, :id],
include_columns: [:name, :age],
clustering_order: [id: :desc]
materialized_view :users_by_age,
primary_key: [:age, :id],
include_columns: [:name, :email]
end
endMigrations
Creating Tables
Use AshScylla.Migrator to execute raw CQL directly:
# Create keyspace first
MyApp.Repo.create_keyspace()
# Create tables and indexes
AshScylla.Migrator.run!(MyApp.Repo.nodes(), [
"""
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY,
name TEXT,
email TEXT,
age BIGINT,
status TEXT,
tags LIST<TEXT>,
metadata MAP<TEXT, TEXT>
)
""",
"CREATE INDEX IF NOT EXISTS idx_users_email ON users (email)",
"CREATE INDEX IF NOT EXISTS idx_users_status ON users (status)"
])Using AshScylla.Migration Helpers
# Generate CQL from resource definitions
AshScylla.Migrator.run!(MyApp.Repo.nodes(), [
AshScylla.Migration.create_table_cql(MyApp.User),
AshScylla.Migration.create_secondary_indexes_cql(MyApp.User)
] |> List.flatten())Creating User Defined Types
AshScylla.Migrator.run!(MyApp.Repo.nodes(), [
"""
CREATE TYPE IF NOT EXISTS address (
street TEXT,
city TEXT,
state TEXT,
zip TEXT
)
"""
])Performance Tips
1. Use Appropriate Consistency Levels
# Fast reads for non-critical data
defmodule MyApp.PageView do
ash_scylla do
consistency :one
end
end
# Strong consistency for critical data
defmodule MyApp.FinancialTransaction do
ash_scylla do
consistency :quorum
end
end2. Connection Pool Tuning
config :my_app, MyApp.Repo,
nodes: ["scylla-1:9042", "scylla-2:9042"],
pool_size: 50, # Connections per node
request_timeout: 300_000, # 5 minutes for complex queries
connect_timeout: 10_000Pool Size Guidelines:
- Development: 5-10
- Production: 25-100 (based on load)
3. Avoid Expensive Queries
# DON'T: Full table scan without partition key
MyApp.User |> Ash.read() # Inefficient
# DO: Query by partition key
MyApp.User
|> Ash.Query.filter(email == "user@example.com")
|> Ash.read()4. Batch Operations
# Use bulk_create for multiple inserts
{:ok, _users} = user_data_list
|> Ash.bulk_create(MyApp.User, :create)Common Patterns
Time-Series Data
defmodule MyApp.Metric do
use Ash.Resource,
data_layer: AshScylla.DataLayer
attributes do
attribute :metric_name, :string, primary_key?: true
attribute :timestamp, :utc_datetime, primary_key?: true
attribute :value, :float
attribute :tags, :map
end
actions do
defaults [:create, :read, :update, :destroy]
end
end
# Query last 24 hours
metrics = MyApp.Metric
|> Ash.Query.filter(
metric_name == "cpu_usage" and
timestamp >= ~U[2024-01-01 00:00:00Z] and
timestamp <= ~U[2024-01-02 00:00:00Z]
)
|> Ash.Query.sort(timestamp: :desc)
|> Ash.read()Counters with Materialized Views
# Main table
defmodule MyApp.PageView do
attributes do
attribute :page_id, :string, primary_key?: true
attribute :view_date, :date, primary_key?: true
attribute :count, :integer
end
end
# Aggregated view
defmodule MyApp.PageViewCount do
attributes do
attribute :page_id, :string, primary_key?: true
attribute :total_views, :integer
end
endTroubleshooting
Common Issues
1. Connection Refused
** (RuntimeError) Could not connect to ScyllaDB at 127.0.0.1:9042- Ensure ScyllaDB is running:
podman ps - Check connection settings in
config/config.exs - Verify firewall/network settings
2. NoHostAvailableError
** (Xandra.NoHostAvailableError) All hosts down- Check if ScyllaDB node is accessible
- Verify
nodesconfiguration - Check ScyllaDB logs:
podman logs <container_id>
3. Invalid Query / Syntax Error
** (Xandra.Error) Invalid syntax in CQL query- Check CQL syntax in custom queries
- Verify table/column names exist
- Run migrations via
AshScylla.Migrator.run!/3
4. Read Timeout
** (Xandra.Error) Request timed out- Increase
request_timeoutin repo config - Optimize slow queries
- Check ScyllaDB performance
5. Secondary Index Not Used / ALLOW FILTERING Error
Cannot execute this query as it might involve data filtering and thus may have unpredictable performance. If you want to execute this query despite the performance unpredictability, use ALLOW FILTERINGThis error means you're filtering on a column that is neither part of the primary key nor has a secondary index. You have three options:
Option A (Recommended): Add a secondary index
ash_scylla do
secondary_index :game_id
endThen run migrations to create the index.
Option B: Use a materialized view for the query pattern:
ash_scylla do
materialized_view :members_by_game,
primary_key: [:game_id, :id]
endOption C (NOT recommended for production): Enable allow_filtering
ash_scylla do
allow_filtering true
endThis appends ALLOW FILTERING to queries, allowing ScyllaDB to execute them. However, this can cause full table scans and severe performance issues in production. Only use this for development or small datasets.
Debugging Tips
Enable Query Logging:
# In config/dev.exs
config :logger, level: :debug
# Or in IEx
Logger.configure(level: :debug)Check Generated CQL:
# Use AshScylla.DataLayer.QueryBuilder to inspect queries
query = AshScylla.DataLayer.QueryBuilder.build_optimized_query(data_layer_struct)
IO.inspect(query, label: "Generated CQL")Test Connection:
# In IEx
MyApp.Repo.query("SELECT release_version FROM system.local")