AshArcadic tour — Ash on ArcadeDB
# Hex install (the published packages):
Mix.install([
{:ash_arcadic, "~> 1.0"},
# `replicant` is the CDC transport (Postgres logical replication → decoded
# transactions) for the CDC-mirror section near the end. ash_arcadic declares
# it `optional: true`, so a tour without that section can drop this line.
{:replicant, "~> 1.2"}
])
# Local dev from a checkout instead: path-install both.
# Mix.install([
# {:ash_arcadic, path: Path.join(__DIR__, "..")},
# {:replicant, path: Path.join(__DIR__, "../../replicant")}
# ])
What this is
A runnable tour of AshArcadic — the Ash DataLayer for
ArcadeDB. It walks the full surface against a real
ArcadeDB: CRUD + upserts + atomics, query push-down, aggregates + calculations,
keyset streaming, concurrent bulk writes, fail-closed multitenancy, vector
search (dense, sparse, hybrid), graph traversal over edges, and the CDC mirror.
You need a running ArcadeDB. One docker command:
docker run --rm -d --name arcadedb-tour -p 2480:2480 \
-e JAVA_OPTS="-Darcadedb.server.rootPassword=playwithdata" \
arcadedata/arcadedb:latest
Then set the two environment variables below (Livebook: use the Secrets panel or
export them before livebook server). The tour creates a throwaway database
and drops it at the end — it never touches existing data.
Connect
url = System.get_env("ARCADEDB_URL", "http://localhost:2480")
password = System.get_env("ARCADEDB_PASSWORD", "playwithdata")
# A throwaway database for this tour (dropped in the last cell).
db = "tour_" <> Base.encode16(:crypto.strong_rand_bytes(3), case: :lower)
admin = Arcadic.connect(url, db, auth: {"root", password})
:ok = Arcadic.Server.create_database!(admin, db)
# The data layer asks the HOST for its connection through one small behaviour —
# the `repo` analog. Stash the tour's database name where the client can read it.
Application.put_env(:ash_arcadic_tour, :database, db)
defmodule Tour.ArcadicClient do
@behaviour AshArcadic.Client
@impl true
def conn do
url = System.get_env("ARCADEDB_URL", "http://localhost:2480")
password = System.get_env("ARCADEDB_PASSWORD", "playwithdata")
db = Application.fetch_env!(:ash_arcadic_tour, :database)
Arcadic.connect(url, db, auth: {"root", password}, timeout: 15_000)
end
end
{:ok, true} = Arcadic.Server.health?(admin)
Define resources
One document resource (CRUD, queries, keyset, bulk, vector dense + sparse + hybrid full-text) and one person resource (edges + graph traversal). Both are multitenant — AshArcadic's flagship posture: every read, write, traversal, vector search, and keyset cursor is fail-closed tenant-scoped.
defmodule Tour.Domain do
use Ash.Domain, validate_config_inclusion?: false
resources do
allow_unregistered? true
end
end
defmodule Tour.Doc do
use Ash.Resource, domain: Tour.Domain, data_layer: AshArcadic.DataLayer
arcade do
client Tour.ArcadicClient
label :Doc
vector_index :embedding, dimensions: 3, similarity: :cosine
sparse_vector_index :sparse_embedding, tokens: :tokens, weights: :weights
end
attributes do
attribute :id, :string, primary_key?: true, allow_nil?: false, public?: true
attribute :org_id, :string, public?: true
attribute :title, :string, public?: true
attribute :body, :string, public?: true
attribute :score, :integer, public?: true
attribute :published_at, :utc_datetime, public?: true
attribute :embedding, {:array, :float}, public?: true
attribute :tokens, {:array, :integer}, public?: true
attribute :weights, {:array, :float}, public?: true
end
calculations do
calculate :banner, :string, expr(title <> "!"), public?: true
end
multitenancy do
strategy :attribute
attribute :org_id
end
actions do
default_accept [:id, :org_id, :title, :body, :score, :published_at, :embedding,
:tokens, :weights]
defaults [:create, :update, :destroy]
read :read do
primary? true
pagination keyset?: true, offset?: true, countable: true, required?: false
end
create :upsert do
upsert? true
end
update :bump do
change atomic_update(:score, expr(score + 5))
end
read :semantic_search do
argument :query_vector, {:array, :float}, allow_nil?: false
argument :k, :integer, allow_nil?: false
prepare {AshArcadic.Preparations.VectorSearch, index: :embedding}
end
read :sparse_search do
argument :query_tokens, {:array, :integer}, allow_nil?: false
argument :query_weights, {:array, :float}, allow_nil?: false
argument :k, :integer, allow_nil?: false
prepare {AshArcadic.Preparations.VectorSearch, kind: :sparse, index: :sparse_embedding}
end
read :hybrid_search do
argument :query_vector, {:array, :float}, allow_nil?: false
argument :query_tokens, {:array, :integer}, allow_nil?: false
argument :query_weights, {:array, :float}, allow_nil?: false
argument :text_query, :string, allow_nil?: false
argument :k, :integer, allow_nil?: false
prepare {AshArcadic.Preparations.VectorSearch,
kind: :hybrid,
arms: [{:dense, :embedding}, {:sparse, :sparse_embedding}, {:fulltext, :body}],
fusion: :rrf}
end
end
end
defmodule Tour.Person do
use Ash.Resource, domain: Tour.Domain, data_layer: AshArcadic.DataLayer
arcade do
client Tour.ArcadicClient
label :Person
edge :knows do
label :KNOWS
direction :outgoing
destination Tour.Person
properties [:since]
end
end
attributes do
attribute :id, :string, primary_key?: true, allow_nil?: false, public?: true
attribute :org_id, :string, public?: true
attribute :name, :string, public?: true
end
multitenancy do
strategy :attribute
attribute :org_id
end
relationships do
# Graph traversal AS an Ash relationship: everyone reachable over :KNOWS
# within 1..2 hops — per-hop tenant-scoped (every node AND edge on the path).
has_many :network, __MODULE__ do
manual(
{AshArcadic.ManualRelationships.Traverse,
edge_label: :KNOWS, direction: :outgoing, min_depth: 1, max_depth: 2}
)
end
end
actions do
default_accept [:id, :org_id, :name]
defaults [:read, :create]
update :befriend do
require_atomic? false
argument :to, {:array, :string}
argument :since, :string
change {AshArcadic.Changes.CreateEdge, edge: :knows, to: :to}
end
end
end
CRUD & upsert
Plain Ash. The MERGE-based upsert is idempotent — run the cell twice, still one
row.
require Ash.Query
Ash.create!(Tour.Doc, %{id: "d1", title: "Graphs", body: "graph graph databases", score: 10}, tenant: "org1")
Ash.create!(Tour.Doc, %{id: "d2", title: "Vectors", body: "embeddings nearest", score: 30}, tenant: "org1")
# Upsert: first call creates, second call (same id) updates in place.
Tour.Doc
|> Ash.Changeset.for_create(:upsert, %{id: "d3", title: "Cypher", body: "match merge", score: 20}, tenant: "org1")
|> Ash.create!()
Tour.Doc
|> Ash.Changeset.for_create(:upsert, %{id: "d3", title: "OpenCypher", body: "match merge", score: 25}, tenant: "org1")
|> Ash.create!()
docs = Tour.Doc |> Ash.Query.sort(score: :asc) |> Ash.read!(tenant: "org1")
Enum.map(docs, &{&1.id, &1.title, &1.score})
Atomic updates
An atomic_update change pushes down to a Cypher SET n.score = n.score + $p —
the arithmetic runs in the engine, not in your app (also available on creates
and upserts, and in bulk).
doc = Ash.get!(Tour.Doc, "d1", tenant: "org1")
updated =
doc
|> Ash.Changeset.for_update(:bump, %{}, tenant: "org1")
|> Ash.update!()
%{score_after: updated.score, score_before: doc.score}
Query push-down
Filters, sorts, and counts compile to parameterized Cypher — including temporal comparisons (AshArcadic wraps datetime/time params in ArcadeDB's native temporal constructors, so they compare temporal-to-temporal, not string-to-native).
Ash.update!(Ash.get!(Tour.Doc, "d1", tenant: "org1"), %{published_at: ~U[2024-06-15 12:00:00Z]})
Ash.update!(Ash.get!(Tour.Doc, "d2", tenant: "org1"), %{published_at: ~U[2023-12-31 23:59:59Z]})
high_scores =
Tour.Doc
|> Ash.Query.filter(score > 15 and title != "nope")
|> Ash.Query.sort(score: :desc)
|> Ash.read!(tenant: "org1")
recent =
Tour.Doc
|> Ash.Query.filter(published_at > ^~U[2024-01-01 00:00:00Z])
|> Ash.read!(tenant: "org1")
page = Ash.read!(Tour.Doc, tenant: "org1", page: [limit: 2, count: true])
%{
high_scores: Enum.map(high_scores, &{&1.id, &1.score}),
recent: Enum.map(recent, & &1.id),
total: page.count
}
Aggregates & calculations
Query aggregates run as one tenant-scoped Cypher statement each; expression calculations load in Elixir (or push down into filters/sorts).
count = Ash.count!(Tour.Doc, tenant: "org1")
avg = Ash.avg(Tour.Doc, :score, tenant: "org1")
max = Ash.max(Tour.Doc, :score, tenant: "org1")
# A loaded expression calculation:
banners =
Tour.Doc
|> Ash.Query.load(:banner)
|> Ash.Query.sort(score: :desc)
|> Ash.read!(tenant: "org1")
|> Enum.map(& &1.banner)
%{count: count, avg: avg, max: max, banners: banners}
Keyset streaming
can?(:keyset) powers cursor pagination and Ash.stream! — bounded memory over
any result size, correct across duplicate sort values (primary-key tiebreaker).
for i <- 1..25 do
Ash.create!(
Tour.Doc,
%{id: "s#{String.pad_leading(to_string(i), 2, "0")}", title: "Doc #{i}", score: rem(i, 5)},
tenant: "org1"
)
end
# Cursor pagination: walk a page, then ask for what follows it.
page1 = Tour.Doc |> Ash.Query.sort(score: :asc) |> Ash.read!(tenant: "org1", page: [limit: 5])
cursor = List.last(page1.results).__metadata__.keyset
page2 =
Tour.Doc
|> Ash.Query.sort(score: :asc)
|> Ash.read!(tenant: "org1", page: [limit: 5, after: cursor])
# Or just stream the whole set — keyset batches under the hood.
streamed =
Tour.Doc
|> Ash.Query.sort(score: :asc)
|> Ash.stream!(tenant: "org1", batch_size: 10)
|> Enum.count()
%{page1: length(page1.results), page2: length(page2.results), streamed_total: streamed}
Concurrent bulk writes
With transaction: false each batch is one autocommit UNWIND statement, and
AshArcadic retries optimistic-lock conflicts at two levels (ArcadeDB's server-side
statement retry + client jittered backoff) — so concurrent bulk converges, even
on a freshly auto-created type.
rows = for i <- 1..200, do: %{id: "b#{i}", title: "Bulk #{i}", score: rem(i, 10)}
result =
Ash.bulk_create(rows, Tour.Doc, :create,
tenant: "org1",
transaction: false,
max_concurrency: 8,
batch_size: 25,
return_errors?: true
)
count = Tour.Doc |> Ash.Query.filter(contains(id, "b")) |> Ash.count!(tenant: "org1")
%{status: result.status, persisted: count}
Multitenancy is fail-closed
Same database, different tenant — none of org1's rows are reachable. The discriminator is injected by the data layer on every path (reads, writes, bulk, traversal, vector, keyset cursors); a missing tenant on a tenant-strategy resource is an error, never a global read.
org2_sees = Ash.read!(Tour.Doc, tenant: "org2")
org1_count = Ash.count!(Tour.Doc, tenant: "org1")
%{org2_sees: length(org2_sees), org1_count: org1_count}
Vector search — dense, sparse, hybrid
Declare the indexes in the arcade block (compile-checked metadata); the host
creates the physical indexes once (no migration machinery). Results come back
ranked with distance/score in record metadata — tenant-scoped like everything
else.
# One-time host-side schema (pairs with the declarations): the vector properties'
# physical types, the data, then the indexes.
Arcadic.command!(admin, "CREATE PROPERTY Doc.embedding ARRAY_OF_FLOATS", %{}, language: "sql")
Arcadic.command!(admin, "CREATE PROPERTY Doc.tokens ARRAY_OF_INTEGERS", %{}, language: "sql")
Arcadic.command!(admin, "CREATE PROPERTY Doc.weights ARRAY_OF_FLOATS", %{}, language: "sql")
Arcadic.command!(admin, "CREATE PROPERTY Doc.body STRING", %{}, language: "sql")
Ash.update!(Ash.get!(Tour.Doc, "d1", tenant: "org1"), %{
embedding: [1.0, 0.0, 0.0], tokens: [10, 20], weights: [1.0, 0.5]
})
Ash.update!(Ash.get!(Tour.Doc, "d2", tenant: "org1"), %{
embedding: [0.0, 1.0, 0.0], tokens: [20, 30], weights: [1.0, 0.5]
})
Ash.update!(Ash.get!(Tour.Doc, "d3", tenant: "org1"), %{
embedding: [0.9, 0.1, 0.0], tokens: [10, 30], weights: [0.9, 0.1]
})
Arcadic.Vector.create_dense_index!(admin, "Doc", "embedding", 3, similarity: :cosine)
:ok = Arcadic.FullText.create_index(admin, "Doc", "body")
:ok = Arcadic.Vector.create_sparse_index(admin, "Doc", "tokens", "weights")
# Dense kNN — ranked by distance, closest first.
nearest =
Tour.Doc
|> Ash.Query.for_read(:semantic_search, %{query_vector: [1.0, 0.0, 0.0], k: 2})
|> Ash.read!(tenant: "org1", authorize?: false)
dense = Enum.map(nearest, &{&1.id, &1.__metadata__[:vector_distance]})
# Sparse (learned-sparse / BM25-style) kNN — ranked by score, higher = better.
sparse =
Tour.Doc
|> Ash.Query.for_read(:sparse_search, %{query_tokens: [10], query_weights: [1.0], k: 2})
|> Ash.read!(tenant: "org1", authorize?: false)
sparse_hits = Enum.map(sparse, &{&1.id, &1.__metadata__[:vector_score]})
# Hybrid fusion — dense + sparse + full-text arms fused (RRF) into one list.
hybrid =
Tour.Doc
|> Ash.Query.for_read(:hybrid_search, %{
query_vector: [1.0, 0.0, 0.0],
query_tokens: [10],
query_weights: [1.0],
text_query: "graph",
k: 3
})
|> Ash.read!(tenant: "org1", authorize?: false)
hybrid_hits = Enum.map(hybrid, &{&1.id, &1.__metadata__[:vector_score]})
%{dense: dense, sparse: sparse_hits, hybrid: hybrid_hits}
Graph: edges & traversal
Edges are first-class writes (CreateEdge/DestroyEdge changes); traversal is an
Ash relationship, so it composes with loading, aggregates, and policies. The
traversal predicate scopes every node and edge on the path to the tenant.
for {id, name} <- [{"ann", "Ann"}, {"bo", "Bo"}, {"cy", "Cy"}] do
Ash.create!(Tour.Person, %{id: id, name: name}, tenant: "org1")
end
# ann -> bo -> cy over :KNOWS edges (edge writes ride an update action).
Tour.Person
|> Ash.get!("ann", tenant: "org1")
|> Ash.Changeset.for_update(:befriend, %{to: ["bo"], since: "2024"}, tenant: "org1")
|> Ash.update!()
Tour.Person
|> Ash.get!("bo", tenant: "org1")
|> Ash.Changeset.for_update(:befriend, %{to: ["cy"], since: "2025"}, tenant: "org1")
|> Ash.update!()
# ann's 1..2-hop network: bo (direct) AND cy (through bo).
ann = Tour.Person |> Ash.get!("ann", tenant: "org1") |> Ash.load!(:network, tenant: "org1")
ann.network |> Enum.map(& &1.name) |> Enum.sort()
CDC mirror — Postgres→ArcadeDB effect-once sink
An optional subsystem (AshArcadic.Replicant.*) mirrors a Postgres logical-replication
stream into an ArcadeDB graph projection exactly once — over the sibling replicant
CDC transport. In production replicant decodes the WAL into %Replicant.Transaction{}
structs and delivers them to the sink; here we hand-build one to show the effect-once
contract end-to-end against the same throwaway DB.
The mirror is a normal AshArcadic graph resource carrying the AshArcadic.Replicant
extension: replicant do source_table ... tenant_attribute ... end declares which Postgres
{schema, table} it mirrors and which source column carries the tenant. It is seam-locked —
a forbid_if always() policy forbids ordinary writes, so only the sink writes (bypassing
with authorize?: false).
defmodule Tour.MirrorDoc do
use Ash.Resource,
domain: Tour.MirrorDomain,
validate_domain_inclusion?: false,
data_layer: AshArcadic.DataLayer,
extensions: [AshArcadic.Replicant],
authorizers: [Ash.Policy.Authorizer]
arcade do
client Tour.ArcadicClient
label :MirrorDoc
end
replicant do
source_table "docs"
tenant_attribute :org
end
attributes do
attribute :id, :string, primary_key?: true, allow_nil?: false, public?: true
attribute :org, :string, allow_nil?: false, public?: true
attribute :title, :string, public?: true
end
multitenancy do
strategy :attribute
attribute :org
end
actions do
default_accept [:id, :org, :title]
defaults [:read, :create, :update, :destroy]
end
# Seam-lock: forbid ordinary writes so only the sink writes (authorize?: false).
policies do
policy always() do
forbid_if always()
end
end
end
# The sink resolves its mirror targets from a domain's REGISTERED resources, so the mirror
# lives in its own explicitly-registered domain (not the tour's allow_unregistered? one — an
# unregistered domain yields an empty resolver index, which fails closed :empty_index).
defmodule Tour.MirrorDomain do
use Ash.Domain, validate_config_inclusion?: false
resources do
resource Tour.MirrorDoc
end
end
The checkpoint is an ArcadeDB-resident watermark vertex (one integer LSN per slot); the
sink is a Replicant.Sink impl baked with the host's config. The watermark advance rides
the SAME Ash.transaction as the mirrored writes — that co-commit is the whole effect-once
mechanism. The checkpoint client: must point at the same database as the mirror.
defmodule Tour.ReplicantCheckpoint do
use AshArcadic.ReplicantCheckpoint,
domain: Tour.Domain,
client: Tour.ArcadicClient
end
defmodule Tour.ReplicantSink do
use AshArcadic.ReplicantSink,
domains: [Tour.MirrorDomain],
checkpoint_resource: Tour.ReplicantCheckpoint,
slot_name: "tour"
end
Feed a hand-built transaction (LSN 10, one insert) through handle_transaction/1. It upserts
the mirror vertex AND advances the watermark in one session, returning {:ok, 10}. The source
record is string-keyed (Postgres column names), and org — the tenant_attribute — resolves
the tenant the sink writes under.
txn = %Replicant.Transaction{
commit_lsn: 10,
changes: [
%Replicant.Change{
op: :insert,
schema: "public",
table: "docs",
record: %{"id" => "d1", "org" => "acme", "title" => "hello"}
}
]
}
{:ok, 10} = Tour.ReplicantSink.handle_transaction(txn)
# The mirrored vertex (peek past the seam-lock with authorize?: false).
Tour.MirrorDoc
|> Ash.read!(tenant: "acme", authorize?: false)
|> Enum.map(&{&1.id, &1.org, &1.title})
The durable watermark now reads 10.
Tour.ReplicantSink.checkpoint()
Re-deliver the SAME commit_lsn: 10 — a replay. The integer <= gate skips it: it returns
the held watermark and applies nothing (dup = 0). The vertex count is unchanged.
before = Tour.MirrorDoc |> Ash.count!(tenant: "acme", authorize?: false)
{:ok, 10} = Tour.ReplicantSink.handle_transaction(txn)
after_replay = Tour.MirrorDoc |> Ash.count!(tenant: "acme", authorize?: false)
%{replay: :no_op, count_before: before, count_after: after_replay}
A higher commit_lsn: 11 update upserts in place and advances the watermark to 11.
txn2 = %Replicant.Transaction{
commit_lsn: 11,
changes: [
%Replicant.Change{
op: :update,
schema: "public",
table: "docs",
record: %{"id" => "d1", "org" => "acme", "title" => "hello (v2)"},
old_record: %{"id" => "d1", "org" => "acme"}
}
]
}
{:ok, 11} = Tour.ReplicantSink.handle_transaction(txn2)
titles = Tour.MirrorDoc |> Ash.read!(tenant: "acme", authorize?: false) |> Enum.map(&{&1.id, &1.title})
%{titles: titles, watermark: Tour.ReplicantSink.checkpoint()}
Cleanup
Arcadic.Server.drop_database(admin, db)
Where next
usage-rules.md— the per-feature fine print: supported operators, sortable types, vector scoping rules, bulk-write concurrency, documented limitations.README.md— capability matrix and connection setup.usage-rules.md→ CDC mirror — the full Postgres→ArcadeDB effect-once sink contract: the eight fail-closed consumer contracts, the checkpoint / sink / pipeline wiring, and theREPLICA IDENTITY FULLprecondition for a tenant-scoped mirror.- Troubleshooting — the error catalog: every fail-closed error and its fix.
- Telemetry — the full event catalog.
- Combinations (
union/intersect/except),update_many, traversal aggregates, query-scoped bulk update/destroy — all live; see the test suite for working examples of every capability.