Lab 8.1: Reproduce an Existing GitHub Issue
Background
A bug you cannot reproduce on demand is a bug you cannot fix with confidence. The reproducer is
the single most valuable artifact in a contribution: it makes root cause provable, makes the fix
verifiable, and is the first thing a maintainer looks for in your PR. Before you write one line of a
fix, you must have something that fails the same way every time on main.
This lab takes a real open issue from opensearch-project/OpenSearch and turns its prose bug report
into a deterministic reproducer — ideally a failing JUnit test you will ship in the PR, at
minimum a recorded curl/REST-YAML sequence on ./gradlew run. The governing rule: it must fail
on main without your change, every run, and pass after the fix. If it does not fail on main,
you are reproducing the wrong thing.
Why This Lab Matters for Contributors
- Maintainers triage by reproducibility. "I can't reproduce" closes more issues than any fix.
- A repro separates symptom (what the reporter saw) from trigger conditions (what is actually required). That separation is the root-cause work, started early.
- A repro promoted to a JUnit test becomes your regression guard for free.
Prerequisites
- A built checkout on
main:./gradlew assemblegreen. Record the commit:git rev-parse --short HEAD git log -1 --format='%h %ci %s' - The ability to run
./gradlew runand./gradlew :server:test. - Read Level 8 index and Capstone Step 2: Reproduction — this lab is the warm-up for that graded step.
Step-by-Step Tasks
Step 1 — Pick and triage an issue (15 min)
Open the issues list and filter:
is:open is:issue label:bug repo:opensearch-project/OpenSearch sort:updated-desc
Pick something that (a) is in server core (not a separate plugin repo), (b) has a concrete symptom, and (c) is small. Write a triage note:
Issue: #NNNNN — <title>
Symptom (reporter's words): <quote the failing behavior>
Suspected subsystem: <e.g. search aggregations / setting validation / parsing>
Assignee / open PR? <none>
My plan: reproduce via <unit | curl> then root-cause.
Note: If you cannot find a clean open issue, this lab works equally well on a real-feeling bug class. A canonical, evergreen example used below: a request parser accepts an out-of-range value that should be rejected, producing a confusing downstream failure instead of a clean validation error. This is the exact shape of dozens of real OpenSearch issues.
Step 2 — Reproduce it manually first, to see it (15 min)
Always start manual. You need to watch the bug happen before you encode it. Spin up a node:
./gradlew run
export OS=http://localhost:9200
Drive the reported scenario. For the worked example (a terms aggregation accepting size: 0 or a
negative value and behaving oddly instead of rejecting it):
curl -s -XPUT "$OS/t" -H 'Content-Type: application/json' -d '{"mappings":{"properties":{"k":{"type":"keyword"}}}}'
curl -s -XPOST "$OS/t/_doc?refresh=true" -H 'Content-Type: application/json' -d '{"k":"a"}' >/dev/null
# The suspicious request — does it 400 cleanly, or do something surprising?
curl -s -i "$OS/t/_search?size=0" -H 'Content-Type: application/json' -d '{
"aggs": { "g": { "terms": { "field": "k", "size": -1 } } } }' | head -20
Record exactly what you see: the HTTP status, the error class/message, or the wrong result. That recorded behavior is your symptom baseline.
Step 3 — Separate symptom from trigger conditions (15 min)
A bug report says "it crashed." Your job is to find the minimal conditions that provoke it. Vary one factor at a time and note which ones matter:
| Factor | Does it still reproduce? | Conclusion |
|---|---|---|
size: -1 vs size: 0 vs size: 5 | only -1/0? | the boundary is the trigger |
keyword vs long field | both? | field type irrelevant |
| 1 shard vs 5 shards | both? | not a reduce/fan-out bug |
| empty index vs 1 doc vs 1000 docs | all? | not data-dependent |
The set of factors that must be true to reproduce is your trigger condition. Everything else is
noise to strip out of the test. This is the heart of the lab — a vague report becomes a precise,
minimal statement: "terms with size <= 0 on any field."
Step 4 — Pin version and seed (5 min)
Reproducibility means the same inputs every run. Pin:
- Commit: the
git rev-parse --short HEADfrom prerequisites. State it in the issue/PR. - Seed: OpenSearch tests are randomized (
RandomizedRunner). When a test fails, the output prints a reproduce line:
Capture that seed. A bug that only reproduces under a specific seed is still a real bug — pin the seed and say so.REPRODUCE WITH: ./gradlew :server:test --tests "...Tests.method" -Dtests.seed=ABCDEF123
Step 5 — Promote the manual repro to a deterministic test (25 min)
Manual curl is for seeing the bug. Ship code. Choose the lowest-cost harness that reliably
reproduces it (see the table in Capstone Step 2):
| Harness | Use when | Speed |
|---|---|---|
OpenSearchTestCase / AggregatorTestCase / OpenSearchSingleNodeTestCase | logic in one class (parser, validator, reduce) | seconds |
OpenSearchIntegTestCase (InternalTestCluster) | needs multiple nodes/shards | tens of seconds |
OpenSearchRestTestCase + .yml | a REST contract (status code, message, shape) | tens of seconds |
For the worked example, the bug is in a request parser/validator — a unit test is right. Find the parser:
grep -rn "size" server/src/main/java/org/opensearch/search/aggregations/bucket/terms/TermsAggregationBuilder.java | grep -i "parse\|require\|valid\|setSize\|shardSize" | head
Now write a test that asserts the desired behavior (a clean IllegalArgumentException), so it
fails on main (where no such validation exists yet):
/*
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*/
package org.opensearch.search.aggregations.bucket.terms;
import org.opensearch.test.OpenSearchTestCase;
public class TermsAggregationBuilderReproTests extends OpenSearchTestCase {
public void testNegativeSizeIsRejected() {
TermsAggregationBuilder builder = new TermsAggregationBuilder("g");
// On main this may NOT throw (the bug). After the fix it must throw with a clear message.
IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> builder.size(-1));
assertThat(e.getMessage(), containsString("[size] must be greater than 0"));
}
public void testZeroSizeIsRejected() {
TermsAggregationBuilder builder = new TermsAggregationBuilder("g");
IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> builder.size(0));
assertThat(e.getMessage(), containsString("[size] must be greater than 0"));
}
}
Run it and confirm it is red on main:
./gradlew :server:test --tests "*.TermsAggregationBuilderReproTests"
Warning: If this test passes on
main, the validation already exists — you reproduced the wrong thing, or the issue is already fixed. Go back to Step 3 and re-examine the trigger conditions. A repro that does not fail onmainis not a repro.
Step 6 — (Alternative) a REST-YAML reproducer (15 min)
If the bug is a REST contract (status code or error body), encode it as a yamlRestTest instead.
Find an existing one to copy the structure:
find . -path "*rest-api-spec/test*search*" -name "*.yml" | head
sed -n '1,30p' "$(find . -path '*rest-api-spec/test*' -name '*.yml' | head -1)"
A reproducer asserting a clean 400:
---
"terms agg rejects non-positive size":
- do:
catch: bad_request
search:
index: t
body:
aggs:
g:
terms:
field: k
size: 0
- match: { error.type: "x_content_parse_exception" }
- match: { status: 400 }
Run it:
./gradlew :rest-api-spec:yamlRestTest --tests "*search*"
Step 7 — Document the reproducer (10 min)
Write the repro up so a maintainer (and future-you) can run it in one command. This goes in the issue comment and later the PR:
Reproduced on <commit hash> (./gradlew assemble green).
Minimal trigger: terms aggregation with size <= 0.
Failing test (red on main):
./gradlew :server:test --tests "*.TermsAggregationBuilderReproTests"
Observed on main: size(-1) is accepted silently; the failure surfaces later/confusingly.
Expected: IllegalArgumentException "[size] must be greater than 0" at build time.
Implementation Requirements
Deliverable is a reproducer package:
- A triage note (issue link, symptom, subsystem, assignee check, plan).
- A minimal trigger statement (the stripped-down conditions from Step 3).
- A pinned commit hash (and seed, if relevant).
-
A reproducer that fails on
main: a JUnit test or a REST-YAML case or a recordedcurlsequence with captured output. - A one-command way to run it.
Expected Output
- A red test on
main, e.g.:TermsAggregationBuilderReproTests > testNegativeSizeIsRejected FAILED java.lang.AssertionError: Expected IllegalArgumentException to be thrown, but nothing was thrown - A documented repro block ready to paste into the issue/PR.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Test passes on main | Bug already fixed, or you asserted current behavior | Re-check the issue; re-derive trigger conditions |
| Repro only sometimes fails | Randomized seed / timing / data-dependent | Pin -Dtests.seed; remove nondeterminism |
| Can't find the parser/validator | Wrong subsystem | grep the symptom's keywords across server/src/main/java |
curl shows the bug but the unit test doesn't | Bug is in transport/REST, not the class you tested | Move up a harness level (integ or REST-YAML) |
./gradlew run won't start | Stale build / port in use | ./gradlew clean assemble; free :9200 |
Stretch Goals
- Reduce the reproducer to its absolute minimum: fewest docs, fewest settings, smallest harness that
still fails on
main. A maintainer should be able to read it in 20 seconds. - Write the repro at two levels (unit + REST-YAML) and decide which you would ship. Justify it.
- Bisect to find roughly when the behavior was introduced (or was always present):
git log -S "the symbol you grepped" -- server/src/main/java/org/opensearch/search/aggregations/bucket/terms/ | head
Validation / Self-check
- Does your reproducer fail on
main? Show the red output. - State the minimal trigger conditions — what must be true, and what is irrelevant?
- Which harness did you choose and why was it the lowest-cost option that reliably reproduces?
- What commit (and seed, if any) did you pin?
- How will this same artifact prove your fix works in Lab 8.2?
- Could a maintainer reproduce the bug from your write-up alone, in one command?
Cross-references: Capstone Step 2: Reproduction, Lab 8.2: Implement the Fix, reading the codebase.