| name | extend-commands-api |
| description | Add or extend Redis commands in the Lettuce client API end-to-end — a new core command, a family of new commands, an extension to an existing command's options, or a module/area command (Search/JSON/Bloom/VectorSet). Gathers evidence first (HLD document, the server-side PR in the repo owning the command family, live verification against the Dockerized test environment with redis-cli), plans the full implementation matrix in plan mode, then implements across all API flavors with unit and integration tests. Trigger on "add support for the <X> command", "implement <REDIS COMMAND> in Lettuce", "extend <command> with <option>", or adding a new argument/overload to an existing command. |
| allowed-tools | Bash(mvn *), Bash(make *), Bash(redis-cli *), Bash(gh *) |
Extend the Lettuce Commands API
Implement a new Redis command (or extend an existing one) in Lettuce, following the
conventions maintainers enforce in review. The workflow is evidence-first: prove the
command's behavior on a real server before designing the Java API.
Read .agents/docs/architecture.md first for
the model behind this flow — especially that the sync/async/reactive/Kotlin command
interfaces are hand-edited source files kept in lockstep by the API consistency
test suite (see .agents/docs/api-consistency.md).
Historical caveat when reading old PRs: PRs merged before the generator removal
also edit src/test/java/io/lettuce/core/api/<Group>Commands.java — the old
template source files. Those files no longer exist; do not recreate them. The
flavor interfaces are now edited directly.
Phase 0 — Gather evidence BEFORE planning
Do all of the following before writing any plan or code:
-
Ask the user for the HLD. Interactively ask for the path to a markdown file
containing the High-Level Design for the command(s) (or confirm none exists). If
a path is given, read it fully — it is the primary source for syntax, semantics,
reply shape per RESP2/RESP3, and edge cases.
-
Find the server-side PR in the repo that owns the command. Route the
search by command family — module command families are developed in their
owning repositories, not in redis/redis:
| Command family | Repository | Syntax source |
|---|
Core commands, vector sets (VADD, …) | redis/redis | src/commands/*.json |
Search (FT.*) | RediSearch/RediSearch | PR diff + command docs (no src/commands/*.json) |
JSON (JSON.*) | RedisJSON/RedisJSON | PR diff + command docs |
Probabilistic (BF.*, CF.*, CMS.*, TOPK.*, TDIGEST.*) | RedisBloom/RedisBloom | PR diff + command docs |
Time series (TS.*) | RedisTimeSeries/RedisTimeSeries | PR diff + command docs |
If the search comes up empty in the routed repo, fall back to redis/redis
(and vice versa) before concluding there is no server PR.
First verify that gh works in the current (sandboxed) environment:
gh auth status
Sandboxes often block access to credential files, so gh may report
unauthenticated here even though it works on the user's machine. If so, ask the
user for permission to run these specific read-only gh commands outside the
sandbox; only fall back to the unauthenticated GitHub REST API if they decline.
Then search for the PR that adds/extends the command on the server:
gh search prs --repo <owning-repo> "<COMMAND NAME>" --limit 10
gh view <num> --repo <owning-repo>
gh diff <num> --repo <owning-repo>
The evidence is your working context, not a deliverable. Hold it in mind to
drive the work — do not paste it back to the maintainer or pause for "spec
approval." The only stop for the maintainer is the Phase 1 plan approval; surface
genuinely ambiguous design choices there, with the proposed sync signatures,
rather than interrupting earlier.
Phase 1 — Plan mode, then explicit approval
Enter plan mode. Using the evidence, classify the change with the decision tree
below and enumerate the exact file-by-file touch list, the test matrix, and the
gating annotations. The plan must contain:
- A short "what this feature enables" section built from the showcase scenarios
(Phase 0 step 4), including one or two representative command/reply transcripts.
- The proposed sync interface signature(s) with their Javadoc. The sync
interface is the human-authored API contract; every other flavor is derived
from it and it is costly to change once mirrored. Plan approval is the
maintainer's sign-off on that contract — if the signatures must deviate later
during implementation, stop and re-confirm before mirroring.
- The complete overload set, enumerated. For every varargs parameter, list
the matching single-argument overload; for a multi-key command with an
*Args
object, use the List<K> shape (no varargs) and list the fixed-arity
convenience overloads instead — both house rules live in "Types & args
conventions". Justify any overload you omit so the maintainer signs off on the
exception. An overload discovered missing in review means reworking all six
flavors.
Present the plan and explicitly ask permission to execute before implementing.
Do not start editing files until the user approves.
Once approved, copy this checklist into your working notes and tick items off:
Extend-commands progress:
- [ ] 0. Evidence: HLD, server PR, live probe + showcase, RESP2/RESP3 replies, @since
- [ ] 1. Plan approved — incl. the sync signature(s), the API contract
- [ ] 2. Types: argument & response types (they must exist before any interface edit)
- [ ] 3. Sync interface: full overload set (varargs → single-arg too) + Javadoc
(@param constraints + @throws for builder-validated preconditions)
- [ ] 4. Mirror: async, reactive, Kotlin, NodeSelection×2 — consistency tests pass
- [ ] 5. Implementations: CommandType/Keyword, builder, async, reactive, Kotlin impl
- [ ] 6. Tests: args/builder/output unit tests + integration base/overloads
- [ ] 7. Docs: entry in the current-release section of docs/new-features.md
- [ ] 8. Verify: mvn clean test + a single integration test run; then make stop
Decision tree — what kind of change is this?
A. Extension of an existing command that fits an existing *Args class
(new option token / new field):
- Touch ONLY the
*Args class (+ CommandKeyword for new tokens) + tests. Do NOT
touch the command interfaces, builder signatures, or dispatch layers — the
existing args.build(commandArgs) delegation carries the new option through
automatically (cf. the XAddArgs part of the stream-idempotency PR).
- Add a fluent setter returning
this; register new tokens in
src/main/java/io/lettuce/core/protocol/CommandKeyword.java.
- If the reply shape grows, extend the response model/output backward-compatibly.
B. New command(s) in an existing group — core or module area — the FULL
matrix, in this order (types first — every flavor references them, so they must
exist to compile). For a command joining an existing module area (a new
FT.* method in the Search group, a new JSON.* method, …) the same matrix
applies with the area substitutions: the group is the area's flavor interfaces,
the builder is the area's Redis<Area>CommandBuilder (+ its
Redis<Area>CommandBuilderUnitTests), argument/reply types go in the area
package, and gating/tests follow the module rules in D (capability probe, stack
node). The dispatch layers are the same AbstractRedisAsyncCommands /
AbstractRedisReactiveCommands and Kotlin *Impl.kt as for core commands.
-
Argument/response types — see "Types & args conventions" below.
-
Sync interface src/main/java/io/lettuce/core/api/sync/<Group>Commands.java
— pick the group by command family (STRING → RedisStringCommands, HASH →
RedisHashCommands, generic-key → RedisKeyCommands, …). This is the reference
signature + Javadoc all flavors mirror (see the
writing-javadoc skill; @since mandatory):
Long strlen(K key);
Three contract rules to apply while designing the signatures and their Javadoc:
- Multi-key commands that also take an
*Args object take the keys as
List<K>, not varargs. A varargs parameter must come last, so a trailing
options object can only follow it via an awkward leading placement (cf. the
older sintercard(long limit, K... keys)). For new commands, take the keys
as List<K> so the *Args argument comes last —
sunioncard(List<K> keys, SUnionCardArgs args) — and add fixed-arity
convenience overloads ((K key1, K key2) and their *Args variants)
instead of a varargs form. Since there is no varargs parameter here, the
single-argument-overload rule below does not apply; add only the overloads
that are meaningful — e.g. no single-key sunioncard/sdiffcard, whose
one-key union/difference is just SCARD.
- Every varargs parameter gets a single-argument overload —
foo(K key, V value) alongside foo(K key, V... values). This is a hard
convention for new commands and overrides any traced precedent that lacks
it (older commands predate the rule). Does not apply to the -shaped
multi-key-with- commands above, which take no varargs.
C. Extension needing new overloads / a new *Args class (hybrid): the new
methods go through the full matrix of B; the option plumbing follows A. Check
whether command helpers must follow (ScanIterator/ScanStream/ScanFlow for
scan-family commands).
D. New command group / module area (Search/JSON/Bloom/VectorSet-style):
- Unlike Jedis, Lettuce module areas are full citizens: every group gets all
six flavors — sync, async, reactive, Kotlin coroutines, and both node-selection
interfaces — plus Kotlin impls.
- Create the flavor interfaces by mirroring an existing area end-to-end, register
the group in the
CommandInterfaces enum
(src/test/java/io/lettuce/core/api/consistency/), and wire the group into the
hand-written aggregate interfaces (RedisCommands, RedisAsyncCommands,
RedisReactiveCommands, and the cluster variants) so they extend it — the
consistency tests enforce the aggregate wiring.
- Areas get their own builder (
Redis<Area>CommandBuilder, cf.
RediSearchCommandBuilder) with a matching
Redis<Area>CommandBuilderUnitTests, and keep their argument types and reply
parsers in an area package (e.g. core/search/arguments/).
- Module commands still gate on server capability, not version:
@EnabledOnCommand("FT.CREATE")-style probes; integration tests target the
stack node.
Types & args conventions
- Argument types: an options object is a
*Args implements CompositeArgument
class (e.g. io.lettuce.core.CopyArgs) whose build(CommandArgs) appends its
tokens. Fluent setters return this. If two overloads share options but differ
in a typed field (long vs double), use a self-typed abstract base
(BaseFooArgs<T extends BaseFooArgs<T>>) with concrete subclasses — cf.
BaseIncrexArgs/IncrexArgs/IncrexFloatArgs.
@since goes on every new public element, not just the class. A
class-level @since is not inherited: the nested Builder type, each of
its static factory methods, and each public fluent setter needs its own
@since tag, or the generated API docs lose the release provenance for those
members.
- Token-valued argument enums are plain enums whose values the builder/args class
appends (cf.
XNackMode).
- Response types: reuse existing models where possible —
Value, KeyValue,
ScoredValue, GeoCoordinates, GeoWithin, StreamMessage, KeyScanCursor
(all in io.lettuce.core) — and add a new one only when the reply genuinely
doesn't map. For a map-shaped/structured reply, pair a model class with a
ComplexDataParser consumed via ComplexOutput (cf. HotkeysReply +
HotkeysReplyParser).
- Return-type idioms (established conventions):
1/0 integer reply →
Boolean (cf. copy, expire, hsetnx); count → Long; status → String;
bulk value → V. And the overload rules from step B.2: every varargs
parameter also gets a single-argument overload, while a multi-key command with
an *Args object takes List<K> (not varargs) plus fixed-arity overloads.
- The
CommandOutput (the reply parser) is chosen at the builder step from the
observed RESP2/RESP3 replies of Phase 0 — if none fits, add one under
with a unit test (cf. ).
The consistency suite is the safety net
After mirroring, run:
mvn -Dtest='*ConsistencyUnitTests,CommandBuilderCoverageUnitTests' \
-Dsurefire.failIfNoSpecifiedTests=false test
It names exactly the flavor/signature you missed. For a genuinely unusual return
type (e.g. Flux<Value<Long>>, or Mono<List<Double>> because Redis returns
nulls), register it in the registry that owns the flavor —
src/test/java/io/lettuce/core/api/consistency/KnownApiDeviations.java for the
Java flavors, src/test/kotlin/io/lettuce/core/api/consistency/KnownKotlinApiDeviations.kt
for the coroutine flavor — with a comment justifying it. Never use a
deviation entry to paper over a sync/async signature mismatch — that breaks the
sync-over-async runtime proxy.
Format before you build: run mvn formatter:format after hand-editing — the
build's formatter:validate step fails the compile on unformatted code. Do not
submit formatting-only diffs.
Test matrix — what to write
Naming, placement, and the base/overload structure are owned by
.agents/docs/integration-testing.md
— follow it. The established per-command layers (write all that apply):
- Args unit tests (
*Args classes): assert the exact encoded tokens and
wire order, setter validation, and overload equivalence — e.g.
IncrexArgsUnitTests, XAddArgsUnitTests. No server needed.
- Builder unit test: assert the constructed command and encoded args —
including the RESP2/RESP3 output shape observed in Phase 0. Core commands go
in
src/test/java/io/lettuce/core/RedisCommandBuilderUnitTests.java; area
commands in their Redis<Area>CommandBuilderUnitTests.
- Output unit tests when a new
CommandOutput was added (cf.
IncrexOutputUnitTests).
- Integration tests: add methods to the sync base class
(
<Group>CommandIntegrationTests), gated per-test with
@EnabledOnCommand("<NAME>"). Use assertions derived from the redis-cli
showcase transcripts — real semantics, not just "no error", covering the whole
family the option touches (with/without optional args, error cases):
@Test
@EnabledOnCommand("COPY")
void copy() {
redis.set(key, value);
assertThat(redis.copy(key, key + "2")).isTrue();
}
- Overloads: the base's
@Test methods re-run automatically under the
group's existing RESP2/cluster/reactive/Tx overload classes — but only the
ones that exist. Check the target group against peer groups and create a
missing overload class when it matters for the command (INCREX created
StringCommandResp2IntegrationTests because its reply differs by protocol).
Provide the base test at minimum; add overloads that carry real
risk (RESP2 when replies differ, cluster when routing matters).
Running the tests
The build pins a specific JDK to match CI — check .github/workflows/ and the
local-gotchas section of
.agents/docs/integration-testing.md
(pin JAVA_HOME, worktree git-commit-id-plugin skip, TEST_WORK_FOLDER).
Tear the environment down when you are done. The Docker topology started in
Phase 0 keeps running (and holds the test ports) until stopped. After the final
verification run — and equally when the task is aborted or fails partway — run:
make stop
PR hygiene checklist (verify before finishing)
Top pitfalls
- Skipping the live verification / not checking RESP2 vs RESP3. The reply
shape can differ between protocols; it determines the
CommandOutput and the
reactive mapping. Confirm against a running server, don't assume.
- Adding Args/response types after the interface edits — every flavor
references them; the project won't compile. Types come first.
- Editing only some flavors, or silencing the consistency suite with a
deviations-registry entry instead of fixing the signature.
- Forgetting a dispatch layer — both
AbstractRedisAsyncCommands and
AbstractRedisReactiveCommands, plus the Kotlin *Impl.kt.
- Recreating the removed generator source files under
src/test/java/io/lettuce/core/api/ because an old reference PR touched them.
- Wrong
CommandArgs order or CommandOutput, missing @since, missing
@EnabledOnCommand gating, or missing the read-only registry entry.
- Letting a traced precedent override a written convention — e.g. skipping
the single-argument overload because
sintercard(K...) doesn't have one, or
stopping at a class-level @since because an old *Args class did. Older
code predates the rules; the conventions win.