Skip to main content
MoleSignal is a single binary: the same executable and image serves every role. Process configuration selects the active roles. The binary supports both a one-command sandbox and horizontal scaling into a multi-role cluster. This page targets operators and SREs. The guide covers role breakdown, background-worker ownership, data flow, cluster discovery, external dependencies and ports, and three deployment topologies: single-node, Docker Compose, and Kubernetes.

Overview: design philosophy

Single binary

Every role is compiled into the same binary and packaged into the same image. Processes are distinguished purely by configuration ([node].roles); no separate build artifacts are required.

Roles selected by config

[node].roles determines process exposure and foreground responsibilities. The default is ["standalone"], meaning one process runs every responsibility.

State externalized where possible

Metadata lands in Postgres, data lands in the object store. Aside from the Intake’s WAL/buffer, most roles are stateless and scale horizontally.

Discovery via Postgres

There is no separate gossip or consensus component. Each node writes a heartbeat into the cluster_nodes table, and peer discovery queries that table directly.
When to run single-node vs. split by role:
  • Evaluation, development, PoC, low-traffic production.
  • One process exposes HTTP + gRPC and runs every internal responsibility in-process (intake, query, compaction, alert evaluation, etc.).
  • Dependencies are still externalized: Postgres + object store (or a local filesystem backend).
Role enum values use snake_case in TOML / environment variables (standalone, alert_manager), matching the naming convention of the internal implementation. All config examples below use snake_case.

Node role reference

[node].roles is an array (Vec<Role>), with valid values: standalone, router, intake, querier, compactor, alert_manager. The default is ["standalone"].
Multiple roles compose in one process. A form like [node].roles = ["intake", "querier"] starts the deduplicated set of foreground servers required by each role, including the gRPC server serving intake and scan. The configuration gates matching background loops and registers the node under all configured roles in cluster_nodes, allowing peer discovery by role. standalone is shorthand for “every role in one process”.
A standalone querier or any role set containing querier or intake starts the gRPC server, which carries the Arrow Flight scan service. The coordinator discovers queriers via list_role(querier) and fans shards out over that RPC. A single-process standalone still works as before; distributed scan-out requires at least two querier peers.

Which role carries which background worker

Role-specific loops (intake flush, compaction + file_meta_dumper, alert evaluation/dispatch) are spawned only when the owning role is configured (standalone counts as all roles). A handful of always-on workers (heartbeat, sweeper, object-store probe, MMDB refresh, search-jobs, scheduled-reports) run on every node. The table below gives the owning role and interval.
ACME issuance and renewal are implemented: the issue loop scans pending domains, and the renewal loop renews active certificates within the 30-day window. Both loops use the single-issuance path with a per-domain cooldown. The TLS-terminating HTTP server starts the runner when [http.tls].enabled = true. TLS and ACME are compiled into every build without a feature flag and remain runtime-gated by [http.tls].enabled.

How to select roles

Configure roles through [node].roles; environment variables can override the setting. Environment variables share the MS_ prefix, with section and field separated by a . (dot). MoleSignal strips the MS_ prefix and splits the remaining key on .. Examples: MS_NODE.ROLES, MS_STORE.META.DSN, MS_HTTP.PORT.
A few bootstrap / secret variables are flat single-underscore and live outside Settings, read directly from the environment: MS_CIPHER_KEY, MS_AUTH_JWT_SECRET_OVERRIDE, MS_LICENSE_FILE, MS_SELF_TELEMETRY_CLUSTER_TOKEN, and the development-only MS_AGENT_<PROVIDER>_* fallback. All other structured fields use the MS_<SECTION>.<FIELD> dotted form (for example MS_NODE.ROLES, MS_STORE.META.DSN, and MS_CLUSTER.ADVERTISE_ADDR).

Data flow

Intake path

The entry point lands on an Intake through the Router with rate limiting and consistent hashing. The Intake first writes the WAL durably to disk, then writes the in-memory buffer. A background flush loop encodes the buffer into columnar files and a search index by time window or size threshold, uploads each file to the object store, stores FileMeta in Postgres, and truncates flushed WAL segments.
Intake path

Intake path

Key config: [wal].dir, [wal].segment_size_mb, [wal].flush_strategy (batch/none/every_write), [wal].sync_level (data/all); [intake].buffer_max_mb (default 256), flush_interval_secs (default 30), flush_parallelism (default 4); [router.rate_limit].intake_qps (default 1000 per org, 0 = unlimited).
The Router rate limits at (org_id, route_class) granularity, returning 429 with a Retry-After header when exceeded. org_id comes from the X-Org-Id request header and defaults to default.

Query path

The query entry point is on a node that exposes HTTP. The engine is wrapped layer by layer according to cluster size: local query engine → (with ≥2 querier peers) distributed engine → (when a remote cluster is specified) federated engine.
Query path

Query path

Distributed query shards are fanned out to peers through a hash of object_key; shard SQL only performs a scan (SELECT * FROM <stream>), while full aggregation runs on the coordinator to avoid partial/final aggregation inconsistencies. The distributed path runs only when the cluster has at least two querier peers; otherwise the query falls back to the local engine with no network hop.

Async search job pipeline

Async search job pipeline

Async search job pipeline

Config: [search_jobs].workers (default 2), idle_poll_secs (default 2), cleanup_interval_secs (default 3600); the automatic async threshold [querier].auto_async_threshold_rows (default 50 million rows).
The FOR UPDATE SKIP LOCKED claim semantics are safe across multiple workers / multiple nodes: multiple processes carrying search_jobs can share the same search_jobs table and claim concurrently without executing the same job twice.

Federated / multi-cluster query

Target clusters are specified via ?clusters=local,sf,nyc. After scanning locally, the coordinator issues one internal scan RPC (with a Bearer token) to each enabled remote cluster, then UNION ALLs the batches each cluster returns with the local data and executes the full SQL.
Federated / multi-cluster query

Federated / multi-cluster query

Federated query is license-gated: as long as clusters contains a non-local target and the current license lacks the federated_search feature, the HTTP layer returns 403 directly. OpenSource Edition remains single-cluster. Remote cluster definitions are stored in the Postgres remote_clusters table (advertise_addr, token_secret_ref, tls_verify, enabled); clusters with enabled=false are skipped during fan-out. Remote auth currently supports Bearer token only; tls_verify=false maps to http:// (rather than “https with verification skipped”).

Cluster membership and discovery

1

Node registration

The heartbeat task periodically upserts (node_id, roles, advertise_addr, last_heartbeat_at_micros) into the Postgres cluster_nodes table (primary key node_id, ON CONFLICT DO UPDATE). The full node role set is stored comma-joined, so a multi-role node occupies one row discoverable under every configured role. The first heartbeat fires immediately, followed by heartbeats at the configured interval.
2

Heartbeat interval

[cluster].heartbeat_interval_secs, default 5s. advertise_addr defaults to 127.0.0.1:5082, i.e. the gRPC address (host:port) peers use to interconnect.
3

Liveness window and stale cleanup

The liveness window is controlled by [cluster].peer_timeout_secs, default 15s: the registry only returns nodes with last_heartbeat_at >= now - peer_timeout. Additionally, a sweeper deletes cluster_nodes rows that have not heartbeated for over 5 minutes every 60s.
4

Peer discovery

There is no gossip or consensus. In distributed mode, each role queries the cluster_nodes table directly and filters live peers by role membership. A node matches when the role set contains the requested role. standalone mode skips the entire discovery flow and returns only the local node.
5

Placement algorithm

Router selecting an Intake: applies consistent hashing over org_id|stream_name then mods, landing deterministically on a particular Intake. Router selecting a Querier: naive round-robin (now_ns % peer_count), not full consistent hashing. Distributed query sharding: a hash over object_key then mods, fanning out to querier peers.
6

Distributed scan RPC

The coordinator encodes a scan request (org/stream/sql/file_metas/time_range) into a ticket and sends the ticket to peers over the internal scan RPC. Each peer reads columnar files, registers an in-memory table, runs the shard SQL, and returns a result stream. This trusted RPC shares port 5082 with node services and the private cluster intake protocol. Standard external OTLP gRPC uses 4317.
Local scan-RPC calls within the cluster are unauthenticated; only federated / remote-cluster calls use an optional Bearer token.
cluster_nodes table schema: node_id VARCHAR(64) PK, role VARCHAR(128) (the comma-joined role set), advertise_addr VARCHAR(255), started_at_micros BIGINT, last_heartbeat_at_micros BIGINT. list_role scans alive rows and matches membership in code, so role lookups don’t depend on the column being indexed.

External dependencies and ports

PostgreSQL

The metadata database: FileMeta, streams, rules, incidents, users, orgs, audit, quotas, certificates, cluster_nodes, search_jobs, remote_clusters, and more. [store.meta]: backend (default sqlite; set to postgres for production), dsn, max_connections (default 16). Migrations are embedded at compile time.

Object store

Columnar files + search-index sidecars. [store.object].backend: local (default, root=./data/objects) / s3 (including MinIO, R2, Alibaba Cloud OSS, overridden via endpoint) / azure / gcs. Credential precedence: env vars > credential file > inline TOML.

No external cache / consensus

Only in-process LRU+TTL caches, the rate limiter, and the async runtime. No Redis / Memcached, no external consensus component.

Rendering dependency (optional)

PNG/PDF rendering for scheduled reports requires headless Chromium, an enabled renderer, and a reachable Web base_url. If rendering is unavailable, the request returns an explicit error rather than a placeholder file.

Listening ports

/metrics (GET, Prometheus text 0.0.4) is always mounted. Restrict the endpoint at the reverse proxy or network boundary. The endpoint exposes fixed-cardinality cache, object-store, WAL, query, alerting, and self-observability metrics.

Health / readiness probe semantics

1

Object store probe at startup (blocking)

startup_ping() synchronously performs a PUT→GET→DELETE (128 bytes) against _health/{uuid}.probe. On failure, the process does not start.
2

Background periodic probe

The same round-trip runs every [store.object].health_probe_interval_secs (default 30s); only 3 consecutive failures set object_store_degraded=true, and a success resets the counter to zero.
3

Intake WAL replay

At startup the Intake scans segment files under [wal].dir, replays all records into the in-memory buffer by (org, stream_type, stream), forces a single flush after loading, then sets replay_done=true. /api/v1/readyz returns 503 until replay completes.

Authentication and config overrides

  • The JWT secret is auto-bootstrapped by the database on first startup; the old jwt_secret TOML field is deprecated (parsing is retained only for backward compatibility with old configs). To pin a fixed secret, use the environment variable MS_AUTH_JWT_SECRET_OVERRIDE.
  • API tokens take the form ms_<prefix>_<secret>, with the secret stored as an argon2id hash.
  • At startup, fields such as store.meta.dsn, wal.dir, http.port, grpc.port, and node.id are treated as immutable; runtime changes trigger a warning.

Deployment topologies

All topologies share the same image (e.g. molesignal:dev), distinguished by MS_NODE.ROLES. The web frontend is a separate nginx image (e.g. molesignal-web:dev).
A single process with all roles combined; the fastest way to get started.
The local backend (store.object.backend=local) is fine for development; use an object store backend for production.

Scaling and high availability

Router — scales horizontally

Stateless; scale replicas with entry traffic. The rate limiter is in-process and ephemeral: under multiple replicas each replica counts independently, so the effective org QPS ceiling is roughly configured value × replica count. When needed, move rate limiting up to a unified gateway or lower the per-replica threshold.

Querier — scales horizontally

Stateless. A querier process serves the scan RPC over gRPC; distributed scans only trigger with ≥2 querier peers, otherwise the coordinator falls back to the in-process engine with no network hop. The bottleneck is columnar-file reads + query-engine memory.

Intake — stateful

Use a StatefulSet + a per-replica PVC for a persistent WAL. The Router places via consistent hashing over org|stream; scaling changes the mod result and causes rebalancing, so ensure the WAL is flushed before scaling down. Leave enough of a replay window for the readiness probe (the manifest sets a 10s initial delay).

Compactor / AlertManager — single instance

Both are recommended at a single replica: multiple Compactor instances would produce merge conflicts (a lease-table lock is planned for the future), and AlertManager’s evaluation / incident state is in-process. Reliability comes from fast restarts rather than multiple replicas.
Recommended metrics to monitor: for the write path, watch wal_append_lock_wait_seconds, wal_append_inflight, wal_fsync_errors_total, file_meta_dump_*; for the object store, object_store_operations_total, object_store_errors_total, object_store_op_duration_seconds, object_store_probe_*; for queries, the cache hit rate cache_* and tantivy_pruned_files_total; for alerts, alert_rule_eval_timeout_total. Combine /api/v1/healthz, /api/v1/readyz, and the cluster_nodes table to observe member liveness.

Minimal production checklist / validation checklist

1

External dependencies ready

Postgres reachable, [store.meta].backend = "postgres" with a correct dsn; choose an object store backend of s3/azure/gcs (not local), with credentials injected in the precedence order “env vars > credential file > inline”.
2

Roles and interconnect addresses

Each process has an explicit MS_NODE.ROLES; set MS_CLUSTER.ADVERTISE_ADDR to a peer-reachable host:5082 (K8s uses $(POD_IP):5082). A process may carry several roles at once, such as ["intake","querier"]. The process starts the deduplicated server set and registers under every configured role.
3

Intake persistence

Run the Intake as a StatefulSet + PVC mounting the WAL directory; set [wal].flush_strategy / sync_level according to durability requirements; leave enough replay delay for the readiness probe.
4

Single-instance roles

Keep Compactor and AlertManager at 1 replica each. Confirm [compactor].interval_secs, retention_days, and [storage.file_meta_dump].enabled match expectations.
5

Entry point and rate limiting

Put an LB in front of the Router; set [router.rate_limit].intake_qps / query_qps per org; note that rate limiting is approximate under multiple replicas. Disable buffering for /api/v1/query/stream on the Ingress.
6

Observability and probes

Wire /metrics into Prometheus; point K8s probes at /api/v1/healthz (liveness) and /api/v1/readyz (readiness). Confirm the object store startup probe passes (otherwise the process will not start).
7

Security and license

Set MS_AUTH_JWT_SECRET_OVERRIDE when a fixed JWT secret is required; otherwise the database bootstraps the secret automatically. Inject a cipher key and avoid the development all-zero value in production. Federated query requires the federated_search license feature. TLS and automatic certificate issuance or renewal are available in every build without a feature flag and activate at runtime through [http.tls].enabled.
Before production use, note the current boundaries: the distributed-consensus WAL term source is still static because multi-node consensus is not implemented; federated query and OIDC/SAML SSO require the corresponding license features; and changing [node].roles requires a process restart.
Last modified on August 9, 2026