Go — HTTP Driver
github.com/ArcadeData/arcadedb-drivers/go/arcadedb is ArcadeDB’s Go HTTP client, generated from
the server’s OpenAPI contract. A hand-written facade on top covers queries, commands,
transactions, streaming, batch loading, insert sessions, vector search, time series, server
administration, login sessions and API tokens, and listing and terminating running work.
Install
go get github.com/ArcadeData/arcadedb-drivers/go/arcadedb@latest
Requires Go 1.26 or later. Go has no package registry: the module is fetched through the Go module
proxy like any other, and each release is the git tag go/arcadedb/v<version> in the
arcadedb-drivers repository.
Connect and query
package main
import (
"context"
"fmt"
"log"
"github.com/ArcadeData/arcadedb-drivers/go/arcadedb"
)
func main() {
ctx := context.Background()
srv, err := arcadedb.NewServer("http://localhost:2480",
arcadedb.WithBasicAuth("root", "playwithdata"))
if err != nil {
log.Fatal(err)
}
defer srv.Close()
db := srv.DB("mydb")
env, err := db.Query(ctx, arcadedb.SQL, "SELECT FROM Person WHERE age > ?", map[string]any{"0": 21})
if err != nil {
log.Fatal(err)
}
fmt.Println(env.Result, env.Returned, env.Truncated)
}
[map[@cat:v @rid:#1:0 @type:Person age:30 name:Alice] map[@cat:v @rid:#1:1 @type:Person age:25 name:Bob]] 2 false
Query returns a QueryEnvelope. The rows are in Result, a []map[string]any, and the
envelope also carries Limit, Returned and Truncated. Check Truncated before you treat Result as the whole
answer; Native Drivers explains why. Pass arcadedb.WithLimit(n) to Query to
set the row cap for one call (-1 for none). Command takes no limit: WithLimit is a
QueryOption only, so passing it to Command does not compile.
An EXPLAIN or PROFILE statement answers with its execution plan instead of rows: the envelope’s
Explain holds it as indented text and ExplainPlan as the same plan in structured form, while
Result is empty and Returned is 0. An empty Result there is not "no rows matched".
Rows are decoded with encoding/json, so numbers arrive as float64. An integer larger than
253 loses precision.
|
Positional parameters are zero-indexed, and Positional parameters are keyed by their zero-based index as a string. For the single placeholder
above, the key is
Named parameters sidestep the problem: |
NewServer takes functional options:
-
WithBasicAuth(user, password)orWithBearerToken(token), for example a session token fromsrv.Login(see Sessions and API tokens). -
WithHeader(name, value)adds a header to every request. -
WithHTTPClient(client)replaces the defaulthttp.Client.
A *Server is safe for concurrent use, and srv.DB(name) makes no request; it only returns a
handle. Every call takes a context.Context as its first argument. There is no async variant of
anything: run calls in goroutines when you want them concurrent.
|
There is no timeout by default. Without
You can also pass |
Close releases idle connections, including those of a client you passed in with
WithHTTPClient. Call it when you are done with the server handle, usually with defer.
Streaming queries
QueryStream and CommandStream read the result as newline-delimited JSON and return an
iter.Seq2[StreamEvent, error], so you range over them instead of holding the whole result in
memory:
for ev, err := range db.QueryStream(ctx, arcadedb.SQL, "SELECT name FROM Person ORDER BY name", nil) {
if err != nil {
log.Fatal(err)
}
if ev.Stats != nil {
fmt.Println("returned", ev.Stats.Returned, "truncated", ev.Stats.Truncated)
continue
}
fmt.Println(ev.Record["name"])
}
Alice
Bob
returned 2 truncated false
The iterator yields events. Each StreamEvent has either Record or Stats set, never both. The Stats event always comes last in a complete stream and carries the same Limit,
Returned and Truncated values as the buffered envelope. If a stream ends without a Stats
event, it was cut short (a server write timeout or a dropped connection, for example), and the
rows you saw may be a partial answer.
Some behavior to know about:
-
The request is sent on the first iteration and again on every later range over the same iterator. Treat the iterator as single-use.
-
An error that happens after the server has sent its
200status arrives in-band. The driver yields it once as an*ArcadeDBError, and the loop ends. Rows yielded before it stay delivered. ItsStatusis the event’s own status (503 for a retryable conflict, 409 for a duplicated key, 500 for an unexpected failure), and only an event that carries none reads as 200. Up to 0.2.0 it was always 200. -
Leaving the loop early with
breakorreturncloses the response body. -
CommandStreamonly works for read-only statements. The server refuses a mutating statement with400, because the streamed rows would reach you before the commit, and that commit could still roll back. UseCommandfor writes.
Transactions
var total any
err := db.Transaction(ctx, func(tx *arcadedb.Database) error {
if _, err := tx.Command(ctx, arcadedb.SQL, "INSERT INTO Account SET balance = 100", nil); err != nil {
return err
}
env, err := tx.Query(ctx, arcadedb.SQL, "SELECT sum(balance) AS total FROM Account", nil)
if err != nil {
return err
}
total = env.Result[0]["total"]
return nil
})
if err != nil {
log.Fatal(err)
}
fmt.Println("total:", total)
total: 100
Transaction begins a server-side transaction and calls your function with a second
*Database, tx above, that carries the transaction’s session id. It takes a callback instead of
returning a handle because Go has no with statement: a handle would rely on every caller
remembering defer tx.Rollback(), while a callback cannot leak a session. The contract:
-
If the function returns
nil, the transaction commits. -
If the function returns an error or panics, the transaction rolls back and the error is returned (or the panic re-raised). If the rollback also fails, you get a
*arcadedb.TxErrorwhoseUnwrapreturns only your error, soerrors.Isanderrors.Asmatch what your code produced. The rollback’s error stays on theRollbackErrfield. -
If the commit itself fails, a best-effort rollback is issued first, so the server-side session is not left open for
arcadedb.server.httpSessionExpireTimeoutto reap it.
Rollbacks run on context.WithoutCancel(ctx), so a callback that failed because ctx was
cancelled still gets its transaction rolled back.
Watch which handle you call. Calls made through the outer db while a transaction is open do not
join it; each one auto-commits on its own. Only calls made through tx take part. Batch loads cannot join a
transaction at all, so tx.BatchLoad returns arcadedb.ErrBatchInTransaction before sending
anything.
Two error models
Facade methods return an *arcadedb.ArcadeDBError for any non-2xx response. Ready is the one
exception: it answers a 503 with false and no error. Match the error with errors.As:
_, err := db.Query(ctx, arcadedb.SQL, "SELECT FROM NoSuchType", nil)
var aerr *arcadedb.ArcadeDBError
if errors.As(err, &aerr) {
fmt.Println(aerr.Status, aerr.ErrorMessage, aerr.Detail, aerr.RequestID)
}
500 Internal error Type with name 'NoSuchType' was not found 44613703d0741a4c-11
RequestID is the server’s X-Request-Id for that call and differs on every request; use it to
find the failure in the server log. The body’s error string is in ErrorMessage rather than
Error, because Go does not allow a field and a method with the same name.
Two more fields come from the response’s headers: RetryAfter, a time.Duration the server asked
you to wait before retrying (on a 503, or a 409 for an identical request still in flight), and
CommitIndex, the Raft index to send back as X-ArcadeDB-Read-After on a later read. Both are 0
when absent.
|
A 409 has two meanings. It is either "an identical request is still in flight", which is
retryable after |
A rejected time-series write returns a *arcadedb.TimeSeriesWriteError, which embeds
*ArcadeDBError and says how many samples were written and dropped and why. A write is not atomic:
the written samples landed, so re-sending the whole batch writes them twice.
srv.Raw() takes the opposite approach. It returns the generated *generated.ClientWithResponses,
which never turns a status code into an error: err reports transport failures only, and you
check the status yourself.
resp, err := srv.Raw().ListDatabasesWithResponse(ctx)
if err != nil {
log.Fatal(err) // transport failure only
}
fmt.Println(resp.StatusCode(), resp.JSON200.Result)
200 [mydb]
Use Raw() for the endpoints the facade does not wrap: cluster, AI, MCP and metrics operations,
and the two protobuf PromQL remote read and write routes. For server commands use
srv.ServerCommand rather than POST /api/v1/server through Raw(), whose typed response
misreads the server’s {"result": "ok"} answer. Mixing up which of the two surfaces you are
calling is the most common way to end up with an unhandled failure.
|
|
Beyond queries
The database handle also has:
-
db.BatchLoadanddb.BatchLoadStreamfor bulk loading vertices and edges through/api/v1/batch. A load commits everycommitEveryrecords, so it is not atomic: a load that fails partway leaves earlier chunks committed, and retrying the whole payload duplicates them. -
db.Vector()for vector, hybrid and full-text search. -
db.TS(),db.Grafana()anddb.PromQL()for time-series ingestion and querying, includingdb.PromQL().LabelValues. -
db.InsertSessionfor a duplex insert session, below.
The package README linked below covers each of them.
Labelling work with a query tag
arcadedb.WithQueryTag("nightly-report") sends the X-ArcadeDB-Query-Tag header, so you can list
and terminate the work by it. Query, QueryStream, Command, CommandStream and Transaction
all take it; a batch load takes it as generated.ExecuteBatchParams.XArcadeDBQueryTag.
err := db.Transaction(ctx, func(tx *arcadedb.Database) error {
_, err := tx.Command(ctx, arcadedb.SQL, "UPDATE Account SET flagged = true WHERE balance < 0", nil)
return err
}, arcadedb.WithQueryTag("audit"))
A transaction’s tag is sent on the begin request only; the server applies it to every statement in the transaction that carries no tag of its own.
Duplex insert session
db.InsertSession opens an insert session on the server’s /ws endpoint. Unlike BatchLoad,
whose commit policy is fixed before the load starts, a session lets you read each chunk’s
acknowledgement before deciding what to send next, and whether to commit at all:
s, err := db.InsertSession(ctx, arcadedb.WithInsertTargetType("Person"))
if err != nil {
return err
}
defer s.Close() // rolls back if the session is still open; idempotent
ack, err := s.SendChunk(ctx, []map[string]any{{"name": "Alice"}, {"name": "Bob"}})
if err != nil {
return err
}
// ack.Inserted, ack.Failed, ack.Errors: decide here whether to go on. Nothing is durable yet.
res, err := s.Commit(ctx) // res.Outcome == "commit"
A session is not safe for concurrent use: use one per goroutine.
Administration
CreateDatabase, DropDatabase, CreateUser, UpdateUser, DropUser, ListUsers,
SaveGroup, DeleteGroup and ListGroups are on the *Server, and ServerCommand runs any other
server command. All of them are root-only:
err := srv.CreateDatabase(ctx, "tenant_a")
err = srv.CreateUser(ctx, "alice", "a-long-password",
map[string][]string{"tenant_a": {"admin"}}) // database -> groups
users, err := srv.ListUsers(ctx)
err = srv.DropUser(ctx, "alice")
err = srv.DropDatabase(ctx, "tenant_a")
A user or database that already exists is a 409 on ArcadeDB 26.10.2 and later. Older servers answer 403 for a duplicate user and 400 for a duplicate database.
Sessions and API tokens
// Log in as each user once, then keep a client that holds only that user's token.
login, err := srv.LoginAs(ctx, "alice", "a-long-password") // or srv.Login(ctx)
alice := srv.WithToken(login.Token) // srv is unchanged
dbs, err := alice.ListDatabases(ctx) // only what alice may see
err = alice.Logout(ctx)
// A long-lived machine credential (root only).
created, err := srv.CreateAPIToken(ctx, arcadedb.APITokenRequest{Name: "ci", Database: "tenant_a"})
store(created.Token) // the only time the plaintext exists
err = srv.DeleteAPIToken(ctx, created.TokenHash) // by hash, never by token
ListSessions and ListAPITokens list what exists. Each user holds at most 100 sessions, and a
further login silently evicts the oldest, so log in once per user and reuse the token rather than
logging in per request. Wrong Basic credentials are answered 403, not 401; 401 is what an invalid,
expired or logged-out token gets.
Running work
qs, err := srv.ListQueries(ctx, arcadedb.ListQueriesOptions{Tag: "nightly-report"}) // or Database
rs, err := srv.TerminateQueriesByTag(ctx, "nightly-report", arcadedb.TerminateOptions{Wait: 3 * time.Second})
r, err := srv.TerminateQuery(ctx, qs[0].ID, arcadedb.TerminateOptions{})
txs, err := srv.ListTransactions(ctx)
r, err = srv.TerminateTransaction(ctx, txs[0].ID, arcadedb.TerminateOptions{})
p, err := db.Progress(ctx) // long-running maintenance operations
Any authenticated user may call these: root sees and stops every statement, another user only
their own. Each terminate answer carries a Status, and which one you get decides whether you have
anything to undo:
Status |
Meaning |
|---|---|
|
It stopped, and what it wrote was rolled back. |
|
It finished before it noticed, so what it did stands. |
|
It had not stopped when the wait ended; the request’s own outcome is the answer. |
|
No such statement on this server, or another user’s. |
|
Next steps
-
Native Drivers — the result envelope and why
truncatedmatters, and how this driver compares to the others. -
Go — gRPC Driver — the gRPC counterpart for throughput-sensitive server-to-server work.
-
The package README for the full API, including batch loading, vector search and time series.