Skip to main content
Back to Blog
Reference
2026-08-28

Test Results Warehouse Schema Design for CI History at Scale

Test results warehouse schema guide for storing CI history at scale, querying flakes, tracking duration, and powering reliable QA decisions.

Test Results Warehouse Schema Design for CI History at Scale

A test results warehouse schema is the set of tables, keys, partitions, and derived views that store CI test history so QA teams can query failures, flakes, duration, ownership, and release risk over time. The useful design separates immutable run facts from changing metadata, stores every attempt, and keeps enough context to explain why a test result happened without turning the warehouse into a log dump.

For QA and test-automation engineers, the payoff is practical: faster triage, smarter test selection, stable dashboards, and defensible release decisions.

Model The CI World As Events And Dimensions

CI history looks simple until retries, matrix jobs, renamed tests, split shards, quarantines, and branch policies enter the room. A schema that stores only "test name, status, duration" will fail the first time a flaky test passes on retry or a renamed package makes history disappear.

Start with a small vocabulary:

ConceptMeaningMutability
Pipeline runOne CI execution for a commit, PR, branch, or scheduled jobImmutable after completion, except late metadata
Job runOne job inside the pipeline, such as unit tests on Node 22Immutable after completion
Test caseA logical test identity independent of one executionSlowly changing
Test attemptOne execution of one test case in one job attemptImmutable
ArtifactFile or URL produced by a run, such as JUnit XML, trace, video, or coverageUsually immutable
OwnershipTeam, service, package, or component responsible for a testSlowly changing

That split keeps event facts append-only. Append-only facts are easier to backfill, audit, and recompute. Dimensions can change without rewriting billions of result rows.

The core relationship is:

ParentChildCardinalityNotes
pipeline_runsjob_runs1 to manyMatrix jobs and shards live here
job_runstest_attempts1 to manyEvery retry attempt is preserved
test_casestest_attempts1 to manyTest identity joins history
job_runsartifacts1 to manyRaw reports and debug files
test_casesownership_snapshots1 to manyOwnership changes over time

The word "attempt" is important. If CI retries a failed test and the final check is green, you still need the failed attempt. Otherwise your warehouse cannot answer the questions QA leaders actually ask: which tests are flaky, how much time retries cost, and which failures are hidden by rerun policy?

The Minimum Useful Schema

Below is a relational starting point. It works in Postgres, and the ideas carry to BigQuery, Snowflake, DuckDB, ClickHouse, or another warehouse. Types and partition syntax vary by engine, but the contracts are the same.

create table pipeline_runs (
  pipeline_run_id text primary key,
  provider text not null,
  repository text not null,
  workflow_name text not null,
  run_number bigint not null,
  commit_sha text not null,
  branch_name text not null,
  pull_request_number bigint,
  trigger_name text not null,
  started_at timestamptz not null,
  finished_at timestamptz,
  conclusion text,
  actor text,
  created_at timestamptz not null default now()
);

create index pipeline_runs_repo_started_idx
  on pipeline_runs (repository, started_at desc);

create index pipeline_runs_commit_idx
  on pipeline_runs (repository, commit_sha);

The pipeline table should not store every test count as hand-maintained columns at first. Derived counts belong in views or summary tables until you know the query patterns. Premature summary columns drift when ingestion is retried or a parser bug is fixed.

create table job_runs (
  job_run_id text primary key,
  pipeline_run_id text not null references pipeline_runs (pipeline_run_id),
  provider_job_id text not null,
  job_name text not null,
  shard_name text,
  runner_os text,
  runtime_name text,
  runtime_version text,
  attempt_number integer not null default 1,
  started_at timestamptz not null,
  finished_at timestamptz,
  conclusion text,
  queue_duration_ms bigint,
  execution_duration_ms bigint,
  unique (pipeline_run_id, provider_job_id, attempt_number)
);

create index job_runs_pipeline_idx
  on job_runs (pipeline_run_id);

Job-level queue and execution duration are worth storing directly because they often come from the CI provider, not from test reports. They answer a different question than test duration. A job can be slow because it waited for a runner, spent time installing dependencies, or ran slow tests. Do not mix those into one duration if you want useful operations data.

create table test_cases (
  test_case_id text primary key,
  repository text not null,
  suite_name text not null,
  file_path text not null,
  test_name text not null,
  test_name_hash text not null,
  framework text not null,
  first_seen_at timestamptz not null,
  last_seen_at timestamptz not null,
  is_active boolean not null default true,
  unique (repository, suite_name, file_path, test_name_hash)
);

create index test_cases_path_idx
  on test_cases (repository, file_path);

Use a stable hash for long test names. Store the full name too. The hash makes indexes and joins smaller, while the full name makes debugging human. If your test framework exposes a stable test id, store it, but do not assume every framework will. Many teams combine file path, suite path, and full test name.

create table test_attempts (
  test_attempt_id text primary key,
  job_run_id text not null references job_runs (job_run_id),
  test_case_id text not null references test_cases (test_case_id),
  attempt_number integer not null,
  status text not null,
  duration_ms bigint not null,
  error_message text,
  error_type text,
  failure_fingerprint text,
  stdout_bytes bigint,
  stderr_bytes bigint,
  started_at timestamptz,
  finished_at timestamptz,
  created_at timestamptz not null default now(),
  unique (job_run_id, test_case_id, attempt_number)
);

create index test_attempts_case_created_idx
  on test_attempts (test_case_id, created_at desc);

create index test_attempts_job_idx
  on test_attempts (job_run_id);

create index test_attempts_status_idx
  on test_attempts (status, created_at desc);

Statuses should be controlled. Pick a small set such as passed, failed, skipped, timed_out, interrupted, quarantined, and unknown. Keep provider-specific raw values in a separate field if needed. Dashboards become painful when one runner emits "failure", another emits "failed", and a parser adds "FAIL."

Preserve Retries Without Lying About Final Status

Retries are where many warehouse designs break. CI systems often expose final job conclusion but not the full story at test level. Test frameworks may emit multiple XML files for retries. Browser tests may retry inside the same worker and produce one final result plus retry metadata. Your ingestion needs a clear rule.

Store each attempt as a separate row when the data exists. Then derive final status from attempts.

Attempt statuses for a test in one jobFinal statusFlake signal
passedpassedno
failed, passedpassed_on_retryyes
failed, failedfailedno, or persistent failure
timed_out, passedpassed_on_retryyes
skippedskippedno
interruptedinterruptedunknown

This query derives final status without losing attempts:

with ordered_attempts as (
  select
    job_run_id,
    test_case_id,
    status,
    attempt_number,
    row_number() over (
      partition by job_run_id, test_case_id
      order by attempt_number desc
    ) as reverse_rank,
    bool_or(status in ('failed', 'timed_out')) over (
      partition by job_run_id, test_case_id
    ) as had_failure
  from test_attempts
)
select
  job_run_id,
  test_case_id,
  case
    when status = 'passed' and had_failure then 'passed_on_retry'
    else status
  end as final_status
from ordered_attempts
where reverse_rank = 1;

That derived final status lets a release gate use the final result while a flake dashboard uses the hidden failure. The two views should disagree sometimes. That is a feature, not a contradiction.

Ingestion Contract For JUnit And Playwright

Most teams start by ingesting JUnit XML because almost every test runner can produce it. That is fine, but JUnit is a loose family, not one exact format. Different tools put duration, classname, file, stdout, and retry metadata in different places. Write an ingestion contract that says what your parser accepts and what it does when a field is absent.

Input fieldWarehouse targetMissing-field behavior
suite nametest_cases.suite_nameUse framework or job name
classnametest_cases.suite_name or file hintPreserve raw value in parser metadata
filetest_cases.file_pathFall back to classname, mark source quality
nametest_cases.test_nameRequired
timetest_attempts.duration_msDefault to 0 only if framework omits it
failure elementstatus and error fieldsCapture short message and fingerprint
skipped elementstatusMark skipped

Here is a TypeScript normalizer that turns parsed report cases into warehouse rows. It avoids framework-specific parser code so the contract stays readable.

import { createHash } from "node:crypto";

type RawCase = {
  repository: string;
  suiteName: string;
  filePath: string | null;
  testName: string;
  framework: string;
  durationSeconds: number | null;
  status: "passed" | "failed" | "skipped" | "timed_out";
  errorMessage?: string;
};

type NormalizedCase = {
  testCaseId: string;
  repository: string;
  suiteName: string;
  filePath: string;
  testName: string;
  testNameHash: string;
  framework: string;
  durationMs: number;
  status: RawCase["status"];
  failureFingerprint: string | null;
};

function sha256(value: string): string {
  return createHash("sha256").update(value).digest("hex");
}

export function normalizeCase(raw: RawCase): NormalizedCase {
  const filePath = raw.filePath ?? "unknown";
  const identity = [raw.repository, raw.suiteName, filePath, raw.testName].join("|");
  const testNameHash = sha256(raw.testName).slice(0, 16);

  return {
    testCaseId: sha256(identity),
    repository: raw.repository,
    suiteName: raw.suiteName,
    filePath,
    testName: raw.testName,
    testNameHash,
    framework: raw.framework,
    durationMs: Math.max(0, Math.round((raw.durationSeconds ?? 0) * 1000)),
    status: raw.status,
    failureFingerprint: raw.errorMessage ? sha256(raw.errorMessage).slice(0, 20) : null
  };
}

For Playwright, keep trace and video artifacts out of the hot fact table. Store pointers. The warehouse should know an artifact exists and where authorized systems can fetch it. It should not become object storage.

create table artifacts (
  artifact_id text primary key,
  pipeline_run_id text not null references pipeline_runs (pipeline_run_id),
  job_run_id text references job_runs (job_run_id),
  test_case_id text references test_cases (test_case_id),
  artifact_type text not null,
  storage_url text not null,
  byte_size bigint,
  sha256 text,
  created_at timestamptz not null default now()
);

create index artifacts_test_case_idx
  on artifacts (test_case_id, created_at desc);

Object storage URLs can expire. If that is true in your system, store a stable object key rather than a signed URL. Tests should verify artifact rows never contain secrets in query strings.

Partition Around Time, Cluster Around Questions

At scale, every schema decision becomes a query bill or a slow dashboard. Partition large fact tables by time. Cluster or index by the fields people filter on: repository, test_case_id, job_run_id, status, created_at, and branch.

The exact DDL depends on the warehouse. The design question is stable: which query should be cheap?

QueryNeeded access pathSchema support
Failures on this PRpipeline_run_id -> job_runs -> attemptsForeign keys or clustered ids
Flake rate for one test over 90 daystest_case_id plus timeIndex or cluster on test_case_id, created_at
Slowest tests on main last weekrepository, branch, time, durationTime partition plus duration sort in summary
Failures by ownertest_case_id -> ownership snapshotOwnership dimension
Retry cost by workflowworkflow, time, attempt countJob and attempt summaries

A common pattern is raw facts plus daily aggregates. Raw facts answer audits and debug questions. Aggregates feed dashboards.

create materialized view daily_test_case_summary as
select
  date_trunc('day', ta.created_at) as day,
  pr.repository,
  tc.test_case_id,
  tc.file_path,
  count(*) as attempt_count,
  count(*) filter (where ta.status = 'passed') as passed_attempts,
  count(*) filter (where ta.status = 'failed') as failed_attempts,
  count(*) filter (where ta.status = 'timed_out') as timed_out_attempts,
  percentile_cont(0.95) within group (order by ta.duration_ms) as p95_duration_ms,
  avg(ta.duration_ms) as average_duration_ms
from test_attempts ta
join job_runs jr on jr.job_run_id = ta.job_run_id
join pipeline_runs pr on pr.pipeline_run_id = jr.pipeline_run_id
join test_cases tc on tc.test_case_id = ta.test_case_id
group by 1, 2, 3, 4;

Some warehouses do not support materialized views with percentile functions exactly like this. In that case, compute summaries in a scheduled job. The shape still holds: one row per day, repository, and test case.

Ownership Is A Slowly Changing Dimension

Ownership is not a string column on test_attempts. Teams reorganize. Files move. Services split. A failure from six months ago should still show the owner at the time, or at least let you reconstruct it. Store ownership snapshots with effective dates.

create table ownership_snapshots (
  ownership_snapshot_id text primary key,
  repository text not null,
  path_prefix text not null,
  owning_team text not null,
  service_name text,
  effective_from timestamptz not null,
  effective_to timestamptz,
  source text not null
);

create index ownership_snapshots_lookup_idx
  on ownership_snapshots (repository, path_prefix, effective_from);

Path-prefix ownership is not perfect, but it is often good enough to start. If your monorepo already has service metadata, join to that. If ownership is in code owners, ingest it as a versioned source. Do not overwrite history in place unless you are comfortable changing past reports.

Queries QA Teams Actually Run

Schema quality shows up in the questions it can answer without a week of spreadsheet work. The first set is triage:

select
  tc.file_path,
  tc.test_name,
  ta.status,
  ta.error_type,
  ta.failure_fingerprint,
  ta.duration_ms
from pipeline_runs pr
join job_runs jr on jr.pipeline_run_id = pr.pipeline_run_id
join test_attempts ta on ta.job_run_id = jr.job_run_id
join test_cases tc on tc.test_case_id = ta.test_case_id
where pr.repository = 'checkout-service'
  and pr.pull_request_number = 4821
  and ta.status in ('failed', 'timed_out')
order by ta.duration_ms desc;

The second set is flake detection. One useful flake proxy is "failed at least once and passed at least once on the same branch in a time window." It is not perfect, but it finds expensive tests quickly.

select
  tc.file_path,
  tc.test_name,
  count(*) filter (where ta.status = 'failed') as failures,
  count(*) filter (where ta.status = 'passed') as passes,
  count(*) as attempts
from test_attempts ta
join test_cases tc on tc.test_case_id = ta.test_case_id
join job_runs jr on jr.job_run_id = ta.job_run_id
join pipeline_runs pr on pr.pipeline_run_id = jr.pipeline_run_id
where pr.repository = 'checkout-service'
  and pr.branch_name = 'main'
  and ta.created_at >= now() - interval '30 days'
group by tc.file_path, tc.test_name
having count(*) filter (where ta.status = 'failed') > 0
   and count(*) filter (where ta.status = 'passed') > 0
order by failures desc, attempts desc
limit 50;

The third set is duration optimization. If the goal is faster CI, query total cost, not only individual duration. A 5 second test that runs 30,000 times may cost more than a 90 second test that runs twice.

select
  tc.file_path,
  tc.test_name,
  count(*) as attempts,
  percentile_cont(0.95) within group (order by ta.duration_ms) as p95_ms,
  sum(ta.duration_ms) as total_ms
from test_attempts ta
join test_cases tc on tc.test_case_id = ta.test_case_id
join job_runs jr on jr.job_run_id = ta.job_run_id
join pipeline_runs pr on pr.pipeline_run_id = jr.pipeline_run_id
where pr.repository = 'checkout-service'
  and pr.branch_name = 'main'
  and ta.created_at >= now() - interval '14 days'
group by tc.file_path, tc.test_name
order by total_ms desc
limit 25;

Those queries become inputs to CI test impact caching strategy. Test selection needs trustworthy history, especially around which files changed, which tests usually fail, and which suites dominate runtime.

Failure Story: The Flake Rate Was Half Real

A team built a flake dashboard and found one browser test with a 38 percent failure rate. The symptom was loud: leadership wanted the test deleted. The wrong theory was that the checkout UI was unstable. Engineers spent two days inspecting frontend changes and could not reproduce the failure locally.

The actual cause was schema identity. The parser used only the test title as the test_case_id. Two different files both had a test named "submits form with valid data." One was a checkout test. The other was an admin billing test that hit a seeded account limit in CI. Their attempts merged into one history row. The dashboard accused the wrong test.

The fix was boring and decisive. The team changed identity to repository, suite, file path, and test title hash. They backfilled 90 days of XML. The checkout test dropped to near zero failures, and the billing test showed a real seed-data problem. The lesson: a test results warehouse schema is a product surface. If identity is wrong, every downstream decision can be wrong with nice charts on top.

What People Get Wrong About Scale

People hear "at scale" and jump to storage volume. Volume matters, but the harder problems are identity, cardinality, and late-arriving facts. A million test attempts per day is manageable in modern warehouses. A million attempts with unstable names, huge error blobs in hot rows, and no retry model becomes hard because every query needs custom cleanup.

Do not put raw stdout, screenshots, traces, or full stack traces in the main attempts table. Store short fields for search and grouping, then link to artifacts. Do not store branch names as unbounded labels in metrics systems unless you understand cardinality cost. In the warehouse, branch names are fine as query dimensions. In monitoring metrics, they can become expensive quickly.

Also avoid rewriting history to make dashboards look clean. If a parser bug created bad rows, write a correction job or versioned backfill. Keep ingestion run metadata so you can explain when data changed.

create table ingestion_runs (
  ingestion_run_id text primary key,
  source_name text not null,
  source_uri text not null,
  parser_name text not null,
  parser_version text not null,
  started_at timestamptz not null,
  finished_at timestamptz,
  status text not null,
  rows_inserted bigint not null default 0,
  rows_rejected bigint not null default 0,
  error_message text
);

Parser version is not vanity. When a dashboard jumps, you need to know whether the product changed or the parser changed.

CI Provider Metadata And Scheduler Metrics

Test history often needs scheduler context: queue duration, runner pool, job state, retries, cancellations, and capacity. Without that context, QA may blame test code for delays caused by runner scarcity. Link job_runs to scheduler metrics rather than stuffing every scheduler sample into the test schema. The companion model for job scheduler metrics data model monitoring is the right place for p95 queue latency, run states, and worker capacity.

In the test warehouse, keep enough fields to join:

FieldTablePurpose
providerpipeline_runsDistinguish GitHub Actions, Buildkite, Jenkins, or internal CI
provider_job_idjob_runsJoin back to CI provider APIs or scheduler tables
runner_osjob_runsSegment platform-specific failures
runtime_versionjob_runsFind failures tied to Node, Java, Python, or browser versions
queue_duration_msjob_runsSeparate runner waiting from execution
execution_duration_msjob_runsTrack job runtime independent of queue

This separation lets a dashboard say, "tests are not slower, the runner queue is slower." That sentence saves engineering hours.

Data Quality Tests For The Warehouse

Warehouse tests should run like application tests. They protect trust in the metrics. Start with constraints that catch impossible data:

select test_attempt_id
from test_attempts
where duration_ms < 0
   or status not in ('passed', 'failed', 'skipped', 'timed_out', 'interrupted', 'quarantined', 'unknown');

Then check referential completeness:

select ta.test_attempt_id
from test_attempts ta
left join job_runs jr on jr.job_run_id = ta.job_run_id
left join test_cases tc on tc.test_case_id = ta.test_case_id
where jr.job_run_id is null
   or tc.test_case_id is null;

And check suspicious identity collisions:

select
  repository,
  test_name,
  count(distinct file_path) as file_count
from test_cases
group by repository, test_name
having count(distinct file_path) > 10
order by file_count desc;

That last query does not prove a bug, but it surfaces names too generic to trust alone. It is a good smoke test for identity design.

Rollout Plan For A Growing Warehouse

Do not start with a perfect enterprise data model. Start with immutable facts, stable identity, and a few queries that remove pain. Then expand.

PhaseBuildExit criteria
1Ingest pipeline_runs, job_runs, test_cases, test_attemptsPR failure query works for current CI
2Preserve retries and artifactsFlake dashboard distinguishes final pass from hidden fail
3Add ownership snapshotsFailures route to teams without manual spreadsheets
4Add daily summariesDashboards load quickly over 90 days
5Join scheduler and impact dataCI optimization decisions use cost and confidence

Keep raw report artifacts for a retention window even after parsing. Parser bugs are normal. Backfill is much easier when you still have source reports.

Schema Evolution Without Breaking History

Test warehouses live longer than test frameworks. You will rename suites, migrate from one browser runner to another, change retry policy, add sharding, and adjust failure categories. Plan for that movement. The safe pattern is to add fields, backfill them, publish a compatibility view, then move dashboards. Avoid changing the meaning of an existing column in place.

ChangeSafer migrationRisky migration
Add browser nameAdd nullable browser_name, backfill from artifactsParse browser out of job_name forever
Rename statusAdd normalized_status viewRewrite raw status and lose provider value
Change identityCreate new test_case_id version and mappingOverwrite ids with no bridge
Add quarantineAdd status plus quarantine dimensionDelete quarantined attempts
Split suitePreserve old suite in historical rowsForce old rows into new suite names

Identity migrations need special care. If you discover that your test_case_id was too weak, do not pretend history was always correct. Create a mapping table that records old id, new id, migration reason, and effective time. Then dashboards can either show corrected history or clearly mark the boundary.

create table test_case_identity_migrations (
  migration_id text primary key,
  old_test_case_id text not null,
  new_test_case_id text not null,
  repository text not null,
  reason text not null,
  effective_at timestamptz not null,
  created_by text not null,
  created_at timestamptz not null default now()
);

Backfills should be repeatable. Store parser version, source artifact pointer, row counts, and rejection counts. Run a backfill first into a shadow table or a new partition, compare aggregate counts, then promote it. QA should insist on before-and-after checks: total attempts by day, failures by status, top files by count, and null rates for required dimensions.

select
  date_trunc('day', created_at) as day,
  status,
  count(*) as attempts
from test_attempts
where created_at >= timestamp '2026-08-01'
group by date_trunc('day', created_at), status
order by day, status;

That query is basic, but it catches ugly backfill mistakes: doubled rows, missing skipped tests, or a parser that turns timeouts into ordinary failures.

Privacy And Retention Boundaries

CI logs often contain tokens, emails, customer ids, internal hostnames, and stack traces with sensitive paths. A test results warehouse should not ingest raw logs as ordinary queryable text. Store small searchable fields and keep sensitive artifacts behind the access controls designed for artifacts.

DataStore in hot table?Safer handling
Full stdoutNoStore byte count and artifact pointer
Full stack traceUsually noStore top frame, error type, fingerprint
Secret-like valuesNoRedact before persistence
ScreenshotNoStore artifact metadata
Test name and fileYesNeeded for triage
Commit SHAYesNeeded for traceability

Add a redaction test to ingestion. It does not have to catch every possible secret to be worth running. It should catch obvious mistakes before they become permanent warehouse rows.

const secretPatterns = [
  /api[_-]?key/i,
  /secret/i,
  /token/i,
  /password/i
];

export function looksSensitive(value: string): boolean {
  return secretPatterns.some((pattern) => pattern.test(value));
}

export function safeErrorMessage(message: string, maxLength: number): string {
  const shortened = message.slice(0, maxLength);
  return looksSensitive(shortened) ? "[redacted]" : shortened;
}

Retention should be explicit. Detailed attempts may be useful for 90 or 180 days. Aggregates may be useful for years. Artifact retention may be shorter because storage costs and privacy risk are higher. The right numbers are organizational choices, but the schema should support tiering from the start.

Dashboard Contracts Beat Ad Hoc Queries

Once people trust the warehouse, dashboards multiply. Without shared views, every dashboard author writes a slightly different flake definition. Publish a small set of blessed views: latest PR failures, daily test summary, flake candidates, slow tests, and owner health. Treat those views as an API.

create view flake_candidates_30d as
select
  tc.repository,
  tc.file_path,
  tc.test_name,
  count(*) filter (where ta.status in ('failed', 'timed_out')) as failing_attempts,
  count(*) filter (where ta.status = 'passed') as passing_attempts,
  count(*) as total_attempts
from test_attempts ta
join test_cases tc on tc.test_case_id = ta.test_case_id
where ta.created_at >= now() - interval '30 days'
group by tc.repository, tc.file_path, tc.test_name
having count(*) filter (where ta.status in ('failed', 'timed_out')) > 0
   and count(*) filter (where ta.status = 'passed') > 0;

Version important views. When you change a definition, announce it like an application change. QA metrics lose credibility when yesterday's flake count and today's flake count differ because someone edited SQL quietly.

Frequently Asked Questions

What is the best primary key for a test case?

Use a deterministic key built from repository, suite path, file path, and full test name, or use a framework-provided stable id when it truly exists. Do not use only the test title. Many suites reuse titles such as "renders correctly" or "submits form." Store the readable fields beside the hash so people can debug queries. When files move, decide whether history should follow the test or remain attached to the old path, then document that rule.

Should retries be stored as separate rows?

Yes. Store every attempt when the source data exposes it. Final status is useful for release gates, but retry history is essential for flake analysis and CI cost work. A test that fails once and passes on retry should not look the same as a test that passed cleanly. Keep attempt_number, status, duration, and failure fingerprint. Then derive final status in a view or summary table.

Where should screenshots, videos, and traces live?

Keep large artifacts in object storage or the CI provider artifact store, then store metadata and pointers in the warehouse. The test_attempts table should contain searchable facts, not binary payloads. Store artifact type, stable object key or URL, byte size, checksum when available, and the related job or test id. Avoid signed URLs with secrets in warehouse rows. A separate artifact table keeps hot queries fast.

How much history should a CI test warehouse keep?

Keep enough raw history to answer release, flake, and trend questions for your delivery cycle. Many teams start with 90 days of detailed attempts and longer retention for daily summaries. Regulated or enterprise environments may need more. The important point is tiering: detailed facts for recent debugging, compact summaries for long-term trends, and source artifacts long enough to backfill after parser fixes.