Skip to content

Releases: Query-farm/vgi-rpc-python

v0.17.1

18 May 14:35

Choose a tag to compare

Sticky streaming conformance coverage

Patch release on top of v0.17.0. The canonical TestSticky conformance group previously only exercised sticky sessions on unary calls — this release extends it with four tests that drive the same _StickyCounter through producer and exchange streams, so cross-language ports implementing sticky must now prove the contract holds across the multi-request shape of streaming RPCs (not just one-shot unary calls).

What's covered

  • TestSticky::test_producer_stream_resumes_session — a producer stream that increments and emits ctx.session.value on every iteration; verifies the session is rebound across the multi-turn producer shape.
  • TestSticky::test_exchange_stream_resumes_session — an exchange stream that mutates the session counter from the input by column on each round-trip; verifies state accumulates across independent HTTP exchange requests.
  • TestSticky::test_stream_without_session_raises — streaming method invoked outside with_session_token() surfaces RpcError with no session bound.
  • TestSticky::test_session_shared_between_unary_and_stream — open via unary, mutate via unary + producer stream + unary, close — all observe the same backing _StickyCounter.

ConformanceService surface additions

Cross-language ports implementing sticky must add these two methods to pass the streaming TestSticky tests:

  • stream_session_counter(count: int) -> Stream[StreamState] — producer stream emitting count increments of the sticky session counter.
  • exchange_session_counter() -> Stream[StreamState] — exchange stream that adds each input by column to the sticky session counter.

Describe method count bumps 79 → 81; runner _EXPECTED_METHODS and _STREAM_METHODS gain the two new names so the describe-conformance suite stays in sync.

Compatibility

  • No behaviour change for existing callers. Non-conformance code paths are byte-identical to 0.17.0.
  • Ports without sticky support skip the entire TestSticky group, including the new streaming tests — no port-side action required if sticky isn't implemented.
  • _StickyCounter moved from vgi_rpc.conformance._impl to vgi_rpc.conformance._types (a private helper; the relocation is mentioned only because in-tree tests imported it directly).

v0.17.0

18 May 14:17

Choose a tag to compare

Features

HTTP sticky sessions (opt-in)

A new opt-in feature for the HTTP transport lets a worker process keep live Python objects — open DuckDB cursors, loaded model handles, streaming LLM clients mid-generation, open file handles — bound to the worker that opened them, keyed by a short-lived AEAD-sealed session token the client echoes on subsequent requests. Non-sticky wire path is byte-identical to 0.16.1; existing callers see no behaviour change. The full cross-language wire spec lives at docs/sticky-sessions-spec.md.

Runtime API

Methods on the server, called from inside an RPC method body:

def open_query(self, sql: str, ctx) -> str:
    cursor = duckdb.connect().execute(sql)
    ctx.open_session(cursor)       # framework mints the token, attaches to response
    return "ok"

def next_rows(self, n: int, ctx) -> bytes:
    return ctx.session.fetch_arrow_table(n).serialize().to_pybytes()

def close_query(self, ctx) -> None:
    ctx.close_session()            # invokes cursor.close(), evicts the registry entry

Eviction is TTL-driven (default 300s, override per-call via ttl=) or explicit. state.close() is invoked on TTL eviction, explicit close, and graceful drain. The framework serializes concurrent calls on the same session via a per-session RLock; different sessions run in parallel. Sticky machinery is HTTP-only — ctx.open_session raises RuntimeError on pipe / subprocess / unix transports.

Client API

from vgi_rpc.http import http_connect

with http_connect(MyService, "http://localhost:8080") as conn, conn.with_session_token() as sess:
    sess.open_query(sql="SELECT * FROM big")
    rows = sess.next_rows(n=1000)
    sess.close_query()

The with conn.with_session_token(): block is the client-side opt-in. Inside the block every request carries VGI-Session-Accept: true and (after open) VGI-Session: <token>. On block exit the view fires a best-effort DELETE /vgi/__session__ to release handle-bearing state promptly. Stash a token across processes via sess.detach() + later conn.with_session_token(token=stashed).

Client-driven routing (Fly.io)

Server can tell the client to replay arbitrary headers on every subsequent request in the session — emitted as VGI-Echo-<name>: <value> on the session-opening response. Used for client-driven routing on platforms where the client knows where to go:

from vgi_rpc.http import make_wsgi_app
from vgi_rpc.http.fly import auto_server_id, fly_sticky_echo_headers

app = make_wsgi_app(
    server,
    enable_sticky=True,
    sticky_echo_headers=fly_sticky_echo_headers(),  # → {"fly-force-instance-id": <id>} on Fly, None elsewhere
)

On Fly the client replays fly-force-instance-id: <machine-id> on every subsequent request and fly-proxy routes directly to the owning Machine. Zero LB config required.

Graceful drain

from vgi_rpc.http import drain_handle, serve_http

# Built-in: serve_http installs SIGTERM/SIGINT handlers automatically
serve_http(server, enable_sticky=True, drain_grace_seconds=30.0)

# Pre-fork (gunicorn): wire your own
# def worker_exit(server, worker):
#     if (h := drain_handle(worker.app.callable)) is not None:
#         h.drain(); time.sleep(30); h.shutdown()

While drained, new ctx.open_session calls raise ServerDrainingError; existing-session calls continue to serve. Double-SIGTERM during grace skips the wait and exits immediately.

Typed errors

  • vgi_rpc.rpc.SessionLostError (error_kind="session_lost") — token decode failure, AAD mismatch, server_id mismatch (wrong worker), registry miss, or TTL expiry.
  • vgi_rpc.rpc.ServerDrainingError (error_kind="server_draining") — ctx.open_session while the server is draining. Existing sessions keep working.

Both follow the vgi_rpc.error_kind metadata convention introduced in 0.16, so client pattern matching against the kind string is wire-stable across language ports.

Access log additions

Records on sticky-touching requests carry two new fields:

  • session_id: 24-char hex (the framework-minted session ID), stable across the lifecycle for a given session.
  • session_action: enum "none" / "open" / "resume" / "close".

Both absent on non-sticky servers. Schema updated in vgi_rpc/access_log.schema.json; spec section at docs/access-log-spec.md §4.7.

Cross-language conformance

New TestSticky group in the canonical conformance suite (vgi_rpc/conformance/_pytest_suite.py) — 11 wire-protocol tests, capability-gated on VGI-Sticky-Enabled: true so ports without sticky support skip the entire group cleanly. Three new ConformanceService methods (open_counter, increment_counter, close_counter) exercise the open / resume / close lifecycle; one further test (test_echo_header_round_trip) is gated on VGI-Sticky-Echo-Headers; another (test_drain_rejects_new_opens) is gated on the conformance server exposing POST/DELETE /__test_drain__. Run against any port: vgi-rpc-test --url http://<server> --filter "Sticky::*".

Porting guide section at docs/porting-guide.md — describes the full contract any port must satisfy to claim sticky support.

Bug fixes

CI Lint unblocked

Two pre-existing CI Lint failures fixed in the same release cycle: pyarrow's stubs declare nulls() overloads only for concrete DataType subtypes, not the abstract base — silenced with a type-ignore on the one call site (vgi_rpc/utils.py:_empty_array). The ty type-checker also flagged drain_handle(app) because the parameter was typed object and isinstance-narrowed to App[Never, Never] — fixed by typing the parameter as falcon.App[falcon.Request, falcon.Response] directly.

Out of scope (deferred follow-ups)

  • Cookie emission for AWS ALB / CloudFront application-cookie stickiness. Header-only multiplexes cleanly across concurrent sessions where cookies cannot. Can be added as an additive operator flag if real demand emerges.
  • Middleware-short-circuit access-log records. Token validation failures (lost / expired / wrong-server) currently bypass the dispatch path where the access log emitter lives. Operators monitoring for misroutes can rely on the typed SessionLostError on the wire surface.
  • Pluggable session store. Sessions hold live Python objects in-process. Redis-style external stores are explicitly excluded — they don't work for the cursor / handle pattern and would compete with the well-defined "TTL eviction + crash = state lost" contract.

v0.16.1

13 May 17:16

Choose a tag to compare

Bug fixes

Linux idle-shutdown hang in serve_unix

_serve_unix_threaded armed an idle timer that closed the listening socket from another thread to interrupt the main accept() loop. On Linux this does not actually wake a blocked accept()close() only decrements the fd refcount, leaving the syscall waiting. macOS happens to wake it, which is why this only manifested on Linux: launcher-spawned workers stayed alive past their idle window and never unlinked their socket file.

The accept loop now runs with a 0.5s socket timeout and an explicit shutdown_requested flag the timer sets instead of closing the listener. Accepted connections are reset to blocking so per-call reads behave unchanged. No public API change; affects only the AF_UNIX (run_server --unix / vgi_rpc.launcher.launch) code path.

Windows mypy errors in launcher tests

tests/test_launcher.py references socket.AF_UNIX and os.geteuid, neither of which appears in Windows type stubs. The tests were already runtime-skipped on Windows but still got parsed by mypy. Added sys.platform != "win32" guards so mypy narrows the POSIX-only attribute access on Windows. Tests remain skipped on Windows.

v0.16.0

13 May 16:08

Choose a tag to compare

Breaking: stream-state tokens now use XChaCha20-Poly1305 AEAD

HTTP stream-state tokens (the opaque continuation tokens carried in the vgi_rpc.stream_state#b64 Arrow custom-metadata field) are now sealed with XChaCha20-Poly1305 authenticated encryption instead of HMAC-SHA256-signed plaintext. The serialized StreamState is no longer readable by anything between client and server — proxies, browser history, log captures, and tcpdump output all see opaque ciphertext.

Wire format v4

b64( version=4(1B) || nonce(24B) || ciphertext+tag )

The plaintext payload (including the creation timestamp used for TTL enforcement) lives entirely inside the ciphertext. TTL is checked after decryption.

Principal binding moves to AAD

Previously, the master HMAC key was HKDF-style stretched per (domain, principal) so that a token minted for user A could not be replayed by user B. The same property now comes from binding (domain, principal) into AEAD associated data — cross-principal replay fails decryption atomically with no per-user key derivation, simplifying key rotation.

Breaking changes

  • The public parameter signing_key has been renamed to token_key on make_wsgi_app, make_sync_client, and in examples / docs. Token bytes are no longer signed — they are sealed.
  • The token wire format is incompatible with v0.15.x. Clients and servers must upgrade together.
  • The VGI_SIGNING_KEY environment variable (referenced in docs/hosting.md) is renamed to VGI_TOKEN_KEY.

New dependency

pynacl >= 1.5 is now a required dependency of the vgi-rpc[http] extra.

Access-log records now capture decrypted state

The request_state and response_state fields in access-log records are now both the decrypted Arrow-IPC StreamState payload, regardless of wire envelope. Previously request_state happened to coincide with the on-wire token (readable under HMAC); under AEAD that would have been opaque ciphertext with no audit value. The two fields are now semantically symmetric. See docs/access-log-spec.md for the updated contract.

HTTP streaming compression middleware now uses ~50% less peak memory

The compression middleware previously held three copies of each response body simultaneously during zstd compression — the response stream's internal BytesIO, a body = stream.read() bytes copy, and the compressed output. For an N-thread WSGI server emitting M-byte Arrow IPC bodies, that was ~3·N·M of avoidable peak; under typical Kafka-consume tuning (64 MiB per tick × 16 Waitress threads) it added up to ~3 GiB on a 2 GiB Fly VM, OOMing the worker.

The new path drives zstandard.ZstdCompressor.stream_writer from 64 KiB reads off resp.stream, eliminating the body bytes copy. Seekable streams (which is what the framework's response builders always produce) get size=N passed through so the zstd frame header still carries content_size — preserving wire-format compatibility with existing clients. Non-seekable streams fall back to the buffer-then-compress path.

Per-thread peak during compression drops from stream_buf + body + compressed (~2× body) to stream_buf + compressed (~1× body + ~5% for zstd output).

v0.15.0

01 May 01:20

Choose a tag to compare

Workers can now learn which transport they are bound to

Workers (RPC service implementations) had no way to know which transport (pipe / http / unix) they were being served over. CallContext.transport_metadata only carried HTTP fields and was ambiguous for non-HTTP transports — an empty dict could mean "pipe" or "HTTP request without those headers." There was no startup hook either.

TransportKind and the on_serve_start hook

A new TransportKind enum (PIPE, HTTP, UNIX) is exposed via three knobs:

  • RpcServer.transport_kind — coarse identifier of the bound transport, populated once serving begins.
  • RpcServer.transport_capabilitiesfrozenset[str] of capability flags, currently {"shm"} when bound to a ShmPipeTransport.
  • CallContext.kind — per-call view of the same TransportKind for methods that already accept ctx.

Implementations may opt into a ServeStartHook lifecycle method:

from vgi_rpc import CallContext, TransportKind


class MyServiceImpl:
    def on_serve_start(self, kind: TransportKind) -> None:
        if kind is TransportKind.HTTP:
            self._cache = build_http_cache()

    def fetch(self, key: str, ctx: CallContext) -> str:
        if ctx.kind is TransportKind.HTTP and self._cache is not None:
            return self._cache.get(key)
        return load_from_disk(key)

The hook is duck-typed (no base class needed); a ServeStartHook Protocol is exported for type-hinting.

Fork-safe HTTP firing

For pipe / unix transports the hook fires inside RpcServer.serve(transport). For HTTP it fires lazily on the first request handled in the current process, via a tiny one-shot Falcon middleware. Pre-fork WSGI servers (gunicorn, uwsgi) therefore run startup work in each child worker, not the master — per-process resources (DB pools, threads, file handles) are no longer fork-unsafely inherited. Subprocess workers report PIPE because they speak Arrow IPC over the parent's stdin/stdout.

Failure semantics

Hook exceptions propagate (and are logged via logging.getLogger("vgi_rpc.rpc").exception first) — a misconfigured worker dies loudly rather than serving in a broken state. Rebinding the same RpcServer to a different transport re-fires the hook with the new kind rather than raising, so test fixtures that exercise multiple transports against one server are supported.

SHM as a capability, not an enum value

Shared-memory availability is exposed via transport_capabilities, not the enum, so coarse transport-kind checks stay simple while workers that need zero-copy paths can still detect SHM:

def on_serve_start(self, kind: TransportKind) -> None:
    if "shm" in self._server.transport_capabilities:
        self._enable_zero_copy()

v0.14.0

30 Apr 19:11

Choose a tag to compare

Sentry: richer user data and protocol-level tagging

Two improvements to the auto-attached Sentry dispatch hook. Server-only — no client cooperation required.

Standards-aligned set_user

Previously, the JWT sub claim was placed in user.username, leaving Sentry showing opaque IdP identifiers (4FySzCeE4zIYuvph49iD9tcJL0_zDWfpMqarUUPc1uA) where a human handle should be. Now auth.principal populates user.id (Sentry's canonical opaque identifier) and the decoded JWT claims feed user.username, user.email, user.name from standard OIDC claim names:

Sentry user field JWT claim
id auth.principal (typically sub)
username preferred_username
email email
name name

SentryConfig gains a user_claim_map so non-standard IdPs (e.g. Auth0 namespaced claims like https://example.com/email) can override per-key. Static bearer tokens (no JWT, empty claims) populate only user.id — the same effective behavior as before but in the correct field.

Generic vgi.attach_id / vgi.transaction_id scope tags

Every dispatch now auto-tags vgi.attach_id and vgi.transaction_id on the Sentry scope when present. _extract_well_known walks one level into kwargs to handle the three protocol shapes:

  • direct kwargs — catalog_detach(attach_id=...)
  • request dataclasses — bind(request=BindRequest(attach_id=...))
  • InitRequest.bind_call nesting — init(request=InitRequest(bind_call=BindRequest(attach_id=...)))

No per-method wiring required. Tag values are 12-char SHA-256 prefixes via the new public short_hash() helper — bytes and their .hex() form produce the same value, so Sentry's tag-value distribution UI stays bounded while same-input → same-output preserves cross-event correlation. Full hex remains in catalog breadcrumbs for direct lookup.

Public API additions

  • vgi_rpc.sentry.short_hash(value, *, length=12) — stable 12-char SHA-256 prefix, accepts bytes | str | None. Reusable for any high-cardinality opaque ID you want to tag in Sentry without exhausting the tag-value distribution UI.
  • SentryConfig.user_claim_mapMapping[str, str] of Sentry user-field name → JWT claim name. Defaults to OIDC standard names.

Internal

  • _SentryDispatchHook.on_dispatch_start walks request dataclasses for attach_id/transaction_id after the existing tag/context logic. Hook signature unchanged.

v0.13.0

30 Apr 16:50

Choose a tag to compare

Sentry: useful traces, not just errors

Phase A of making Sentry traces actually useful for finding slow RPC calls. Three changes ship together — all server-only, no client cooperation required.

Transaction names

By default, vgi-rpc now overrides Sentry's WSGI-derived /{method} transaction name (a literal route template — the actual method name was getting lost) with rpc {method}. Transactions group by RPC method in the Performance dashboard. Disable with SentryConfig(set_transaction_name=False) if you have alerts pinned to the route names.

Searchable span attributes

Every dispatch attaches rpc.system, rpc.service, rpc.method, and rpc.method_type as span data on the active transaction's root span — searchable in Trace Explorer / Insights. Streams also carry rpc.stream_id (one uuid shared across all /init and /exchange HTTP turns of one logical call) on span data so you can find sibling turns in the UI.

Opt-in RPC parameter recording

SentryConfig(record_params=True) exposes kwargs as rpc.param.<k> span attributes — the executeScan(table='orders', predicate=...) use case:

span.op:rpc.server rpc.method:executeScan rpc.param.table:orders
-> chart p99(span.duration) GROUP BY rpc.param.predicate

Default off because kwargs may carry user data and Sentry's default scrubber matches key names only. New SentryConfig knobs:

Field Default Purpose
set_transaction_name True Override WSGI route-template names with rpc {method}
record_params False Record kwargs as rpc.param.<k> span attributes
tag_params () Operator-curated whitelist of params duplicated as scope tags for Issues filtering (low cardinality)
param_redactor key-based default Sanitiser; default strips password|token|secret|key|authorization keys. noop_redactor opts out
max_param_value_bytes 1024 Truncate string values; matches Relay's per-attribute cap

Span attributes accept primitives only (str/bool/int/float and homogeneous lists thereof) — non-primitive kwargs are silently dropped to avoid Sentry ingestion errors. Free-text PII is the operator's responsibility; the default scrubber won't catch values like predicate=\"email = 'alice@x.com'\".

See docs/api/sentry.md for the full caveat list.

Internal contract change

_DispatchHook.on_dispatch_start Protocol gains a kwargs: Mapping[str, Any] parameter so observability backends can attach RPC parameters to traces. OTel hook accepts-and-ignores it for now (param recording on OTel is a separate follow-up). The hook is _-prefixed and internal — no public-API impact.

Other

  • fix(utils): empty_batch now supports schemas with sparse and dense union fields. pyarrow's pa.array([], type=union) raises ArrowNotImplementedError, so empty union arrays are now built via Array.from_buffers from empty children plus an empty type-codes buffer (and an empty offsets buffer for dense unions).
  • docs(sentry): corrected misleading wording about SENTRY_DSN. The Sentry SDK does not auto-initialise on import; sentry_sdk.init() must be called explicitly. It will fall back to reading SENTRY_DSN from the environment only if you don't pass an explicit dsn=.

v0.12.0

28 Apr 21:46

Choose a tag to compare

Sentry: zero-config auto-attach

If sentry_sdk is initialised in the worker process (via sentry_sdk.init(), or by setting SENTRY_DSN so the SDK auto-inits), every RpcServer now wires default-config Sentry instrumentation automatically. No flag, no extra env var — sentry_sdk.is_initialized() is the signal of intent.

import sentry_sdk
sentry_sdk.init(dsn="https://...")        # or just set SENTRY_DSN

from vgi_rpc import RpcServer
server = RpcServer(MyService, MyServiceImpl())   # Sentry already wired

The check is gated on sentry_sdk already being importable in the process, so workers without vgi-rpc[sentry] pay nothing.

Customising

instrument_server_sentry() (and make_wsgi_app(sentry_config=...) / serve_http(sentry_config=...)) now use replace semantics: an explicit configured call strips any existing _SentryDispatchHook from the chain (preserving non-Sentry hooks like OTel) before installing the new one. So explicit config always wins, regardless of timing relative to auto-attach.

from vgi_rpc.sentry import SentryConfig, instrument_server_sentry

instrument_server_sentry(server, SentryConfig(custom_tags={"env": "prod"}, enable_performance=True))

See docs/api/sentry.md for the full configuration reference.

v0.11.1

28 Apr 20:55

Choose a tag to compare

Performance

Cut subprocess worker startup time by ~55% (312ms → 140ms) and the full test suite by ~29% (132s → 94s).

  • Lazy-load optional integrations (vgi_rpc.http, vgi_rpc.sentry, vgi_rpc.otel, vgi_rpc.s3, vgi_rpc.gcs) via module-level __getattr__ instead of importing them eagerly from vgi_rpc/__init__.py. Cuts import vgi_rpc cost roughly in half (~250ms → ~115ms) for callers that don't need those extras — including every pipe/subprocess worker.
  • Test fixture service (Protocol, Impl, state classes) extracted from tests.test_rpc into tests/_fixture_service.py so subprocess fixtures don't drag pytest and conftest into worker import paths.

No public API changes. from vgi_rpc import http_connect, vgi_rpc.SentryConfig, from vgi_rpc import * all behave identically.

v0.11.0

28 Apr 17:51

Choose a tag to compare

Highlights

  • HTTP response caps reworked: separate max_response_bytes (HTTP body) and max_externalized_response_bytes (external upload volume) knobs; worker-visible budgets on OutputCollector; strict-fail enforcement; pre-flight externalization check. Deprecated max_stream_response_bytes retained for one cycle.
  • Slim __describe__ v4 + protocol_hash: language-neutral 8-column wire format; SHA-256 protocol hash surfaced in describe metadata and access-log records.
  • Access-log spec: cross-language conformance (access_log.schema.json), record truncation, size/time rotation, {pid}/{server_id} path placeholders, Vector + Fluent Bit shipper configs.
  • OAuth PKCE token-exchange proxy for SPA clients.
  • HTTP dispatcher symmetry: extracted _run_http_exchange_turn / _mint_continuation_token; refactored producer-stream dispatch.
  • Windows CI is green.
  • Sequential Unix-socket listen backlog bumped to 16.
  • OutputCollector.emit select-then-cast so projection works correctly.

See git log v0.10.0..v0.11.0 for the full diff.