Current section
Files
Jump to
Current section
Files
notebooks/operations.livemd
# Operations with Arcadic
## Setup
This notebook is an **operations** tour of `Arcadic`: the parts of the surface a
running application leans on day to day rather than in its first query — schema
migrations, import/export, backup/restore, streaming, telemetry — followed by
ArcadeDB's event and programmability layer: live change events, user-defined
functions, triggers, and geospatial indexing.
This notebook needs a running **ArcadeDB**. The quickest way is Docker:
```
docker run -d --name arcadic-operations \
-p 2480:2480 \
-e JAVA_OPTS="-Darcadedb.server.rootPassword=playwithdata" \
arcadedata/arcadedb:latest
```
Only the HTTP port (**2480**) is needed — every operation below, including the
change-events `/ws` feed, rides the same HTTP endpoint; none of it needs Bolt
(see [`getting_started.livemd`](getting_started.livemd) for the Bolt transport).
Every connection value can be overridden with an environment variable set before
you launch Livebook (`ARCADIC_HTTP_URL`, `ARCADIC_PASSWORD`); otherwise the
defaults match the `docker run` above.
> **Re-running cells.** Most cells are idempotent: the migration, the DDL
> (`IF NOT EXISTS`/idempotent-by-default index/function-redefine/trigger-recreate
> patterns), and the read-only cells are all safe to re-run. **Two spots are not
> re-run-safe in isolation** (mirrors the callout in
> [`graphrag.livemd`](graphrag.livemd)): the **Import & export** section's seed
> inserts add two more `Metric` documents each time you re-run just that cell,
> and the **Functions & triggers** section's `CREATE VERTEX` add another
> `Reading`/`AuditLog` row each time you re-run just that line. Running the whole
> notebook top to bottom (or **Cleanup** then top to bottom) always ends up in
> the same clean state.
## Install
> **Requires an unreleased arcadic build.** `Arcadic.Changes`, `Arcadic.Function`,
> `Arcadic.Trigger`, `Arcadic.MaterializedView`, and `Arcadic.Geo` — everything
> from **Change events** onward below — are not in any published release yet.
> Concretely, as of this writing: Hex hosts only `0.4.0`, so the `~> 0.6`
> requirement below doesn't resolve at all (`Mix.install` fails immediately with
> `version solving failed`, not a silent downgrade to `0.4.0`); the modules also
> aren't yet on the `main` branch on GitHub, so `{:arcadic, github:
> "baselabs/arcadic"}` won't have them either until that catches up. The only way
> to run this notebook today is a local path dependency against a checkout that
> has these modules, e.g. `{:arcadic, path: "/path/to/your/arcadic/checkout"}` in
> place of the pin below. Once a release carrying them is published (and pushed),
> the `~> 0.6` pin resolves normally and this note no longer applies.
```elixir
Mix.install([
{:arcadic, "~> 0.7"},
{:mint_web_socket, "~> 1.0"},
{:kino, "~> 0.14"}
])
```
## Connect
We create a throwaway `arcadic_operations` database to play in (only if it does
not already exist, so this cell is safe to re-run).
```elixir
http_url = System.get_env("ARCADIC_HTTP_URL", "http://localhost:2480")
password = System.get_env("ARCADIC_PASSWORD", "playwithdata")
database = "arcadic_operations"
admin = Arcadic.connect(http_url, database, auth: {"root", password})
{:ok, true} = Arcadic.Server.ready?(admin)
unless match?({:ok, true}, Arcadic.Server.database_exists?(admin, database)) do
:ok = Arcadic.Server.create_database(admin, database)
end
conn = Arcadic.connect(http_url, database, auth: {"root", password})
```
## Schema & migrations
`Arcadic.Migrator` runs `Arcadic.Migration`s in order and records applied
versions in `_arcadic_migrations`, so re-running is a no-op. This defines the
`Reading` vertex type the rest of the notebook writes to.
```elixir
defmodule Ops.Migrations.V1 do
@behaviour Arcadic.Migration
@impl true
def version, do: 1
@impl true
def up(c) do
Arcadic.command!(c, "CREATE VERTEX TYPE Reading IF NOT EXISTS", %{}, language: "sql")
Arcadic.command!(c, "CREATE PROPERTY Reading.sensor IF NOT EXISTS STRING", %{}, language: "sql")
Arcadic.command!(c, "CREATE PROPERTY Reading.value IF NOT EXISTS DOUBLE", %{}, language: "sql")
:ok
end
@impl true
def down(c), do: Arcadic.command!(c, "DROP TYPE Reading IF EXISTS", %{}, language: "sql") && :ok
end
defmodule Ops.Migrations do
use Arcadic.MigrationRegistry
migrations([Ops.Migrations.V1])
end
{:ok, applied} = Arcadic.Migrator.migrate(conn, Ops.Migrations)
{:ok, status} = Arcadic.Migrator.status(conn, Ops.Migrations)
{applied, status}
```
`Arcadic.Schema` reads the database's own catalog — the `Reading` type the
migration just created shows up here too.
```elixir
Arcadic.Schema.types!(conn) |> Enum.map(& &1["name"])
```
## Import & export
`Arcadic.Export.database!/3` runs ArcadeDB's `EXPORT DATABASE` server-side, and
`Arcadic.Import.database!/3` its inverse. Both take a bare, allowlisted name —
never a caller path — and settings via `with:`.
> Re-running just the two seed inserts below duplicates `Metric` documents (see
> the Setup heads-up); a top-to-bottom re-run is always clean.
```elixir
Arcadic.command!(conn, "CREATE DOCUMENT TYPE Metric IF NOT EXISTS", %{}, language: "sql")
Arcadic.command!(conn, "INSERT INTO Metric SET n = 1", %{}, language: "sql")
Arcadic.command!(conn, "INSERT INTO Metric SET n = 2", %{}, language: "sql")
export_name = "arcadic_operations_export"
Arcadic.Export.database!(conn, export_name, with: [overwrite: true])
```
Import it into a second, disposable database to prove the round trip. ArcadeDB
writes `EXPORT DATABASE` artifacts to a server-local directory (`with:
overwrite: true` above lets this cell re-run); the path below matches the
official `arcadedata/arcadedb` image — override it if your setup differs.
```elixir
import_dst = "arcadic_operations_import_dst"
_ = Arcadic.Server.drop_database(admin, import_dst)
:ok = Arcadic.Server.create_database!(admin, import_dst)
dst_conn = Arcadic.connect(http_url, import_dst, auth: {"root", password})
import_rows = Arcadic.Import.database!(dst_conn, "file:///home/arcadedb/exports/#{export_name}")
# The round trip proven, drop the throwaway destination immediately.
Arcadic.Server.drop_database(admin, import_dst)
import_rows
```
## Backup & restore
`Arcadic.Backup.backup!/2` runs `BACKUP DATABASE` on the current database and
returns the server-generated `backupFile` name; `list!/1` lists tracked backups.
```elixir
%{"backupFile" => backup_file} = Arcadic.Backup.backup!(conn)
backups = Arcadic.Backup.list!(conn)
{backup_file, backups}
```
`Arcadic.Backup.restore!/3` creates a **new** database from a backup file — the
target must not already exist. ArcadeDB writes each database's backups under
its own subdirectory of the server's backup root (again, the official image's
default path; override it if yours differs).
```elixir
restore_check = "arcadic_operations_restore_check"
_ = Arcadic.Server.drop_database(admin, restore_check)
Arcadic.Backup.restore!(
conn,
restore_check,
"file:///home/arcadedb/backups/#{database}/#{backup_file}"
)
restored_conn = Arcadic.connect(http_url, restore_check, auth: {"root", password})
proof = Arcadic.query!(restored_conn, "SELECT count(*) AS c FROM Metric", %{}, language: "sql")
# Proof done — drop the throwaway restore target.
Arcadic.Server.drop_database(admin, restore_check)
proof
```
## Streaming
`Arcadic.query_stream/4` streams a large read as a lazy `Stream` over HTTP,
paging behind the scenes so the whole result never sits in memory at once — see
[`getting_started.livemd`](getting_started.livemd) for the full HTTP-vs-Bolt
walkthrough (Bolt is unused here; every operation below is HTTP-only).
```elixir
Arcadic.command!(conn, "MERGE (r:Reading {sensor: $s}) SET r.value = $v", %{
"s" => "temp-1",
"v" => 21.5
})
Arcadic.command!(conn, "MERGE (r:Reading {sensor: $s}) SET r.value = $v", %{
"s" => "temp-2",
"v" => 19.0
})
{:ok, stream} =
Arcadic.query_stream(conn, "SELECT sensor, value FROM Reading", %{},
language: "sql",
chunk_size: 5
)
Enum.to_list(stream) |> Kino.DataTable.new()
```
## Telemetry
Every `query`/`command` emits a `:telemetry` span
(`[:arcadic, :query | :command, :start | :stop | :exception]`) whose metadata is
**value-free by construction** — `Arcadic.Telemetry`'s allowlist admits only
shape (`language`, `mode`, `http_status`, `reason`, `row_count`,
`in_transaction?`, …), never a statement, params, or a row's values.
```elixir
handler_id = "operations-notebook"
notebook_pid = self()
:telemetry.attach_many(
handler_id,
[[:arcadic, :query, :stop], [:arcadic, :command, :stop]],
fn event, measurements, metadata, _config ->
send(notebook_pid, {:telemetry, event, measurements, metadata})
end,
nil
)
Arcadic.command!(conn, "MERGE (r:Reading {sensor: $s}) SET r.value = $v", %{
"s" => "temp-3",
"v" => 5.0
})
:telemetry.detach(handler_id)
```
```elixir
receive do
{:telemetry, event, measurements, metadata} -> {event, measurements, metadata}
after
2000 -> :no_telemetry_event
end
```
## Change events
`Arcadic.Changes` is arcadic's one caller-supervised process: it holds
ArcadeDB's `/ws` change-events socket open and pushes each change to a single
subscriber as `{:arcadic_change, %Arcadic.Changes.Event{}}`. It is
**best-effort at-most-once** — `/ws` has no replay, so a `:reconnected` marker
(after a dropped socket) or an `:overflow` marker (a slow subscriber's buffer
dropped events) both obligate the subscriber to reconcile; a terminal
`{:arcadic_change_error, :unauthorized}` message means the handshake was
rejected and the process has stopped.
We start it via `Kino.start_child/1` so re-evaluating this cell cleanly stops
the previous socket before opening a new one.
```elixir
{:ok, changes} = Kino.start_child({Arcadic.Changes, conn: conn})
:ok = Arcadic.Changes.subscribe(changes, database, type: "Reading")
# Small margin for the subscribe frame to reach the server before the next
# cell's write — otherwise the write could race the subscription.
Process.sleep(500)
```
Trigger a change from a separate cell (over the same `conn`, just to show the
event is a live push, not a poll):
```elixir
Arcadic.command!(conn, "MERGE (r:Reading {sensor: $s}) SET r.value = $v", %{
"s" => "temp-4",
"v" => 99.0
})
```
Receive it:
```elixir
receive do
{:arcadic_change, %Arcadic.Changes.Event{change_type: :create} = event} ->
event
{:arcadic_change, %Arcadic.Changes.Event{change_type: :reconnected}} ->
:reconnected_reconcile_needed
{:arcadic_change_error, :unauthorized} ->
raise "change-events subscription was rejected (unauthorized)"
after
5000 -> raise "timed out waiting for the change event"
end
```
## Functions & triggers
`Arcadic.Function.define!/4` emits `DEFINE FUNCTION` — a dotted `library.fn`
name, a single-line body (ArcadeDB's `"..."` body literal has no escape, so a
multi-line or quote-carrying body is rejected client-side before any wire
call), and `opts` (`:params`, `:language`). Redefining an existing function errors
server-side, so the idempotent-safe shape is delete-then-define. Deleting a
function twice within an existing library is idempotent, but on a fresh database
the `ops` library doesn't exist yet, so the first delete errors — the `_ =`
below discards that, making this cell re-run-safe either way.
```elixir
_ = Arcadic.Function.delete(conn, "ops.celsius_to_fahrenheit")
:ok =
Arcadic.Function.define!(
conn,
"ops.celsius_to_fahrenheit",
"return c * 9 / 5 + 32",
params: [:c],
language: :js
)
```
Call it via the backtick idiom inside an ordinary query — the function name is
interpolated (behind the per-segment allowlist), the argument rides `params`:
```elixir
Arcadic.query!(conn, "SELECT `ops.celsius_to_fahrenheit`(:c) AS f", %{"c" => 21.5}, language: "sql")
```
`Arcadic.Trigger.create!/4` fires a `timing` × `event` action on a type.
`DROP TRIGGER` takes no `IF EXISTS`, so — same idiom as the function above — we
discard the drop's error before creating.
```elixir
Arcadic.command!(conn, "CREATE DOCUMENT TYPE AuditLog IF NOT EXISTS", %{}, language: "sql")
_ = Arcadic.Trigger.drop(conn, "reading_audit")
:ok =
Arcadic.Trigger.create!(conn, "reading_audit", "Reading",
timing: :after,
event: :create,
execute: {:sql, "insert into AuditLog set event = 'reading_created'"}
)
```
Fire it — creating a `Reading` runs the trigger, which writes to `AuditLog`:
```elixir
Arcadic.command!(conn, "CREATE VERTEX Reading SET sensor = 'trigger-demo', value = 1.0", %{},
language: "sql"
)
Arcadic.query!(conn, "SELECT FROM AuditLog", %{}, language: "sql")
```
## Geospatial indexing
`Arcadic.Geo.create_index!/4` builds a `GEOSPATIAL` index over a **string
property holding WKT** (ArcadeDB has no native `POINT` schema type); it is
idempotent by default (`IF NOT EXISTS` unless `if_not_exists: false`).
Geospatial querying itself rides ordinary `Arcadic.query/4` — here,
`distance/2` between a WKT column and a literal `point(...)`.
```elixir
Arcadic.command!(conn, "CREATE VERTEX TYPE Station IF NOT EXISTS", %{}, language: "sql")
Arcadic.command!(conn, "CREATE PROPERTY Station.wkt IF NOT EXISTS STRING", %{}, language: "sql")
Arcadic.command!(conn, "MERGE (s:Station {name: $name}) SET s.wkt = $wkt", %{
"name" => "HQ",
"wkt" => "POINT (-122.42 37.77)"
})
:ok = Arcadic.Geo.create_index!(conn, "Station", "wkt")
```
```elixir
Arcadic.query!(
conn,
"SELECT name, distance(wkt, point(-122.41, 37.78)) AS meters FROM Station",
%{},
language: "sql"
)
```
## Beyond this notebook
`Arcadic.MaterializedView` — `create!/3`/`drop!/2` for ArcadeDB's
`MATERIALIZED VIEW`, parallel to `Function`/`Trigger`/`Geo` above — doesn't get
its own cell here, e.g. `Arcadic.MaterializedView.create!(conn,
"reading_summary", "SELECT sensor, avg(value) AS avg_value FROM Reading GROUP BY sensor")`
followed by an ordinary `Arcadic.query!(conn, "SELECT FROM reading_summary", %{}, language: "sql")`.
## Cleanup
Drop the throwaway database when you are done (or before a fresh top-to-bottom
run). This also tears down anything the sections above created inside it
(`Reading`, `Metric`, `AuditLog`, `Station`, the trigger, the function, and any
backups) — the `import_dst`/`restore_check` databases were already dropped
within their own sections.
```elixir
if Process.alive?(changes), do: GenServer.stop(changes)
Arcadic.Server.drop_database(admin, database)
```