Skip to content
Query.Farm
Talk with Us

Report errors well

Every callback returns an error, and what you return decides what the person running the query sees. Returning the right type is the difference between “Invalid Input Error: argument “count” (position 0): must be >= 0“ and an opaque runtime failure.

Return one of these and the extension surfaces it with a matching error class, so DuckDB reports it the way it reports its own errors of that kind.

TypeUse it for
ArgumentErrorA bad argument value — out of range, wrong shape, failing a constraint. Carries the argument name and position.
SchemaValidationErrorInput didn’t match the schema you bound — wrong column count, wrong type.
TypeBoundErrorAn any argument arrived as a type outside its declared bound=.
UnknownFunctionErrorA name the worker doesn’t serve. Mostly raised by the framework.
CatalogReadOnlyErrorA DML attempt against a read-only catalog.
WorkerPanicErrorRaised for you when a callback panics — see below.

Anything else becomes a generic RuntimeError carrying your message.

Do not wrap a typed error with %w — it loses the type

AsRpcError maps errors with a type switch, not errors.As. So the idiomatic Go move is exactly wrong here:

// The type is lost — this reaches DuckDB as a generic RuntimeError.
return fmt.Errorf("validating count: %w", argErr)

// Return it unwrapped, and put the context inside the error instead.
return &vgi.ArgumentError{ArgName: "count", Position: 0, Detail: "must be >= 0"}

If you need to add context, build the typed error with the fuller message rather than wrapping one.

The framework wraps bind, init, statistics and cardinality in RecoverPanic, so a panic in your code becomes a WorkerPanicError — carrying the function name, the phase, the recovered value and a stack — rather than killing the worker process.

Anywhere the framework doesn’t cover, do it yourself with the same helper:

func (f *MyFn) Process(ctx context.Context, params *vgi.ProcessParams,
  state *myState, out *vgirpc.OutputCollector,
) (err error) {                                    // named return, so the defer can set it
  defer vgi.RecoverPanic("process", f.Name(), &err)
  ...
}

The named return value is what makes this work — RecoverPanic writes through the pointer.

How an error reaches the user

A returned error crosses the wire as an RpcError and is re-raised by the extension, so what the user sees is Invalid Input Error: VGI Worker Exception: <your message> with the worker command appended. Write the message for that reader: name the argument, say what was expected, and don’t assume they know what a “worker” is.

Process on a scalar or table function takes a context.Context, and so do the copy handlers’ Read, Write and Close. OnBind, OnInit, NewState and the aggregate methods (Update, Combine, Finalize) do not — they receive only their params struct, and are expected to be quick.

The examples in these docs discard the context because they finish instantly. A function that talks to a network, scans a large source, or loops should not:

func (*EventsFn) Process(ctx context.Context, params *vgi.ProcessParams,
  state *eventsState, out *vgirpc.OutputCollector,
) error {
  for state.more() {
      if err := ctx.Err(); err != nil {
          return err          // the query was cancelled or timed out
      }
      ...
  }
  return nil
}

Honouring it is what makes a cancelled query actually stop rather than run to completion with nobody listening. Pass ctx down to any HTTP or database call you make.

Two channels, and they go to different places:

  • log/slog through the SDK’s loggers → the worker’s stderr. For operators.
  • ClientLog(level, msg) → back to the DuckDB client in-band, surfacing in duckdb_logs() with type='VGI'. For the person running the query.

Which receiver you call ClientLog on depends on the shape. A streaming function has it on the *vgirpc.OutputCollector it is already writing to:

out.ClientLog(vgirpc.LogWarn, "skipped 3 rows with a null key")

A buffering table function has no collector, so the framework wires the sink onto the params instead — params.ClientLog(...). Elsewhere the sink is nil and the call is a silent no-op, so don’t rely on it from OnBind or NewState; return an error or log to stderr there.

Use ClientLog for something the user can act on — a row skipped, a credential about to expire — and stderr for everything else. On the stdio transport stdout is the protocol, so never print there.

There is a third channel, and it is the one to reach for when the question is “why was that scan slow?” rather than “what went wrong?”. A table function that implements DynamicToStringHook gets its key/value pairs merged into the operator’s Extra Info:

func (*SeriesFn) DynamicToString(_ context.Context,
  params *vgi.DynamicToStringParams,
) (keys []string, values []string, err error) {
  return []string{"shard_size", "source"},
      []string{"3", "in-memory range"}, nil
}
EXPLAIN ANALYZE SELECT count(*) FROM calc.series(10);
│   Batch Bytes: 80 bytes   │
│       shard_size: 3       │
│                           │
│          source:          │
│      in-memory range      │

The hook is called once per scan thread and the last writer wins, so return an aggregate rather than one thread’s private view. params.Storage is the same execution-scoped store the scan wrote to — Storage.Snapshot() reads every worker’s contribution without draining it, which is how you report a total (rows fetched, bytes read, upstream calls) instead of a sample.