anonpenguin23 9373c2ad92 feat(rqlite,serverless): add local read consistency and async invocation
- Introduce `BatchQueryConsistency` with `ReadConsistencyNone` to allow
  local SQLite reads, bypassing leader round-trips for performance.
- Add `function_invoke_async` host function to support non-blocking
  fire-and-forget function execution.
2026-06-01 19:59:30 +03:00

419 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package hostfunctions
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strconv"
"time"
"github.com/DeBrosOfficial/network/pkg/rqlite"
"github.com/DeBrosOfficial/network/pkg/serverless"
)
// dbQueryBatchTimeout caps the rqlite round-trip for a single
// `oh.DBQueryBatch` host call. Tighter than the function's invocation
// timeout (typically 15-30s) so a stalled leader doesn't burn the entire
// budget on one batched read; the WASM function still has headroom to
// do downstream work after the read returns. 10s is generous for the
// 167ms-RTT cross-region devnet cluster (one round-trip ~340ms) while
// catching genuine leader stalls quickly.
const dbQueryBatchTimeout = 10 * time.Second
// DBQuery executes a SELECT query and returns JSON-encoded results.
func (h *HostFunctions) DBQuery(ctx context.Context, query string, args []interface{}) ([]byte, error) {
if h.db == nil {
return nil, &serverless.HostFunctionError{Function: "db_query", Cause: serverless.ErrDatabaseUnavailable}
}
var results []map[string]interface{}
if err := h.db.Query(ctx, &results, query, args...); err != nil {
return nil, &serverless.HostFunctionError{Function: "db_query", Cause: err}
}
data, err := json.Marshal(results)
if err != nil {
return nil, &serverless.HostFunctionError{Function: "db_query", Cause: fmt.Errorf("failed to marshal results: %w", err)}
}
return data, nil
}
// DBExecute executes an INSERT/UPDATE/DELETE query and returns affected rows.
//
// IMPORTANT: this returns 0 for BOTH "0 rows affected" AND "SQL error"
// — callers can't distinguish. That ambiguity caused bug #218 (AnChat's
// migrate function silently dropped statements). For new code, prefer
// DBExecuteV2 which returns a typed envelope.
func (h *HostFunctions) DBExecute(ctx context.Context, query string, args []interface{}) (int64, error) {
if h.db == nil {
return 0, &serverless.HostFunctionError{Function: "db_execute", Cause: serverless.ErrDatabaseUnavailable}
}
result, err := h.db.Exec(ctx, query, args...)
if err != nil {
return 0, &serverless.HostFunctionError{Function: "db_execute", Cause: err}
}
affected, _ := result.RowsAffected()
return affected, nil
}
// dbExecuteV2Result is the JSON wire shape returned by DBExecuteV2.
type dbExecuteV2Result struct {
RowsAffected int64 `json:"rows_affected"`
LastInsertID int64 `json:"last_insert_id,omitempty"`
Error string `json:"error,omitempty"`
}
// DBExecuteV2 is the typed equivalent of DBExecute. Returns the same shape
// regardless of success/failure so callers can distinguish "0 rows affected"
// from "SQL error" — fixing the contract gap that caused bug #218.
//
// Returns a Go error only for host-side setup failures (no DB). SQL errors
// are encoded in the JSON envelope's "error" field.
func (h *HostFunctions) DBExecuteV2(ctx context.Context, query string, args []interface{}) ([]byte, error) {
if h.db == nil {
return nil, &serverless.HostFunctionError{
Function: "db_execute_v2",
Cause: serverless.ErrDatabaseUnavailable,
}
}
out := dbExecuteV2Result{}
result, err := h.db.Exec(ctx, query, args...)
if err != nil {
out.Error = err.Error()
buf, mErr := json.Marshal(out)
if mErr != nil {
return nil, &serverless.HostFunctionError{Function: "db_execute_v2", Cause: mErr}
}
return buf, nil
}
if result != nil {
out.RowsAffected, _ = result.RowsAffected()
out.LastInsertID, _ = result.LastInsertId()
}
buf, mErr := json.Marshal(out)
if mErr != nil {
return nil, &serverless.HostFunctionError{Function: "db_execute_v2", Cause: mErr}
}
return buf, nil
}
// dbQueryV2Result is the JSON wire shape returned by DBQueryV2.
type dbQueryV2Result struct {
Rows []map[string]interface{} `json:"rows"`
Error string `json:"error,omitempty"`
}
// DBQueryV2 is the typed equivalent of DBQuery. Distinguishes "empty
// result set" from "query failed" via the "error" field.
func (h *HostFunctions) DBQueryV2(ctx context.Context, query string, args []interface{}) ([]byte, error) {
if h.db == nil {
return nil, &serverless.HostFunctionError{
Function: "db_query_v2",
Cause: serverless.ErrDatabaseUnavailable,
}
}
out := dbQueryV2Result{Rows: []map[string]interface{}{}}
if err := h.db.Query(ctx, &out.Rows, query, args...); err != nil {
out.Error = err.Error()
// Reset rows to non-nil empty on error so callers get a stable shape.
out.Rows = []map[string]interface{}{}
}
buf, mErr := json.Marshal(out)
if mErr != nil {
return nil, &serverless.HostFunctionError{Function: "db_query_v2", Cause: mErr}
}
return buf, nil
}
// dbTransactionRequest is the WASM-side shape for db_transaction input.
type dbTransactionRequest struct {
Ops []rqlite.BatchOp `json:"ops"`
}
// DBTransaction executes ops as a single atomic batch.
// Input is JSON: {"ops": [{"kind":"exec"|"query","sql":"...","args":[...]}, ...]}
// Output is JSON: BatchResult — caller checks `committed` to know if writes landed.
//
// Returns an error only for setup/validation problems. A rolled-back batch is
// communicated via committed=false in the returned JSON; that's not a Go error.
func (h *HostFunctions) DBTransaction(ctx context.Context, opsJSON []byte) ([]byte, error) {
if h.db == nil {
return nil, &serverless.HostFunctionError{Function: "db_transaction", Cause: serverless.ErrDatabaseUnavailable}
}
var req dbTransactionRequest
if err := json.Unmarshal(opsJSON, &req); err != nil {
return nil, &serverless.HostFunctionError{
Function: "db_transaction",
Cause: fmt.Errorf("invalid json: %w", err),
}
}
if len(req.Ops) == 0 {
return nil, &serverless.HostFunctionError{
Function: "db_transaction",
Cause: fmt.Errorf("ops required"),
}
}
if len(req.Ops) > rqlite.MaxBatchOps {
return nil, &serverless.HostFunctionError{
Function: "db_transaction",
Cause: fmt.Errorf("too many ops: max %d", rqlite.MaxBatchOps),
}
}
res, err := h.db.Batch(ctx, req.Ops)
// Always return the structured result, even on rollback — caller wants the
// per-op detail to know which op failed.
if res == nil {
// Unrecoverable setup failure (no native conn). Surface as Go error.
return nil, &serverless.HostFunctionError{Function: "db_transaction", Cause: err}
}
out, mErr := json.Marshal(res)
if mErr != nil {
return nil, &serverless.HostFunctionError{
Function: "db_transaction",
Cause: fmt.Errorf("marshal result: %w", mErr),
}
}
// Rollback errors are encoded in the JSON; do NOT propagate as Go error.
// Only true setup/transport errors after the result was built warrant returning err.
_ = err // intentionally swallowed — committed=false carries the signal
return out, nil
}
// dbQueryBatchRequest is the WASM-side shape for db_query_batch input.
// Each op MUST be Kind=BatchOpQuery; mixing exec is rejected at the
// rqlite layer.
type dbQueryBatchRequest struct {
Ops []rqlite.BatchOp `json:"ops"`
// Consistency is the optional rqlite read level for this batch.
// "" / "weak" (default): leader-routed, always fresh. "none": fast LOCAL
// read (~1ms, no leader hop) — ONLY safe for reads that don't need
// read-your-own-writes freshness (see rqlite.ReadConsistency / bug #235).
// feat-6: lets read-heavy functions skip the cross-region weak-read hop.
Consistency string `json:"consistency,omitempty"`
}
// batchQueryConsistencyClient is the optional capability a Client exposes when
// it can serve reads at an explicit consistency level. The production
// *rqlite.client implements it; bare test mocks don't. Kept OFF the
// rqlite.Client interface so the none-read path doesn't churn every mock.
type batchQueryConsistencyClient interface {
BatchQueryConsistency(ctx context.Context, ops []rqlite.BatchOp, rc rqlite.ReadConsistency) ([]rqlite.OpResult, error)
}
// resolveBatchQuery runs the batched read at the requested consistency.
// Empty or "weak" → the default leader-routed read. "none" → a fast local read
// via the consistency-capable client (degrading to weak only when the client
// can't serve an explicit level — weak is always correct). Unknown values are
// rejected here at the boundary rather than silently downgraded.
func (h *HostFunctions) resolveBatchQuery(ctx context.Context, ops []rqlite.BatchOp, consistency string) ([]rqlite.OpResult, error) {
switch consistency {
case "", string(rqlite.ReadConsistencyWeak):
return h.db.BatchQuery(ctx, ops)
case string(rqlite.ReadConsistencyNone):
if ext, ok := h.db.(batchQueryConsistencyClient); ok {
return ext.BatchQueryConsistency(ctx, ops, rqlite.ReadConsistencyNone)
}
return h.db.BatchQuery(ctx, ops)
default:
return nil, fmt.Errorf("invalid consistency %q (allowed: \"none\", \"weak\")", consistency)
}
}
// dbQueryBatchResult is the JSON wire shape returned to WASM callers.
// `Results` is one entry per input op, in the same order. Per-op errors
// are surfaced in `error`; transport/validation errors come back as a
// Go error from the host fn.
type dbQueryBatchResult struct {
Results []rqlite.OpResult `json:"results"`
}
// DBQueryBatch runs N SELECTs in one round-trip via RQLite's /db/query
// bulk endpoint. Designed for read-heavy functions that gather state
// from multiple tables before doing work (e.g. anchat's message-create
// reads auth + participants + devices = 7-10 SELECTs).
//
// Wire shapes:
//
// in: {"ops": [{"sql":"...","args":[...]}, ...]}
// out: {"results": [{"kind":"query","rows":[...],"error":""}, ...]}
//
// Per-query errors are reported in the per-op `error` field; the host
// fn only returns a Go error on setup/validation/transport failures.
// Kind is auto-set to "query" on input — exec ops are rejected, since
// mixing kinds in a query batch is meaningless and would silently
// drop the writes (see bugboard #270).
//
// Empirical baseline on devnet's cross-region cluster (167ms RTT to
// leader): 10 sequential DBQuery host calls = ~3.5s; one DBQueryBatch
// with 10 statements = ~340ms. 10× speedup.
func (h *HostFunctions) DBQueryBatch(ctx context.Context, opsJSON []byte) ([]byte, error) {
if h.db == nil {
return nil, &serverless.HostFunctionError{Function: "db_query_batch", Cause: serverless.ErrDatabaseUnavailable}
}
var req dbQueryBatchRequest
if err := json.Unmarshal(opsJSON, &req); err != nil {
return nil, &serverless.HostFunctionError{
Function: "db_query_batch",
Cause: fmt.Errorf("invalid json: %w", err),
}
}
if len(req.Ops) == 0 {
return nil, &serverless.HostFunctionError{
Function: "db_query_batch",
Cause: fmt.Errorf("ops required"),
}
}
if len(req.Ops) > rqlite.MaxBatchOps {
return nil, &serverless.HostFunctionError{
Function: "db_query_batch",
Cause: fmt.Errorf("too many ops: max %d", rqlite.MaxBatchOps),
}
}
// Force kind=query for every op. Callers can omit the field; this
// makes the wire format more ergonomic AND prevents accidental exec
// ops from being silently dropped by the rqlite-side validator.
for i := range req.Ops {
req.Ops[i].Kind = rqlite.BatchOpQuery
}
// Explicit batch-level deadline. The caller's ctx already carries the
// function's invocation timeout (typically 15-30s), but we want a
// tighter cap on the rqlite round-trip itself so a stalled leader
// doesn't burn the entire invocation budget on one batched query.
// Leaves headroom for downstream WASM work after the read returns.
batchCtx, cancel := context.WithTimeout(ctx, dbQueryBatchTimeout)
defer cancel()
results, err := h.resolveBatchQuery(batchCtx, req.Ops, req.Consistency)
if err != nil {
return nil, &serverless.HostFunctionError{Function: "db_query_batch", Cause: err}
}
out, mErr := json.Marshal(dbQueryBatchResult{Results: results})
if mErr != nil {
return nil, &serverless.HostFunctionError{
Function: "db_query_batch",
Cause: fmt.Errorf("marshal result: %w", mErr),
}
}
return out, nil
}
// execAndPublishResult is the JSON wire shape returned to WASM callers.
type execAndPublishResult struct {
Results []rqlite.OpResult `json:"results"`
Committed bool `json:"committed"`
FailedIndex int `json:"failed_index,omitempty"`
Seq int64 `json:"seq,omitempty"`
Published bool `json:"published,omitempty"`
PublishError string `json:"publish_error,omitempty"`
}
// ExecAndPublish runs ops atomically (with a seq increment in the same batch)
// and, if committed, publishes data with `{{seq}}` substituted for the
// assigned per-namespace sequence number.
//
// Failure modes (each communicated in the JSON, not as Go error):
// - Rollback: committed=false, failed_index points to the failing user op
// - Publish failed but commit succeeded: committed=true, published=false,
// publish_error is set. Writes are durable; caller can retry the publish.
// - Both succeeded: committed=true, published=true.
//
// Returns a Go error only on setup failures (no DB, bad JSON, no namespace).
func (h *HostFunctions) ExecAndPublish(
ctx context.Context, opsJSON []byte, topic string, dataTemplate []byte,
) ([]byte, error) {
if h.db == nil {
return nil, &serverless.HostFunctionError{
Function: "exec_and_publish",
Cause: serverless.ErrDatabaseUnavailable,
}
}
if h.pubsub == nil {
return nil, &serverless.HostFunctionError{
Function: "exec_and_publish",
Cause: fmt.Errorf("pubsub not available"),
}
}
if topic == "" {
return nil, &serverless.HostFunctionError{
Function: "exec_and_publish",
Cause: fmt.Errorf("topic required"),
}
}
// Resolve namespace from invocation context — server-trusted.
// ctx-attached invCtx wins over singleton; see invocation_context.go.
ns := ""
if cur := h.currentInvocationContext(ctx); cur != nil {
ns = cur.Namespace
}
if ns == "" {
return nil, &serverless.HostFunctionError{
Function: "exec_and_publish",
Cause: fmt.Errorf("no namespace in invocation context"),
}
}
var req dbTransactionRequest
if err := json.Unmarshal(opsJSON, &req); err != nil {
return nil, &serverless.HostFunctionError{
Function: "exec_and_publish",
Cause: fmt.Errorf("invalid ops json: %w", err),
}
}
if len(req.Ops) > rqlite.MaxBatchOps {
return nil, &serverless.HostFunctionError{
Function: "exec_and_publish",
Cause: fmt.Errorf("too many ops: max %d", rqlite.MaxBatchOps),
}
}
batchRes, seq, batchErr := h.db.BatchWithSeq(ctx, ns, req.Ops)
out := execAndPublishResult{}
if batchRes != nil {
out.Results = batchRes.Results
out.Committed = batchRes.Committed
out.FailedIndex = batchRes.FailedIndex
}
// On rollback or pre-publish error, return without publishing.
if batchErr != nil || !out.Committed {
// On a true rollback batchErr may be non-nil; that's already encoded
// in the result. Don't surface as Go error — caller reads `committed`.
_ = batchErr
buf, mErr := json.Marshal(out)
if mErr != nil {
return nil, &serverless.HostFunctionError{Function: "exec_and_publish", Cause: mErr}
}
return buf, nil
}
out.Seq = seq
// Substitute {{seq}} in the data template, then publish.
finalData := bytes.ReplaceAll(
dataTemplate,
[]byte("{{seq}}"),
[]byte(strconv.FormatInt(seq, 10)),
)
if err := h.pubsub.Publish(ctx, topic, finalData); err != nil {
out.PublishError = err.Error()
} else {
out.Published = true
}
buf, mErr := json.Marshal(out)
if mErr != nil {
return nil, &serverless.HostFunctionError{Function: "exec_and_publish", Cause: mErr}
}
return buf, nil
}