AshScylla Usage Guide

Copy Markdown View Source

Comprehensive usage guide for AshScylla


Table of Contents

  1. Quick Start
  2. Resource Configuration
  3. Generating Resources
  4. CRUD Operations
  5. Querying
  6. Data Modeling Best Practices
  7. ScyllaDB Features
  8. Migrations
  9. Ash Extension Callbacks
  10. Performance Tips
  11. Common Patterns
  12. Troubleshooting
  13. Additional Resources

Quick Start

Complete Setup Example

1. Add to your dependencies:

# mix.exs
def deps do
  [
    {:ash_scylla, "~> 0.12.0"}
  ]
end

2. Create a Repo:

# lib/my_app/repo.ex
defmodule MyApp.Repo do
  use AshScylla.Repo,
    otp_app: :my_app
end

3. Configure the Repo:

# config/config.exs
import Config

config :my_app, MyApp.Repo,
  nodes: ["127.0.0.1:9042"],
  keyspace: "my_app_dev",
  pool_size: 10

4. Add to supervision tree:

# lib/my_app/application.ex
children = [
  MyApp.Repo,
  # ...
]

5. Generate a Resource:

mix ash_scylla.new_template User name:string, email:string

Or define it manually:

# lib/my_app/resources/user.ex
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

6. Create a Domain:

# lib/my_app/domain.ex
defmodule MyApp.Domain do
  use Ash.Domain

  resources do
    resource MyApp.User
  end
end

7. Create Keyspace and Tables:

# Generate migrations from your Ash resources (writes .exs files to priv/<repo>/migrations)
mix ash_scylla.generate_migrations --dev

# Or use the standard Ash tasks (AshScylla.DataLayer is auto-discovered as the extension):
mix ash.codegen --dev

# Run migrations (includes migration files from priv/<repo>/migrations)
mix ash_scylla.migrate
# or: mix ash.migrate

8. Start Using It:

# Create
{:ok, user} = Ash.create(MyApp.User, %{name: "John", email: "john@example.com"})

# Read
users = MyApp.User
  |> Ash.Query.filter(email == "john@example.com")
  |> Ash.read!()

# Update
{:ok, updated} = user
  |> Ash.Changeset.for_update(:update, %{name: "John Doe"})
  |> Ash.update()

# Delete
:ok = Ash.destroy(user)

Resource Configuration

Basic Resource with All Options

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

  scylla do
    table "products"
    keyspace "custom_keyspace"
    consistency :quorum
    ttl 3600
    lwt true

    # Secondary indexes
    secondary_index :category
    secondary_index [:brand, :price]

    # Materialized views
    materialized_view :products_by_category,
      primary_key: [:category, :id],
      include_columns: [:name, :price]

    # Per-action consistency
    per_action_consistency read: :one, create: :quorum
  end

  attributes do
    uuid_primary_key :id
    attribute :name, :string
    attribute :category, :string
    attribute :brand, :string
    attribute :price, :decimal
  end

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

Composite Primary Keys

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

  scylla do
    table "order_items"
  end

  attributes do
    attribute :order_id, :uuid, primary_key?: true
    attribute :product_id, :uuid, primary_key?: true
    attribute :quantity, :integer
    attribute :price, :decimal
  end
end

Generating Resources

Command Format

mix ash_scylla.new_template ResourceName field1:type1, field2:type2

Options

OptionDescription
--domainDomain module (auto-prefixes resource name)
--resourceFully-qualified resource module name

Supported Types

Ash TypeCQL Type
:stringTEXT
:integerBIGINT
:uuidUUID
:booleanBOOLEAN
:floatDOUBLE
:decimalDECIMAL
:dateDATE
:timeTIME
:utc_datetimeTIMESTAMP
:naive_datetimeTIMESTAMP
:binaryBLOB

Examples

# Simple resource
mix ash_scylla.new_template User user_id:uuid, name:string, age:int

# With domain
mix ash_scylla.new_template User name:string --domain MyApp.Domain

# Fully-qualified name
mix ash_scylla.new_template User name:string --resource MyApp.Domain.User

Generated Output with --domain

# lib/my_app/resources/user.ex
defmodule MyApp.Domain.User do
  use Ash.Resource,
    data_layer: AshScylla.DataLayer,
    domain: MyApp.Domain

  scylla do
    table "users"
  end

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

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

CRUD Operations

Create

# Single record
{:ok, user} = Ash.create(MyApp.User, %{name: "Alice", email: "alice@example.com"})

# With changeset
{:ok, user} =
  MyApp.User
  |> Ash.Changeset.for_create(:create, %{name: "Alice", email: "alice@example.com"})
  |> Ash.create()

# Bulk create (uses BATCH)
{:ok, users} = Ash.bulk_create(user_data_list, MyApp.User, :create)

Read

# All records
users = Ash.read(MyApp.User)

# With filter
{:ok, user} =
  MyApp.User
  |> Ash.Query.filter(email == "alice@example.com")
  |> Ash.read_one()

# With domain
users = MyApp.Domain.read_users!()

Update

# Single record
{:ok, updated} =
  user
  |> Ash.Changeset.for_update(:update, %{name: "Alice Smith"})
  |> Ash.update()

# Bulk update (via query)
MyApp.User
|> Ash.Query.filter(status: "pending")
|> Ash.update!(%{status: "active"})

Delete

# Single record
:ok = Ash.destroy(user)

# Bulk delete (via query)
MyApp.User
|> Ash.Query.filter(status: "inactive")
|> Ash.destroy!()

Querying

Filter Operators

OperatorExample
==Ash.Query.filter(email == "user@example.com")
!=Ash.Query.filter(status != "inactive")
>Ash.Query.filter(age > 18)
>=Ash.Query.filter(price >= 100)
<Ash.Query.filter(age < 65)
<=Ash.Query.filter(price <= 50)
inAsh.Query.filter(status in ["active", "pending"])
containsAsh.Query.filter(tags contains "elixir")
is_nilAsh.Query.filter(email is_nil true)

Combining Filters

# AND (default)
MyApp.User
|> Ash.Query.filter(status: "active")
|> Ash.Query.filter(age > 18)

# OR (rewritten to IN where possible)
import Ash.Query
MyApp.User
|> Ash.Query.filter(status == "active" or status == "pending")

Sorting and Pagination

# Sort by clustering column (within partition)
MyApp.User
|> Ash.Query.sort(:name, :asc)
|> Ash.read!()

# Keyset pagination (default, recommended)
MyApp.User
|> Ash.Query.limit(10)
|> Ash.read!()

# Keyset pagination using paging_state token (recommended for large datasets)
MyApp.User
|> Ash.Query.limit(10)
|> Ash.read!(paging_state: last_paging_state)

Note: ScyllaDB/Cassandra does not support OFFSET pagination. AshScylla defaults to keyset/paging_state token-based pagination for efficient, scalable result traversal.


Aggregates

AshScylla supports COUNT, SUM, AVG, MIN, and MAX aggregates at the query level and via relationship aggregates do blocks.

Query-Level Aggregates

Use Ash.count/2, Ash.sum/2, Ash.avg/2, Ash.min/2, Ash.max/2 on a query:

# COUNT
count =
  MyApp.User
  |> Ash.Query.filter(status == "active")
  |> Ash.count!()

# SUM
total =
  MyApp.Order
  |> Ash.Query.filter(user_id == user_id)
  |> Ash.sum!(:amount)

# AVG / MIN / MAX
avg = MyApp.Order |> Ash.avg!(:amount)
min = MyApp.Order |> Ash.min!(:amount)
max = MyApp.Order |> Ash.max!(:amount)

Resource-Level Aggregates (aggregates do)

Define aggregates on a resource that traverse belongs_to relationships:

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

  attributes do
    uuid_primary_key(:id)
    attribute(:redeemed, :boolean)
    attribute(:amount, :integer)
    attribute(:deal_id, :uuid)
  end

  relationships do
    belongs_to(:deal, MyApp.Deal,
      source_attribute: :deal_id,
      primary_key?: true
    )
  end

  aggregates do
    sum :saved_money, [:deal], :amount do
      # where the redeem is redeemed
      filter expr(redeemed == true)

      # where the `deal` is active
      join_filter :deal, expr(active == true)
    end
  end
end

Then load them on read:

MyApp.Deal
|> Ash.read!(load: [:saved_money])

Unrelated Aggregates

Aggregate over a different resource using Ash.Query.aggregate/4:

User
|> Ash.Query.aggregate(
  :matching_profiles,
  :count,
  Profile,
  query: [
    filter: expr(name == parent(name))
  ]
)
|> Ash.read!()

Note: has_many and many_to_many relationship aggregates are not yet implemented. Use denormalization or materialized views for those patterns. :first, :list, :exists, and :custom aggregate kinds are also not supported.


Data Modeling Best Practices

1. Query-First Design

Design your tables around your queries:

defmodule MyApp.User do
  attributes do
    attribute :email, :string, primary_key?: true  # Partition key
    attribute :name, :string
  end
end

# Query by partition key (efficient)
MyApp.User
|> Ash.Query.filter(email == "user@example.com")
|> Ash.read_one()

2. Denormalization is Normal

Duplicate data across tables for different query patterns:

defmodule MyApp.PostByAuthor do
  attributes do
    attribute :author_id, :uuid, primary_key?: true
    attribute :post_id, :uuid, primary_key?: true
    attribute :title, :string
    attribute :content, :string
  end
end

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  # Denormalized
  end
end

3. Choosing Partition Keys

  • High cardinality: Distribute data evenly
  • Query patterns: Support your most common queries
  • Avoid hotspots: Don't use low-cardinality partition keys
# Good: User ID has high cardinality
attribute :user_id, :uuid, primary_key?: true

# Avoid: Status has low cardinality (creates hotspots)
attribute :status, :string, primary_key?: true  # Don't do this

ScyllaDB Features

Consistency Levels

defmodule MyApp.CriticalData do
  scylla do
    consistency :quorum  # Strong consistency
  end
end

defmodule MyApp.CachedData do
  scylla do
    consistency :one  # Fast, eventual consistency
  end
end

Available levels: :any, :one, :two, :three, :quorum, :all, :local_quorum

TTL (Time To Live)

defmodule MyApp.Session do
  scylla do
    ttl 3600  # Expire after 1 hour
  end

  attributes do
    attribute :user_id, :uuid, primary_key?: true
    attribute :data, :string
  end
end

Collections

defmodule MyApp.User do
  attributes do
    attribute :tags, {:array, :string}  # LIST<TEXT>
    attribute :scores, {:array, :integer}  # LIST<BIGINT>
    attribute :metadata, :map  # MAP<TEXT, TEXT>
  end
end

Secondary Indexes

defmodule MyApp.User do
  scylla do
    secondary_index :email                    # Single column
    secondary_index [:name, :age]             # Multi-column (separate indexes)
    secondary_index :status, name: "idx_status"  # Custom name
  end
end

Note: ScyllaDB OSS doesn't support multi-column secondary indexes. AshScylla generates separate single-column indexes.

Materialized Views

defmodule MyApp.User do
  scylla do
    materialized_view :users_by_email,
      primary_key: [:email, :id],
      include_columns: [:name, :age],
      clustering_order: [id: :desc]
  end
end

Generates:

CREATE MATERIALIZED VIEW IF NOT EXISTS users_by_email
AS SELECT id, email, name, age
FROM users
WHERE email IS NOT NULL AND id IS NOT NULL
PRIMARY KEY (email, id)
WITH CLUSTERING ORDER BY (id DESC)

Migrations

Creating Tables

Use mix ash_scylla.generate_migrations (or the standard mix ash.codegen) to generate migration files from your Ash DSL. AshScylla's data layer is discovered automatically as an Ash extension, so mix ash.codegen routes to it:

# Auto-generate with timestamp-based name (writes .exs files)
mix ash_scylla.generate_migrations --dev

# Or via the standard Ash task:
mix ash.codegen --dev

# With specific migration name
mix ash_scylla.generate_migrations add_user_table

# For a specific domain
mix ash_scylla.generate_migrations --domains MyApp.Domain

This creates .exs files in priv/<repo>/migrations/:

# priv/<repo>/migrations/20260615155440_migrate_resources1.exs
defmodule MyApp.Migrations.MigrateResources1 do
  use AshScylla.Schema

  def change do
    [
      %AshScylla.Schema{
        domain: MyApp.Domain,
        resources: [
          %AshScylla.Schema.Resource{
            name: :users,
            statements: [
              "CREATE TABLE IF NOT EXISTS users (id UUID PRIMARY KEY, name TEXT, email TEXT)",
              "CREATE INDEX IF NOT EXISTS idx_users_email ON users (email)"
            ]
          }
        ]
      }
    ]
  end
end

Using AshScylla.Migration Helpers

defmodule MyApp.Repo.Migrations.CreateUsers do
  def change do
    AshScylla.Migration.create_table_cql(MyApp.User)
    |> then(&AshScylla.Migrator.run!/3)
  end
end

Creating User Defined Types

defmodule MyApp.Repo.Migrations.CreateAddressType do
  def change do
    AshScylla.Migration.create_type("address",
      city: :text,
      street: :text,
      zip: :text
    )
    |> then(&AshScylla.Migrator.run!/3)
  end
end

Running Migrations

# Migrate all resources and schema files
mix ash_scylla.migrate

# Migrate specific resource
mix ash_scylla.migrate --resource MyApp.User

# Dry run (show statements without executing)
mix ash_scylla.migrate --dry-run

# Only schema files from priv/migrations
mix ash_scylla.migrate --schemas-only

Generating Migrations from DSL

Run mix ash_scylla.generate_migrations to introspect your Ash resources and generate CQL migration files:

mix ash_scylla.generate_migrations
# Generated migration: priv/repo/migrations/20240101120000_migration.cql

The generated file contains plain CQL:

-- 20240101120000_migration.cql

CREATE TABLE IF NOT EXISTS users (
  id uuid,
  email text,
  name text,
  age int,
  created_at timestamp,
  PRIMARY KEY (id)
);

CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);

You can also generate for a specific name:

mix ash_scylla.generate_migrations --name add_users
# Generated migration: priv/repo/migrations/20240101120000_add_users.cql

Ash Extension Callbacks

AshScylla implements the Ash.Extension behaviour, enabling standard Ash Mix tasks:

# Install AshScylla for a resource (generates migration files)
mix ash.install AshScylla --resource MyApp.User

# Reset the database (drop keyspace, recreate, re-run migrations)
mix ash.reset AshScylla

# Rollback migrations (note: CQL has no transactional DDL rollback)
mix ash.rollback AshScylla --version 20240101000000

# Tear down (drop keyspace)
mix ash.tear_down AshScylla

All callbacks support --dry-run to preview actions without executing them.


Performance Tips

1. Use Appropriate Consistency Levels

defmodule MyApp.PageView do
  scylla do
    consistency :one  # Fast writes, eventual consistency is fine
  end
end

defmodule MyApp.FinancialTransaction do
  scylla do
    consistency :quorum  # Strong consistency required
  end
end

2. Connection Pool Tuning

config :my_app, MyApp.Repo,
  pool_size: 50,                # Connections per node
  request_timeout: 300_000,     # Query timeout (ms)
  connect_timeout: 10_000

Pool Size Formula:

pool_size = num_nodes * num_cores_per_node

3. Avoid Expensive Queries

  • Use primary key queries when possible
  • Create secondary indexes for non-primary key queries
  • Use materialized views for alternative query patterns
  • Avoid unfiltered queries on non-indexed columns (raises error by default); add a secondary_index instead
  • Use BATCH statements for multiple operations

4. Batch Operations

# Synchronous batch
statements = [
  {"INSERT INTO users (id, name) VALUES (?, ?)", [id1, "Alice"]},
  {"INSERT INTO users (id, name) VALUES (?, ?)", [id2, "Bob"]}
]
AshScylla.DataLayer.Batch.batch_insert(repo, statements)

# Async partition-aware batch (recommended for large datasets)
AshScylla.DataLayer.Batch.batch_insert_async(repo, statements, max_concurrency: 8)

5. Prepared Statement Caching

Enable for high-throughput workloads:

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

Common Patterns

Time-Series Data

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

  scylla do
    table "metrics"
  end

  attributes do
    attribute :sensor_id, :uuid, primary_key?: true
    attribute :timestamp, :utc_datetime, primary_key?: true
    attribute :value, :float
    attribute :unit, :string
  end
end

# Query recent metrics
MyApp.Metric
|> Ash.Query.filter(sensor_id: sensor_id)
|> Ash.Query.sort(timestamp: :desc)
|> Ash.Query.limit(100)
|> Ash.read!()

Counters with Materialized Views

defmodule MyApp.PageView do
  attributes do
    attribute :page_id, :uuid, primary_key?: true
    attribute :user_id, :uuid
    attribute :viewed_at, :utc_datetime
  end
end

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

  scylla do
    table "page_view_counts"
  end

  attributes do
    attribute :page_id, :uuid, primary_key?: true
    attribute :count, :integer
  end
end

Troubleshooting

Common Issues

IssueCauseSolution
Connection refusedScyllaDB not runningpodman-compose -f podman-compose.yml up -d
Keyspace does not existKeyspace not createdmix ash_scylla.setup or mix ash_scylla.migrate --create-keyspace
Table not foundMigration not runmix ash_scylla.migrate
Invalid filterNon-indexed column filterAdd secondary_index to the resource
OFFSET not supportedUsed offset queryUse keyset pagination instead
timeoutQuery too slowIncrease request_timeout, add indexes, optimize query

Debugging Tips

# Check ScyllaDB is running
podman ps

# Check ScyllaDB logs
podman logs ash_scylla_test

# Verify connection
iex -S mix
iex> {:ok, conn} = Xandra.start_link(nodes: ["scylla:9042"])
iex> Xandra.execute(conn, "SELECT release_version FROM system.local")

# Inspect generated CQL
iex> alias AshScylla.DataLayer.QueryBuilder
iex> query = %AshScylla.DataLayer{resource: MyApp.User, repo: MyApp.Repo, table: "users", filters: [%{operator: :eq, left: %{name: :email}, right: %{value: "test@example.com"}}]}
iex> QueryBuilder.build_optimized_query(query)

Additional Resources


License

Apache License 2.0 - see LICENSE file for details.