orion-server 1.0.0

Turn business logic into live REST/Kafka services. Declare workflows as JSON and Orion runs them, with rate limiting, circuit breakers, versioning, and observability built in
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
# Orion Configuration
#
# Every setting below is shown with its real default, taken from
# `src/config/*.rs`. Uncomment only what you want to change — a file with no
# uncommented settings behaves exactly like no file at all.
# `tests/integration/config_docs_drift_test.rs` fails the build if any default
# here stops matching the code, so trust these values.
#
# Two ways to override a value without editing this file:
#
#   1. Environment variables, `ORION_SECTION__KEY` (double underscore between
#      levels): ORION_SERVER__PORT=3000, ORION_STORAGE__URL=..., and so on.
#      Env vars win over this file. Every setting's variable is listed in
#      docs/src/configuration/reference.md.
#   2. Placeholders inside this file: `$${VAR}` (required — startup fails if
#      unset) or `$${VAR:-default}` (optional). `$$` escapes a literal `$`,
#      which is why the two names above are written with a doubled `$`:
#      substitution runs over the raw file text, comments included.
#      The same substitution runs on connector `config_json` blobs at startup,
#      so secrets stay out of the database:
#         [storage]
#         url = "${ORION_DB_URL:-sqlite:orion.db}"
#      A placeholder in *this* file may name any variable — Orion sees the
#      file and knows it reads it. A placeholder in a connector `config_json`
#      cannot be seen while the config loads, so if you point one at a name
#      shaped like a setting (`ORION_` with a `__` in it), use the reserved
#      `ORION_SECRET_*` namespace (`$${ORION_SECRET_DB_PASSWORD}`) — a name
#      that looks like a setting and is not one stops the boot.
#
# Convention in this file: a comment indented two or more spaces past the `#`
# is an illustrative example, not a default.
#
# START HERE if you are going to production: set `environment = "production"`
# below. It turns three footguns into startup errors — see the next section.

# Deployment environment. Any value starting with "prod" (case-insensitive)
# is a production environment, which makes three checks fatal instead of a
# warning: admin auth must be enabled, CORS may not be the "*" wildcard, and
# a cluster may not migrate at boot (cluster.enabled with
# storage.auto_migrate — every replica would race the others).
# Overridden by ORION_ENVIRONMENT.
# environment = "development"

[server]
# host = "0.0.0.0"
# port = 8080
# shutdown_drain_secs = 30           # Grace period for in-flight requests on SIGTERM/SIGINT
# shutdown_force_timeout_secs = 30   # Hard cap on waiting after the drain window; 0 = wait forever
# max_admin_body_size = 8388608      # Max request body for /api/v1/admin/* in bytes (8 MB).
                                     # Separate from ingest.max_payload_size, so raising the
                                     # limit for a bulk import does not also raise it for the
                                     # unauthenticated data plane.
# Return the real task-failure message on the data plane instead of the generic
# "Task processing failed; full detail is available in the trace". Unset (the
# default) means on when `environment` is NOT a production variant — the same
# rule as server.docs.enabled. An explicit true IS REFUSED in production: raw
# task errors can carry upstream URLs, connector names and driver detail, and
# the data plane is unauthenticated. Leave it unset.
# verbose_errors = true

# [server.tls]
# Terminate HTTPS in Orion itself. Leave disabled when a load balancer or
# service mesh already terminates TLS. Both paths are required when enabled and
# must exist at startup — Orion refuses to boot otherwise.
# enabled = false
# cert_path = ""                     # PEM certificate chain, e.g. "/etc/orion/tls/tls.crt"
# key_path = ""                      # PEM private key, e.g. "/etc/orion/tls/tls.key"

# [server.compression]
# Response compression (gzip). Off by default: the layer is
# unconditional once inserted and runs DEFLATE per response regardless of size,
# which costs CPU without saving bytes on small JSON bodies. Turn it on when
# responses are typically large.
# enabled = false

# [server.docs]
# Swagger UI (/docs) and the OpenAPI spec (/api/v1/openapi.json). Both are
# unauthenticated and the spec publishes the complete admin API surface, so
# when `enabled` is unset (the default) they are served only when
# `environment` is NOT a production variant. An explicit true/false always
# wins. Disabled means the routes are not registered at all (404, not 401).
# `orion-server dump-openapi` writes the spec offline regardless.
# enabled = true

[storage]
# url = "sqlite:orion.db"      # sqlite: / postgres:// / mysql:// — picks the backend at runtime
# max_connections = 50         # Pool ceiling. Size against the server's own limit:
                               # N replicas x max_connections must stay under
                               # Postgres `max_connections`, or replicas will fail to connect.
# min_connections = 5          # Connections kept warm (0 = none)
# busy_timeout_ms = 5000       # SQLite busy timeout; ignored by other backends
# acquire_timeout_secs = 3     # How long a request waits for a pooled connection before erroring
# idle_timeout_secs = 300      # Close connections idle this long (0 = never)
# connector_encryption_key = ""  # Encrypt connectors.config_json at rest (AES-256-GCM).
                                # Empty means plaintext. Set to `openssl rand -hex 32` output;
                                # prefer the env var form so the key does not sit beside the
                                # database it protects. Plaintext rows written before the key
                                # keep loading and re-encrypt on their next write.
# backup_dir = "./backups"     # Where POST /api/v1/admin/backups writes (SQLite only)
# backup_retention_count = 10  # Keep only the newest N backups, pruning older ones after
                               # each successful backup. Unset keeps every backup — they
                               # accumulate on the same disk as the live database.
# auto_migrate = true          # Run pending migrations at startup. Set false for multi-replica
                               # deployments and run `orion-server migrate` as a deploy step;
                               # startup then fails fast if migrations are still pending.
# connect_retry_secs = 60      # How long to keep retrying the initial database connection
                               # (0 = fail fast). A failover takes tens of seconds; without a
                               # window every replica exits and the restart backoff outlives
                               # the outage. Ignored for SQLite; the migration check above
                               # stays fail-fast either way.

[cluster]
# Multi-instance (HA) coordination. With enabled = false (the default) Orion is
# a plain single node: no epoch watcher, no shared backends, no job leases.
# When enabled, N replicas sharing one Postgres/MySQL and one Redis behave as
# one logical system — config changes made through any node propagate to all,
# dedup and response caches default to the shared Redis, and background jobs
# (trace cleanup, audit cleanup, DLQ retry) single-flight behind a lease.
# Requires postgres:// or mysql:// storage; SQLite is rejected at startup.
# Prefer auto_migrate = false above, so replicas don't race migrations at boot.
# enabled = false
# redis_url = ""                  # Required when enabled, e.g. "redis://redis:6379"
# epoch_poll_interval_ms = 2000   # How often each node polls for config changes made elsewhere
# instance_id = ""                # Auto-generated UUID when empty; max 64 chars because it
                                  # doubles as the Kafka group.instance.id

[ingest]
# max_payload_size = 1048576   # Max request body in bytes (1 MB) for the data plane.
                               # The admin API has its own: server.max_admin_body_size

[engine]
# health_check_timeout_secs = 2            # Timeout for the /readyz cluster-Redis ping
# max_channel_call_depth = 10              # Recursion limit for channel_call
# default_channel_call_timeout_ms = 30000  # Default channel_call timeout
# max_loop_iterations = 10000              # Ceiling on a workflow loop's `max`, refused at
                                           # write time. 0 removes the ceiling and leaves only
                                           # the author's own bound
# global_http_timeout_secs = 30            # Safety net for every outbound HTTP request;
                                           # shorter connector/task timeouts still win
# max_pool_cache_entries = 100             # Cached connection pools per external backend (LRU)
# cache_cleanup_interval_secs = 60         # Sweep interval for expired cache entries
# max_memory_cache_entries = 100000        # Per-namespace bound on the in-memory caches: the
                                           # default dedup store, the default response cache, and
                                           # every (purpose, connector) use of a
                                           # `backend = "memory"` connector each get their own
                                           # store with this bound, so worst-case resident
                                           # entries are this value times the namespace count
                                           # (2 built-in stores + up to 3 per memory connector).
                                           # LRU-evicted on insert. 0 disables the bound —
                                           # entries written without a TTL are then never
                                           # reclaimed.
# rollout_sticky_header = ""               # Header identifying the caller for sticky canary
                                           # bucketing, e.g. "x-user-id". Empty falls back to the
                                           # forwarded client IP; with neither, each request is
                                           # bucketed at random and a caller can flip versions
                                           # between requests.
# fail_on_connector_load_error = false     # Refuse to start when an enabled connector cannot be
                                           # loaded (missing env://VAR, unparseable config,
                                           # unresolvable secret). Default false skips it with a
                                           # log line, so every workflow using it fails at request
                                           # time instead — possibly hours later. Set true in
                                           # production so a bad rollout fails at boot. Startup
                                           # only; a hot reload never takes the process down.

# [engine.circuit_breaker]
# Sheds load to a failing dependency after `failure_threshold` consecutive
# failures, returning 503 CIRCUIT_OPEN until the cooldown lets a probe through.
# Currently applied to http_call only.
# enabled = false
# failure_threshold = 5
# recovery_timeout_secs = 30    # Cooldown before a half-open probe is admitted
# max_breakers = 10000          # Tracked breakers before LRU eviction

[trace_queue]                        # Async trace processing, retention and DLQ
# workers = 4                        # Concurrent async trace workers
# buffer_size = 1000                 # Channel buffer for pending async traces
# shutdown_timeout_secs = 30         # Wait for in-flight traces during shutdown
# retention_hours = 72               # Delete completed traces older than this (0 = keep forever)
# cleanup_interval_secs = 3600       # How often the trace cleanup job runs
# processing_timeout_ms = 60000      # Per-trace processing timeout on the async path
# max_result_size_bytes = 1048576    # Reject (sync) or fail (async) results larger than this
# max_queue_memory_bytes = 104857600 # Total queued payload bytes before new submissions get 503
# dlq_retry_enabled = true           # Retry failed async traces from the DLQ table
# dlq_max_retries = 5                # Attempts before a DLQ row is marked exhausted (>= 1)
# dlq_poll_interval_secs = 30        # How often the DLQ retry worker polls
# dlq_batch_size = 20                # Rows claimed per retry tick
# dlq_lease_secs = 60                # How long a claimed row stays leased to one node; expired
                                     # leases are re-claimable, which is how a crashed node's
                                     # work is recovered in cluster mode

[audit]                              # Admin audit-log retention
# retention_days = 90                # Delete admin audit-log rows older than this (0 = keep
                                     # forever). Every admin mutation writes one and nothing
                                     # else removes them, so 0 grows `audit_logs` without bound.
# cleanup_interval_secs = 3600       # How often the audit cleanup job runs
# max_pending = 1000                 # Audit rows accepted but not yet written. Admin mutations
                                     # never wait on the INSERT; past this bound submissions are
                                     # dropped and counted in orion_audit_events_dropped_total.
# drain_timeout_secs = 5             # How long shutdown waits for the audit queue to drain. A
                                     # database that stopped accepting writes must not hold the
                                     # process open, so the drain gives up and logs the loss.
                                     # Must be > 0: unlike other timeouts here, 0 does not mean
                                     # "no bound" — it would skip the drain entirely.

[query]                              # Page-size bounds for the portable `data_query` handler
# default_limit = 100                # Page size when a query omits `limit`
# max_limit = 1000                   # Hard cap; a larger request is rejected, never clamped
# max_skip = 10000                   # Hard cap on the `skip` offset, on every backend; a larger
                                     # offset is rejected, never clamped

[write]                              # Safety bounds for the portable `data_write` handler
# max_rows = 1000                    # Hard cap on rows per bulk insert/upsert
# allow_unfiltered = false           # Permit unfiltered update/delete (still needs "all": true
                                     # on the call itself)

[kafka]                              # Consumer + producer, compiled into every binary
# Messages are processed strictly sequentially per consumer — the
# at-least-once commit contract requires it (an offset commit covers every
# earlier offset). Scale throughput by running more instances in the same
# consumer group.
# enabled = false
# brokers = ["localhost:9092"]
# group_id = "orion"
# topics = []                        # Topic-to-channel mappings; see [[kafka.topics]] below
# processing_timeout_ms = 60000      # Per-message processing timeout
# lag_poll_interval_secs = 30        # Consumer lag metric poll interval (0 disables)
# session_timeout_ms = 45000         # Consumer group session timeout; always applied. In cluster
                                     # mode it pairs with static group membership so rolling
                                     # restarts rejoin without a full rebalance

# [[kafka.topics]]                   # Repeat this block per mapping. Channels with a Kafka
# topic = "incoming-orders"          # protocol also contribute topics from the database;
# channel = "orders"                 # the two sets are merged at startup.

# [kafka.dlq]
# Failed messages are published here instead of being dropped. Delivery is
# at-least-once: an offset only advances on success or a confirmed DLQ write,
# so with the DLQ disabled a failing message is retried in place rather than
# lost. Enable it to stop poison messages blocking a partition.
# enabled = false
# topic = "orion-dlq"

# [kafka.auth]
# Broker authentication and TLS, applied to every Kafka client Orion creates
# (ingest consumer, publish_kafka producer, DLQ producer). This is what makes
# Confluent Cloud, MSK, and Aiven reachable. Each field maps 1:1 to a
# librdkafka property; leave a field unset to keep librdkafka's default.
# GSSAPI and OAUTHBEARER are not available — librdkafka is built without
# libsasl2.
# security_protocol = "sasl_ssl"     # plaintext | ssl | sasl_plaintext | sasl_ssl
# sasl_mechanism = "SCRAM-SHA-256"   # PLAIN | SCRAM-SHA-256 | SCRAM-SHA-512
# sasl_username = "orion"
# sasl_password = "change-me"        # Prefer ORION_KAFKA__AUTH__SASL_PASSWORD or $${KAFKA_PASSWORD}
# ssl_ca_location = ""               # CA bundle for broker verification, e.g. "/etc/kafka/ca.pem";
                                     # unset uses the system trust store
#
# Worked example — Confluent Cloud (API key as username, secret as password):
#   [kafka]
#   enabled = true
#   brokers = ["pkc-abc12.us-east-1.aws.confluent.cloud:9092"]
#   group_id = "orion-prod"
#   [kafka.auth]
#   security_protocol = "sasl_ssl"
#   sasl_mechanism = "PLAIN"
#   sasl_username = "$${CONFLUENT_API_KEY}"
#   sasl_password = "$${CONFLUENT_API_SECRET}"
#
# AWS MSK with IAM is not supported; use MSK's SCRAM-SHA-512 credentials:
#   security_protocol = "sasl_ssl"
#   sasl_mechanism = "SCRAM-SHA-512"

# [kafka.extra_config]
# Raw librdkafka properties, applied after everything Orion sets, so entries
# here override even [kafka.auth]. Free-form maps do not fit the
# ORION_SECTION__KEY scheme, so there is no env-var equivalent — TOML only.
#   "client.id" = "orion-prod-1"
#   "socket.keepalive.enable" = "true"

[rate_limit]
# Platform-level limits. Per-channel limits are separate and live in the
# channel's config_json in the database.
# enabled = false
# default_rps = 100             # Requests per second per client identity
# default_burst = 50            # Burst allowance on top of the sustained rate
# trusted_proxies = []          # CIDR blocks or bare IPs of reverse proxies whose
                                # X-Forwarded-For / X-Real-IP header identifies the client.
                                # IMPORTANT: empty (the default) means forwarded headers are
                                # NEVER trusted and the direct peer IP is the identity — so
                                # behind a load balancer every request shares one bucket
                                # unless you list the balancer here, e.g. ["10.0.0.0/8"].
                                # Listing an untrusted network lets clients spoof the header
                                # and mint a fresh bucket per request.

# [rate_limit.endpoints]
# Per-route-group limits. Setting a group to null makes it fall back to default_rps.
# admin_rps defaults to 20: admin traffic is interactive and low-volume, and the
# admin plane holds every mutating operation, so it should not share the
# anonymous data plane's budget.
# admin_rps = 20                # Admin API
# data_rps = 200                # Data plane (unset -> default_rps)

[channel_filter]
# Which channels this instance loads from the database. Glob patterns matched
# against the channel name; exclude is applied after include. Use it to run
# separate fleets off one database — e.g. a public instance serving orders-*
# and an internal one serving everything else.
# include = []                  # Empty = load all, e.g. ["orders-*", "payments-*"]
# exclude = []                  # e.g. ["internal-*"]

[admin_auth]
# Authentication for /api/v1/admin/* and the trace-read endpoints. Disabled by
# default so a fresh install is usable; REQUIRED (startup fails without it)
# once environment starts with "prod".
# enabled = false
# api_keys = []                 # Any listed key authorises a request. Multiple entries exist
                                # for zero-downtime rotation: add the new key, roll clients,
                                # drop the old one. Each entry is either the plaintext key
                                # (e.g. "s3cret-key") or "sha256:<64-hex>" — the SHA-256 digest
                                # of the key — so the config file holds a hash, not a secret.
                                # Generate one with:  printf %s "$KEY" | shasum -a 256
# read_only_api_keys = []       # Keys limited to GET/HEAD on the admin plane; every mutating
                                # method answers 403. Same entry forms as api_keys. For
                                # dashboards, auditors and CI checks that should never hold a
                                # credential able to rewrite workflows.
# header = "Authorization"      # "Authorization" expects `Bearer <key>`; any other value
                                # (e.g. "X-API-Key") expects the raw key

[cors]
# allowed_origins = ["*"]       # Exactly ["*"] is permissive CORS. Rejected at startup when
                                # environment starts with "prod" — list explicit origins
                                # instead, e.g. ["https://app.example.com"]. Mixing "*" into
                                # a list of origins is always a config error.

[logging]
# level = "info"                # trace, debug, info, warn, error
# format = "pretty"             # pretty or json — use json wherever logs are collected

[metrics]
# enabled = false               # Collect metrics and serve them at GET /metrics. With this off the
                                # route is not registered at all, so /metrics 404s rather than
                                # answering 200 with a permanently empty body.
# bind_addr = "127.0.0.1:9090"  # Optional dedicated, UNAUTHENTICATED listener serving only
                                # GET /metrics. Unset (default) keeps the endpoint on the main
                                # listener, where admin_auth guards it — so every scraper has to
                                # hold an admin key that can also rewrite workflows. Point this at
                                # a private interface instead. Plain HTTP; server.tls covers the
                                # main listener only. Needs enabled = true — set on its own it
                                # raises no listener and startup warns.

[tracing]                       # OpenTelemetry export; gated at runtime by `enabled`
# enabled = false
# otlp_endpoint = "http://localhost:4317"  # OTLP gRPC endpoint (Jaeger, Tempo, OTel Collector)
# service_name = "orion"        # Service name reported in spans
# sample_rate = 1.0             # 0.0 (none) to 1.0 (all)
# debug_profile_enabled = false # Allow per-request profiling: with this on, a request carrying
                                # `X-Orion-Profile: 1` (or ?profile=1) gets an `_orion.profile`
                                # object breaking the request down by phase. Leave off in
                                # production so callers cannot probe internal timing.

[trace_storage]
# Persistence policy for Orion's own per-request traces (rows in the `traces`
# table, read via /api/v1/admin/traces). Unrelated to the OTLP export in
# [tracing] above — the two were one section before 1.0, which is exactly the
# confusion this split removes.
# A channel can override this with its `config.tracing` field; unset per-channel
# fields fall back to what is set here.
#
#   sync   — write inline before responding (default): a served request implies
#            a persisted trace, and throughput is capped by the DB's write rate
#   async  — enqueue to a bounded background queue, one DB write per task
#   batch  — bounded queue; workers commit `batch_size` rows per transaction
#   off    — no persistence on the synchronous endpoint, where the caller
#            already holds the answer. POST /{channel}/async still writes its
#            row and still returns a trace_id: submitting async *is* the
#            request for a result to fetch later.
#
# `async` and `batch` lift the throughput cap by letting the request path run
# ahead of the trace table — which means it can outrun it. What happens then is
# `async_on_overflow`, and the default ("drop") is silent data loss beyond a
# WARN log and a counter. Measured on SQLite at c=50: sync sustained ~5.7k req/s
# and kept every trace; batch sustained ~77k req/s and kept 34% of them. Pick a
# background mode when traces are telemetry you are willing to sample, not when
# they are the record of what happened.
# mode = "sync"
#
# Filters, composing with the mode and applied per trace:
# sample_rate = 1.0                 # Fraction of traces persisted, 0.0-1.0
# errors_only = false               # Persist only traces that ended with errors
#
# Backpressure (async and batch modes):
# max_pending = 10000               # Bounded queue capacity
# async_on_overflow = "drop"        # drop | block — what a full queue does to a new trace
# overflow_block_timeout_ms = 100   # How long "block" waits for capacity before dropping
#
# Async mode:
# async_workers = 4
#
# Batch mode:
# batch_size = 1000                 # Rows per transaction, and the dominant term in
#                                   # drain rate: 100 drains 26k rows/s, 1000 drains
#                                   # 45k rows/s. Capped at 1000 by SQLite's bind limit.
# batch_flush_interval_ms = 100     # Flush a partial batch after this long
# batch_workers = 4                 # Each worker owns an independent batch