JSON Schema Validation for Druid Ingestion Specs

An assembled ingestion spec that is missing tuningConfig, carries a segmentGranularity typo, or pairs forceGuaranteedRollup with a dynamic partitionsSpec will be rejected by the Overlord — but only after it has consumed an API round-trip, occupied a task slot, and produced a failure report that a human then has to read. A JSON Schema check moves that rejection left, into CI, where it costs a millisecond of local validation and returns a precise path to the offending field. This guide builds that gate: a jsonschema contract that an assembled spec must satisfy before any call to POST /druid/indexer/v1/task. It is the structural half of schema validation for Druid specs — the machine-checkable shape of the spec, ahead of the Druid-specific semantic rules that layer on top.

JSON Schema validation as a hard gate before the Overlord An assembled ingestion spec enters a validator that checks it against a JSON Schema contract. A valid spec is POSTed to the Overlord. An invalid spec is rejected in CI before any Overlord call is made, with the failing JSON path reported back to the author. Assembled spec JSON in memory jsonschema gate Draft 2020-12 contract hard gate POST to Overlord /indexer/v1/task Rejected in CI failing path reported valid invalid
The schema is a hard gate: an invalid spec never reaches the Overlord, and the author gets the failing JSON path.

Failure Modes & Diagnostics

The failures a JSON Schema catches are structural — the ones that make a spec malformed regardless of cluster state. The three that dominate are a missing required key, a wrong-typed value, and an unknown key introduced by a typo. Before writing the schema, inspect the assembled spec's shape so the contract you write matches reality:

# Confirm the required top-level and spec-level keys are present
jq '{top: keys, spec_keys: (.spec | keys),
     schema_keys: (.spec.dataSchema | keys)}' spec.json

# Surface a segmentGranularity typo (must be one of the Druid period names)
jq '.spec.dataSchema.granularitySpec.segmentGranularity' spec.json

# Check the rollup/partitioning pairing the schema will also enforce
jq '{forceGR: .spec.tuningConfig.forceGuaranteedRollup,
     partType: .spec.tuningConfig.partitionsSpec.type}' spec.json

A missing spec.tuningConfig, a segmentGranularity of "HOURLY" instead of "HOUR", or a metricsSpec that is an object rather than an array are all structural defects a schema rejects instantly. What a JSON Schema deliberately does not catch are semantic contradictions that are individually well-formed — a dropExisting: true without bounded intervals, or an intervals window that overlaps a live supervisor. Those belong to the semantic layer in the parent guide and, for the overlap case, to preventing batch/streaming segment overlap. Keep the two layers distinct: the schema owns shape, the semantic checks own intent. Handling a genuinely new source column so it does not silently become the wrong dimension is the domain of handling schema evolution in Druid ingestion.

Target Spec & Validated JSON

The contract below is a JSON Schema (Draft 2020-12) that pins the structural shape of an index_parallel spec: required keys at every level, enumerated segmentGranularity values, metricsSpec as an array, and a partitionsSpec discriminated by type. It is intentionally strict — additionalProperties: false on the objects it fully owns so a typo'd key fails loudly rather than being silently ignored by Druid.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "Druid index_parallel ingestion spec",
  "type": "object",
  "required": ["type", "spec"],
  "properties": {
    "type": { "const": "index_parallel" },
    "spec": {
      "type": "object",
      "required": ["dataSchema", "ioConfig", "tuningConfig"],
      "properties": {
        "dataSchema": {
          "type": "object",
          "required": ["dataSource", "timestampSpec", "granularitySpec"],
          "properties": {
            "dataSource": { "type": "string", "minLength": 1 },
            "timestampSpec": {
              "type": "object",
              "required": ["column"],
              "properties": {
                "column": { "type": "string", "minLength": 1 },
                "format": { "type": "string" }
              }
            },
            "dimensionsSpec": { "type": "object" },
            "metricsSpec": { "type": "array" },
            "granularitySpec": {
              "type": "object",
              "required": ["segmentGranularity"],
              "properties": {
                "type": { "type": "string" },
                "segmentGranularity": {
                  "enum": ["MINUTE", "FIVE_MINUTE", "HOUR", "DAY",
                           "WEEK", "MONTH", "YEAR"]
                },
                "queryGranularity": { "type": "string" },
                "rollup": { "type": "boolean" },
                "intervals": {
                  "type": "array",
                  "items": { "type": "string" }
                }
              }
            }
          }
        },
        "ioConfig": {
          "type": "object",
          "required": ["type", "inputSource", "inputFormat"],
          "properties": {
            "type": { "const": "index_parallel" },
            "dropExisting": { "type": "boolean" },
            "appendToExisting": { "type": "boolean" }
          }
        },
        "tuningConfig": {
          "type": "object",
          "required": ["type", "partitionsSpec"],
          "properties": {
            "type": { "const": "index_parallel" },
            "forceGuaranteedRollup": { "type": "boolean" },
            "maxRowsInMemory": { "type": "integer", "minimum": 1 },
            "partitionsSpec": {
              "type": "object",
              "required": ["type"],
              "properties": {
                "type": { "enum": ["dynamic", "hashed", "range"] },
                "targetRowsPerSegment": { "type": "integer", "minimum": 1 },
                "maxRowsPerSegment": { "type": "integer", "minimum": 1 }
              }
            }
          }
        }
      }
    }
  }
}

The schema encodes exactly the structural guarantees the Overlord depends on: the presence of the three spec children, a non-empty dataSource, a segmentGranularity drawn from Druid's period vocabulary, and a partitionsSpec.type from the three legal partitioning strategies. Field semantics beyond shape are maintained in the official Apache Druid ingestion spec reference; the schema's job is only to guarantee the JSON is well-formed against that reference before the Druid cluster ever sees it.

Python Automation Script

The gate below validates an assembled spec against the schema using the jsonschema library, and only on success submits it to the Overlord with capped exponential backoff. jsonschema is a widely-used open-source validator; it is the one external dependency here beyond requests. Validation failures raise ValidationError carrying a JSON path (json_path) that points straight at the offending field, which the gate surfaces and then exits non-zero without ever calling the Druid cluster.

import sys
import time
import json
import requests
from jsonschema import Draft202012Validator

OVERLORD = "http://overlord:8090"


def load_validator(schema_path: str) -> Draft202012Validator:
    with open(schema_path) as fh:
        schema = json.load(fh)
    Draft202012Validator.check_schema(schema)  # fail fast on a broken schema
    return Draft202012Validator(schema)


def validate_spec(validator: Draft202012Validator, spec: dict) -> list[str]:
    """Return a sorted list of human-readable errors; empty means valid."""
    errors = sorted(validator.iter_errors(spec), key=lambda e: e.json_path)
    return [f"{e.json_path}: {e.message}" for e in errors]


def submit_with_backoff(spec: dict, retries: int = 5) -> str:
    """POST the validated spec to the Overlord, retrying transient failures."""
    delay = 2.0
    for attempt in range(retries):
        try:
            r = requests.post(f"{OVERLORD}/druid/indexer/v1/task",
                              json=spec, timeout=30)
            r.raise_for_status()
            return r.json()["task"]
        except requests.RequestException:
            if attempt == retries - 1:
                raise
            time.sleep(delay)
            delay = min(delay * 2, 30.0)  # capped backoff
    raise RuntimeError("unreachable")


def main() -> int:
    schema_path, spec_path = sys.argv[1], sys.argv[2]
    validator = load_validator(schema_path)
    with open(spec_path) as fh:
        spec = json.load(fh)

    errors = validate_spec(validator, spec)
    if errors:
        print("SPEC REJECTED (not submitted):", file=sys.stderr)
        for err in errors:
            print(f"  {err}", file=sys.stderr)
        return 1

    task_id = submit_with_backoff(spec)
    print(f"SPEC VALID: submitted {task_id}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

Two choices make this gate reliable. check_schema runs first so a mistake in the contract itself fails immediately rather than silently passing every spec. And iter_errors collects all violations in one pass — sorted by json_path — rather than stopping at the first, so an author fixes an entire spec in one iteration instead of one field per CI run. Submission is reached only when the error list is empty, guaranteeing no malformed spec consumes an Overlord slot.

Verification Steps

Confirm the gate rejects a malformed spec and accepts a valid one. First, feed it a spec with tuningConfig removed and check for a non-zero exit with a precise path:

jq 'del(.spec.tuningConfig)' spec.json > broken.json
python3 spec_gate.py druid-index-schema.json broken.json
echo "exit=$?"
# SPEC REJECTED (not submitted):
#   $.spec: 'tuningConfig' is a required property
# exit=1

Then confirm a well-formed spec validates and is submitted:

python3 spec_gate.py druid-index-schema.json spec.json
echo "exit=$?"
# SPEC VALID: submitted index_parallel_events_web_2025-09-22T...
# exit=0

As a cross-check, confirm the schema itself is valid before trusting any result it produces:

python3 -c "import json; from jsonschema import Draft202012Validator; \
Draft202012Validator.check_schema(json.load(open('druid-index-schema.json'))); \
print('schema OK')"
# schema OK

Wire the gate as a required CI step ahead of every ingestion submit, keep the schema in version control beside the specs it guards, and treat a schema change as a reviewed change like any other. Pair it with the semantic checks in the parent guide so structure and intent are both enforced before the Overlord is called.

Up one level: Schema validation for Druid specs frames how structural JSON Schema checks and semantic rules compose into one pre-flight gate.

Back to Automated Ingestion Pipeline Orchestration