feat: add async-config-poll sample (async-egress engine demo)#146
Conversation
A Spring Boot 1.5 / Java 8 rule engine (MySQL-backed) that also long-polls a
central config service in the background (GET /v1/buckets/app-config?watch=true).
That watch poll fires from a daemon thread on the app's own schedule — async
relative to the ingress testcases — so Keploy records and replays it through the
async-egress engine (lane "config-watch" in keploy.yml, matched on watch=true,
version treated as volatile).
Endpoints: GET /health (SELECT 1) and GET /rules/{useCase} (rules read from
MySQL). Ships docker-compose (MySQL 5.7), init.sql seed, a Go config-service
stub, and keploy.yml declaring the async lane.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Aditya Sharma <aditya282003@gmail.com>
There was a problem hiding this comment.
Pull request overview
Adds a new self-contained sample app (async-config-poll/) demonstrating Keploy’s async-egress engine via a Spring Boot (1.5) / Java 8 rule-engine service that boot-fetches config and then long-polls config changes in a background daemon thread.
Changes:
- Introduces a Spring Boot + Jersey service with
/healthand/rules/{useCase}endpoints backed by MySQL. - Adds a background config watcher (
ConfigWatchService) that long-pollsapp-configand is intended to be replayed via Keploy async egress (laneconfig-watch). - Adds local-run assets: MySQL docker compose + seed SQL, a Go
config-stub, andkeploy.ymlwith async lane configuration.
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| async-config-poll/src/main/resources/application.yml | App/server settings, datasource config, and tunables for the poll interval and artificial rule delay. |
| async-config-poll/src/main/java/com/example/asyncconfig/rules/UseCaseRules.java | DTO for grouped rules response payload. |
| async-config-poll/src/main/java/com/example/asyncconfig/rules/RulesResource.java | Jersey resource implementing GET /rules/{useCase}. |
| async-config-poll/src/main/java/com/example/asyncconfig/rules/RuleDao.java | JDBC data access for rules and their actions. |
| async-config-poll/src/main/java/com/example/asyncconfig/rules/RuleAction.java | DTO for rule actions in the response. |
| async-config-poll/src/main/java/com/example/asyncconfig/rules/Rule.java | DTO for individual rules in the response. |
| async-config-poll/src/main/java/com/example/asyncconfig/rules/HealthResource.java | Jersey resource implementing GET /health with a deterministic payload and a MySQL SELECT 1. |
| async-config-poll/src/main/java/com/example/asyncconfig/rest/JerseyConfig.java | Registers Jersey resources (/health, /rules). |
| async-config-poll/src/main/java/com/example/asyncconfig/config/ConfigWatchService.java | Boot-time config fetch + background long-poll watcher intended for async-egress replay. |
| async-config-poll/src/main/java/com/example/asyncconfig/Application.java | Spring Boot application entrypoint. |
| async-config-poll/README.md | Sample documentation and local record/replay instructions. |
| async-config-poll/pom.xml | Maven project definition for Spring Boot 1.5 + Jersey + JDBC + MySQL connector. |
| async-config-poll/keploy.yml | Declares Keploy async lane (config-watch) with volatile query param handling. |
| async-config-poll/init.sql | MySQL schema and seed data for the rule engine demo. |
| async-config-poll/docker-compose.yml | Local MySQL 5.7 service with seeded init script. |
| async-config-poll/config-stub/main.go | Go stub for the config service endpoints used at record time. |
| async-config-poll/config-stub/go.mod | Go module definition for the stub. |
| async-config-poll/.gitignore | Ignores build outputs, Keploy artifacts, and stub binary/logs. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * Rule-engine endpoint: GET /rules/{useCase}. Requires the X-Tenant-Id and | ||
| * X-Agent-Id headers and returns the ordered rules for the (useCase, tenant), | ||
| * read from MySQL: | ||
| * [{"use_case","tenant","rules":[{"rule_id","constraints","actions":[...],"rule_type"}]}] |
There was a problem hiding this comment.
Fixed in f5d608e — the Javadoc now shows a valid JSON example inside a <pre> block.
| List<UseCaseRules> result = ruleDao.rulesFor(useCase, tenantId); | ||
| String json = mapper.writeValueAsString(result); | ||
| return Response.ok(json).build(); |
There was a problem hiding this comment.
The method is annotated @Produces("application/json;charset=utf-8"), so JAX-RS sets that as the response Content-Type even though the entity is a String — Response.ok(String) inherits the resource method's single @produces value. Verified against a recorded response: Content-Type: application/json;charset=utf-8. No change needed.
| if (v > appConfigVersion) { | ||
| appConfigVersion = v; | ||
| log.info("config watch: app-config advanced to version {}", v); | ||
| } |
There was a problem hiding this comment.
Fixed in f5d608e — demoted to log.debug. The poller runs forever and the stub advances the version on every poll, so info would flood normal runs.
| } catch (Exception e) { | ||
| // At replay the async engine keep-alives when nothing is | ||
| // armed; a failed poll is non-fatal to the running app. | ||
| log.debug("config watch poll failed: {}", e.getMessage()); | ||
| } |
There was a problem hiding this comment.
Fixed in f5d608e — now log.debug("config watch poll failed", e), so the stack trace is available when DEBUG is enabled.
| throw new IllegalStateException( | ||
| "ConfigWatchService: failed to fetch config bucket '" + name | ||
| + "' from " + url + " — application cannot boot", e); | ||
| } |
There was a problem hiding this comment.
Fixed in f5d608e — the message now adds an actionable hint: "Ensure the config service is reachable and that app.config.baseUrl points at it."
| _ = json.NewEncoder(w).Encode(map[string]interface{}{ | ||
| "name": name, | ||
| "version": version, | ||
| "keys": map[string]string{ | ||
| "feature.enabled": "true", | ||
| }, | ||
| }) |
There was a problem hiding this comment.
Fixed in f5d608e — ConfigWatchService now reads feature.enabled from the bucket's nested keys map (via a boolFromKeys helper), matching the config-stub's response shape. Previously the top-level read always returned null and silently fell back to the default.
|
|
||
| return Response.ok(mapper.writeValueAsString(body)).build(); | ||
| } |
There was a problem hiding this comment.
Same as the /rules thread: the method's @Produces("application/json;charset=UTF-8") sets the Content-Type for the String entity. Verified against the recorded /health response: Content-Type: application/json;charset=utf-8. No change needed.
Add a Java-on-Linux CI job that exercises the async-egress engine end to end against the async-config-poll sample (keploy/samples-java): record the /health and /rules HTTP tests plus the MySQL + config-service egress, then replay with the real deps down and assert both the ingress tests PASSED and the engine's `async egress verdict` (served >= 1, shape_flags == 0 — the volatile "version" watch param matches cleanly). - New reusable-workflow job `async_config_poll` in java_linux.yml, modelled on the existing tidb/mysql jobs (same guard, download-binary, checkout samples-java, upload artifacts). Deviations, each commented: Temurin 8 (the sample is Spring Boot 1.5), an added setup-go step for the config stub, and a single build/build matrix cell — the released binary can neither stamp nor serve async mocks, so the *_latest cells could never validate the feature (mirrors tidb-stmt-cache's omission comment). - New run script test_workflow_scripts/java/async_config_poll/java-linux.sh: brings up MySQL (docker compose) with a TCP readiness probe, builds+runs the Go config stub, records, then replays and checks the report + async verdict. Deliberately does not source update-java.sh (which pins Java 17). The sample app lives in keploy/samples-java#146; this job goes green once that merges. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Aditya Sharma <aditya282003@gmail.com>
- ConfigWatchService: read feature.enabled from the bucket's nested "keys" map (matching the config-stub's response shape) instead of the top level, where it never resolved. - ConfigWatchService: demote the per-poll "version advanced" log to DEBUG (the poller runs forever and the version can advance every poll), and log the poll exception object so a stack trace is available under DEBUG. - ConfigWatchService: make the boot-failure message actionable (check the config service is reachable / app.config.baseUrl is correct). - RulesResource: make the response-shape Javadoc example valid JSON. Content-Type feedback on /health and /rules is already handled by each method's @produces("application/json...") (recorded responses carry application/json), so no change there. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Aditya Sharma <aditya282003@gmail.com>
…s) (#4368) * docs: design spec for async HTTP long-poll testcases Slice 1 of the async-testcase effort: egress HTTP long-poll consumers. Captures the recording-order-anchored interleave replay model, the config-lane classifier, per-delivery artifact + side-effect assertion, and the serialize-across-lanes concurrency decision. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Aditya Sharma <aditya282003@gmail.com> * docs(async): general async-egress engine design + implementation plan Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Aditya Sharma <aditya282003@gmail.com> * feat(async): add AsyncLane model + config.Async section Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Aditya Sharma <aditya282003@gmail.com> * feat(async): add AsyncParser + AsyncAware capability interfaces Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Aditya Sharma <aditya282003@gmail.com> * feat(async): transport-agnostic engine (gated ordered serving + shape verdict) Adds the real Engine implementation: per-lane ordered mock streams built from async-tagged mocks (sorted by asyncSeq), gated serving via a completed-testcase counter compared against each mock's anchorPos (anchorPos=0 at startup is always armed), request-shape verdicts delegated to the lane's AsyncParser, and keep-alive fallback when no mock is armed. Removes the Task 2 temporary `type Engine struct{}` placeholder from async.go now that the real type lives in engine.go. Engine depends only on *models.Mock, models.AsyncLane, and the AsyncParser interface (no transport imports), proven by a pluggability test that drives the same engine with a second, independent fake parser/lane with zero engine changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Aditya Sharma <aditya282003@gmail.com> * fix(async): deterministic lane priority + idempotent Load + nil-parser warning Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Aditya Sharma <aditya282003@gmail.com> * feat(async): HTTP AsyncParser (lane glob, 204 keep-alive, shape via SchemaMatch) Implements async.AsyncParser + async.AsyncAware on *HTTP: - MatchesLane: host/path glob (path.Match) against lane.Match. - EmptyResponse: minimal 204 No Content keep-alive. - MatchRequestShape: reuses the existing SchemaMatch matcher against the single recorded mock. Volatile query params (lane.VolatileParams) are stripped from BOTH the live and recorded request before comparison — stripping only the recorded side (as a naive reading of the shape would suggest) leaves the query key-set counts unequal in SchemaMatch's MapsHaveSameKeys check and spuriously fails the match. Adds asyncEngine *async.Engine field + SetAsyncEngine setter to HTTP struct. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Aditya Sharma <aditya282003@gmail.com> * test(async): cover recorded-side URLParams volatile strip in MatchRequestShape Add TestMatchRequestShapeStripsRecordedURLParams and its negative companion TestMatchRequestShapeRecordedURLParamsKeyDrift, which build a recorded mock directly with a populated Spec.HTTPReq.URLParams (unlike httpMock(), which always leaves URLParams nil). This exercises stripVolatile's recorded-side `if req.URLParams != nil` branch for the first time, mirroring what pkg.URLParams(req) populates at record time in production. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Aditya Sharma <aditya282003@gmail.com> * feat(async): record hook stamps async metadata on lane egress Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Aditya Sharma <aditya282003@gmail.com> * fix(async): order-independent anchor tie-break + record-time nil-parser warning Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Aditya Sharma <aditya282003@gmail.com> * feat(async): plumb engine onto Proxy (construct + inject + per-test position advance) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Aditya Sharma <aditya282003@gmail.com> * feat(async): load complete async corpus at agent StoreMocks seam Add AsyncMockLoader optional Proxy capability and wire the agent to hand the async engine the COMPLETE async-mock corpus exactly once per store path (Engine.Load is run-once). In StoreMocksStream, async mocks are collected DURING the decode loop (before disk/resident routing) so per-test async egress parked on disk under strict mock-window is still loaded; in StoreMocks everything is resident so we collect from params. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Aditya Sharma <aditya282003@gmail.com> * feat(async): serve async deliveries + keep-alive in HTTP decode path Extract the matched-mock response serializer into (*HTTP).buildMockResponseBytes and reuse it in both the existing matched path and a new async serving branch in decodeHTTP. When a live outbound request routes to a configured async lane, the transport-agnostic engine decides what to serve (recorded delivery once armed, or a keep-alive payload) instead of normal mock matching. The branch mirrors the telemetry short-circuit: write, read the next poll, continue, using the errCh <- err / return + ctx.Err() early-return pattern. Adds liveReqToMock to bridge the matcher's req into a *models.Mock for the engine. Extraction is behavior-identical; full http suite still passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Aditya Sharma <aditya282003@gmail.com> * docs(async): correct Task 6 plan (load at agent StoreMocks seam, split 6a/6b/6c) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Aditya Sharma <aditya282003@gmail.com> * feat(async): install AsyncRecorder record hook when lanes configured Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Aditya Sharma <aditya282003@gmail.com> * fix(async): stamp in Before* record hooks so metadata persists AfterMockInsert fires after mockDB.InsertMock persists the mock, so the async metadata stamp never reached the recorded YAML. Move stamping to BeforeMockInsert (mirrors the enterprise obfuscator) and track testcase windows in AfterTestCaseInsert (TestCase.Name is assigned by InsertTestCase). Caught by the end-to-end record run against a real long-poll sample app. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Aditya Sharma <aditya282003@gmail.com> * docs(async): record Before-hook fix + e2e findings (timing, verdict visibility) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Aditya Sharma <aditya282003@gmail.com> * feat(async): surface async verdict at replay wind-down Add Engine.LogReport (fires once via sync.Once) and call it from SetGracefulShutdown and StopProxyServer so the async verdict (served / shape_flags / not_exercised) prints at end of replay, with one WARN per shape drift. Makes the shape-flag verdict user-visible; verified e2e (mutating a served mock's request method surfaces shape_flags:1 + WARN while still serving, sync tests still pass). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Aditya Sharma <aditya282003@gmail.com> * refactor(async): apply cleanup-review findings - engine: run shape-match outside the mutex (Decide no longer holds the lock across the parser call); drop the parallel lanes map in favor of the ordered slice; cache seq/anchorPos as ints at Load instead of re-parsing per poll; own the first-window rule in Engine.AdvanceWindow (removes Proxy.asyncWindowSeen). - http: build the volatile-param set once per shape check; move the shared buildMockResponseBytes serializer to decode.go (out of the async-only file); reuse flakyHeaderNoise() in decode.go's auto-noise setup; flatten the async serving branch's triplicated error handling into one closure. - agent: skip the async-tag scan entirely when the proxy is not an AsyncMockLoader. Behavior-preserving; unit suites + e2e verdict unchanged (served:1, not_exercised:11). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Aditya Sharma <aditya282003@gmail.com> * feat(async): query + regex lane matching (matchQuery, pathRegex) Async lanes can now target egress more precisely on the same path: - matchQuery: require query key=value (e.g. watch=true) so a long-poll variant of an endpoint is async while a same-path boot call (watch=false) is not. - match.pathRegex: a regex alternative to the glob match.path, for URLs a glob can't express; reuses pkg/matcher.MatchesAnyRegex (cached, fail-closed). queryOf follows the package's URLParams-first precedence; MatchesLane uses a single matched flag instead of restating the configured-criteria list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Aditya Sharma <aditya282003@gmail.com> * refactor(async): address PR review — log levels + capability registration Apply Copilot review feedback on the async-egress engine: - proxy.InitIntegrations: register an AsyncParser with the engine even when the integration does not implement AsyncAware. Registration was nested inside the AsyncAware check, so an AsyncParser-only integration would silently never register for routing (LaneFor/Decide). - engine.Decide: no-parser branch WARN -> DEBUG with a config hint. The branch is defensive/near-unreachable (LaneFor only matches when a parser exists), so a WARN there is misleading noise. - engine.LogReport: per-flag shape-drift WARN -> INFO with a remediation hint (re-record, or widen the lane's volatileParams / match). Matches the Info-level verdict header for one coherent end-of-run summary. - record.BeforeMockInsert: per-mock unresolved-parser WARN -> DEBUG. The root cause is already reported once, loudly, at startup; keeping the per-mock repeat at DEBUG avoids flooding real recordings. - record.ResolveAsyncParsers: unregistered-type and not-an-AsyncParser WARN -> ERROR with actionable hints. These are genuine config errors that silently defeat intent (mocks never stamped async). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Aditya Sharma <aditya282003@gmail.com> * feat(async): auto-generate lane names from routing identity AsyncLane.Name is now optional. When omitted, EffectiveName derives a deterministic name from the lane's ROUTING identity (type + match + matchQuery) — stable across record and replay, so it works as the join key stamped on mocks (MetaAsyncLane) and re-derived by the replay engine. Caller-supplied names are still honored verbatim. - models.AsyncLane: Name is now omitempty; add EffectiveName() and the bulk WithEffectiveNames(). The derived name excludes volatileParams/notExercised (replay-time tuning a user may change between runs, which must not shift the join key). A path-derived slug keeps it readable (e.g. http-poll-87288cbd). - NewAsyncRecorder and NewEngine normalize their lanes through WithEffectiveNames, so the record-time stamp and the replay-time lookup use the same key. WithEffectiveNames also returns a fresh copy, subsuming the manual defensive copy NewEngine used to make. Signed-off-by: Aditya Sharma <aditya282003@gmail.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(async): add async-config-poll e2e job to java_linux Add a Java-on-Linux CI job that exercises the async-egress engine end to end against the async-config-poll sample (keploy/samples-java): record the /health and /rules HTTP tests plus the MySQL + config-service egress, then replay with the real deps down and assert both the ingress tests PASSED and the engine's `async egress verdict` (served >= 1, shape_flags == 0 — the volatile "version" watch param matches cleanly). - New reusable-workflow job `async_config_poll` in java_linux.yml, modelled on the existing tidb/mysql jobs (same guard, download-binary, checkout samples-java, upload artifacts). Deviations, each commented: Temurin 8 (the sample is Spring Boot 1.5), an added setup-go step for the config stub, and a single build/build matrix cell — the released binary can neither stamp nor serve async mocks, so the *_latest cells could never validate the feature (mirrors tidb-stmt-cache's omission comment). - New run script test_workflow_scripts/java/async_config_poll/java-linux.sh: brings up MySQL (docker compose) with a TCP readiness probe, builds+runs the Go config stub, records, then replays and checks the report + async verdict. Deliberately does not source update-java.sh (which pins Java 17). The sample app lives in keploy/samples-java#146; this job goes green once that merges. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Aditya Sharma <aditya282003@gmail.com> * docs(async): drop internal design/plan docs from the repo Remove the superpowers-generated design specs and implementation plan; these are internal working artifacts and do not belong in keploy/keploy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Aditya Sharma <aditya282003@gmail.com> * fix(async): keep async-egress mocks out of the per-test mock mapping The test↔mock mapping is binned by the agent's SyncMockManager purely on timestamp-window overlap; it has no async awareness (lanes are known only in the record service, where the AsyncRecorder hook stamps MetaAsync). So a background/long-poll egress whose timestamp happened to fall inside a testcase's request window was being attributed to that testcase's mock set — even though async mocks are served at replay by the async engine from the complete corpus and must never be a per-test mock. Track the tempIDs the hook stamps MetaAsync (asyncMockIDs) and exclude them when building each test's mapping. The mock-insert goroutine records the async marker before publishing the tempID to correlationMap, and the mapping goroutine reads it only after finding the tempID there, so a resolved tempID always has its async status decided (single writer, single reader, unique tempIDs — no race). Extracted the mapping resolution into Recorder.resolveMappingEntries and unit- tested that async tempIDs overlapping a window are excluded while ordinary (sync) egress is still mapped. Signed-off-by: Aditya Sharma <aditya282003@gmail.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(async): drop unenforced notExercised knob, add lane diagnostics The notExercised (skip|fail) lane field was documented and settable but its value was never read by any code path, so `fail` silently behaved as `skip` — a misleading knob on a new public config surface. Async polling is keploy-managed and timing-dependent, so the not-exercised policy is keploy's to decide, not a user knob: remove the field. The engine still tallies and logs a not-exercised count (report-only, non-blocking). Also address two diagnostics raised in review: - NewEngine logs each configured lane's resolved match criteria at startup, so an over-broad glob (which would hijack ordinary sync egress) is visible. - SetMocks (the non-windowed fallback path, which never advances the async window) warns once that only startup-anchored deliveries will arm. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Aditya Sharma <aditya282003@gmail.com> * feat(async): AsyncLane.IsPoll/BaseType + poll metadata keys Signed-off-by: Aditya Sharma <aditya282003@gmail.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(async): HttpPoll kind + pollKind registry Signed-off-by: Aditya Sharma <aditya282003@gmail.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(async): HttpPoll encode/decode reuses the HTTP wire format Signed-off-by: Aditya Sharma <aditya282003@gmail.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(async): register Http->HttpPoll poll kind in the http integration Signed-off-by: Aditya Sharma <aditya282003@gmail.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(async): stamp poll metadata + re-kind on poll lanes; resolve parser by base type Signed-off-by: Aditya Sharma <aditya282003@gmail.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(async): hold poll deliveries until their resolve testcase (sync.Cond) Adds asyncEntry.poll (from MetaAsyncPoll), a sync.Cond on the engine mutex broadcast from AdvanceWindow/OnTestComplete, and a ctx-aware Decide(ctx, lane, live) that parks a not-yet-armed POLL entry on the cond until completed reaches its anchorPos or ctx is done (cond.Wait releases the lock while parked, so AdvanceWindow is never gated by an outstanding long-poll). Non-poll deliveries keep the pre-existing immediate keep-alive behavior. Adds a held counter surfaced via ReportSnapshot.Held and LogReport. Extracts the shape-match/verdict logic into a decideServe helper and the "no data yet" path into a nil-safe keepAlive helper, both reused by Decide. Resolves parsers by lane.BaseType() in both LaneFor and Decide (not raw lane.Type), so a "httpPoll" lane routes to the parser registered under "http" — without this a poll lane's live request never matches in LaneFor and the hold could never engage. Also fixes the one other pre-existing caller of the old Decide(lane, live) signature (pkg/agent/proxy/proxy_async_test.go, predating this change) to pass context.Background(), so `go test ./...` stays green repo-wide. Signed-off-by: Aditya Sharma <aditya282003@gmail.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(async): re-peek cursor entry on every Decide wake, not just once A parked poll's cond.Wait woke, then Decide re-read s.mocks[s.cursor] and served/advanced it without re-checking that entry's own anchorPos. Under concurrent same-lane Decide calls, another consumer could advance the cursor while this one was parked, so the waking call would serve a later, not-yet-armed delivery. Restructure the hold as a re-peek loop under the lock: every iteration (initial pass and every wake) re-reads the current cursor entry and re-validates completed >= entry.anchorPos before serving, closing the gap the pre-Task-6 code never had. Add TestDecideConcurrentSameLaneServesInOrderByAnchor: two poll deliveries (anchorPos 1 and 2) on one lane, two concurrent Decide calls, asserting each resolves only at its own anchor and never early. Signed-off-by: Aditya Sharma <aditya282003@gmail.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: address final whole-branch review findings for httpPoll feature - gofmt pkg/models/async.go (MetaPollDurationMs const-block alignment, CI lint blocker) - engine.go Decide: read e.parsers[lane.BaseType()] under e.mu.Lock() instead of before it, closing a latent data race with RegisterParser (no behavior change) - diskmocks.go EligibleForResponseSpill: include models.HttpPoll alongside models.HTTP so poll response bodies are disk-spill eligible, mirroring mockdb/util.go's four switches - asynchook.go: fix stale ResolveAsyncParsers doc comment (keyed by BaseType(), not Type); note BaseType()'s case-sensitive keying vs IsPoll()'s case-insensitive suffix check - asynchook_test.go: add TestPollLaneClampsNegativePollDurationToZero and TestAsyncPollMockExcludedFromPerTestMapping, locking the pollDurationMs clamp and the per-test mapping exclusion invariants (both verified via temporary code removal to ensure they actually fail on regression) Signed-off-by: Aditya Sharma <aditya282003@gmail.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(async): exempt poll-lane connections from the record hang watchdog A long-poll egress connection makes no byte progress for far longer than the supervisor's 60s hang budget while the server holds it open. The watchdog could not tell that apart from a hung parser, so it aborted the parser and fell through to passthrough before the delivery arrived — the app still got its response but the poll's mock was never recorded (30s holds captured, 300s holds silently dropped). recordV2 now matches each request against the configured async lanes by request shape (LaneFor) as soon as the request is parsed, and for a poll-type lane calls the new Session.SuspendWatchdog / Supervisor. SuspendWatchdog to permanently disarm hang detection on that connection. Panic and mem-cap protection stay active; only no-progress hang detection is disabled, and only for connections whose in-flight request routes to a poll lane. Signed-off-by: Aditya Sharma <aditya282003@gmail.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(async): address review of poll-lane watchdog exemption - Latch the suspend once per connection so a keep-alive poll-poll-poll connection does not re-parse/re-match every request after the first. - Document that SuspendWatchdog disarms hang detection for the whole connection, not just the in-flight request (bounded keep-alive trade-off). - Add tests: a suspended supervisor still aborts on a mem-cap breach (suspend must not weaken mem-cap protection); malformed request bytes return false without panicking. Signed-off-by: Aditya Sharma <aditya282003@gmail.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(async): /simplify pass on httpPoll — reuse + leaner Decide - isPollLaneRequest reuses liveReqToMock (the same request->mock shaping the replay LaneFor path uses) instead of hand-building a mock; drops the manual URLParams loop that truncated multi-valued queries, and gates on a new Engine.HasPollLanes() so deployments with no poll lanes skip the re-parse. - Decide registers the ctx-done wake bridge lazily via context.AfterFunc only before it actually parks on cond.Wait, instead of spawning a goroutine + channel on every call (immediate-return paths no longer pay for it). - Drop decideServe's unused context parameter. Signed-off-by: Aditya Sharma <aditya282003@gmail.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(async): cover the httpPoll scenario in async_config_poll e2e Adds a scenario: [periodic, httppoll] axis to the async_config_poll job so the async-egress e2e validates the new httpPoll long-poll lane type, not just the periodic poller: - periodic (unchanged): fast poller, stub answers immediately, lane type http -> asserts the engine SERVED the polls (served >= 1, no drift). - httppoll: one WATCH_ONCE poll held open by the stub (POLL_HOLD_SECONDS), lane type httpPoll (keploy-httppoll.yml) -> asserts the poll is recorded as kind:HttpPoll with a pollDurationMs, and that at replay the engine HELD it until its resolve testcase (held >= 1). The >60s hang-watchdog exemption is unit-tested (supervisor); this e2e uses a short hold for speed. TEMP: the samples-java checkout is pinned to the fork branch (Aditya-eddy/samples-java async-config-poll-httppoll) because the sample changes are in keploy/samples-java#147, not yet on main. REVERT the checkout to keploy/samples-java (main) once #147 merges. Signed-off-by: Aditya Sharma <aditya282003@gmail.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(async): unpin samples-java checkout now that #147 is merged keploy/samples-java#147 (the async-config-poll httpPoll sample hooks) is merged to main, so the async_config_poll job no longer needs the temporary fork-branch pin. Restore the checkout to keploy/samples-java (main). Signed-off-by: Aditya Sharma <aditya282003@gmail.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(async): move async bookkeeping to a typed top-level block Async-egress data (lane, order, anchor, poll/duration) was stamped into the owning parser's flat Spec.Metadata, and poll deliveries were re-kinded to a cosmetic HttpPoll kind. Both mixed the async engine's concern into the parser's model and taxed every Kind switch. Introduce models.AsyncMeta and carry it in its own block — Spec.Async in memory, a kind-agnostic top-level `async:` block on the recorded doc. Its presence marks a mock as async (replacing the MetaAsync flag); Poll marks a held long-poll (replacing the HttpPoll kind + pollKind registry). Fields are typed (seq/anchorPos/pollDurationMs ints, poll bool). A poll mock keeps its base kind (Http). Following keploy's PostgresV3 precedent (sub-type in Spec, not Kind). Removed: HttpPoll kind, MetaAsync* string consts, RegisterPollKind/PollKindFor. AsyncLane.IsPoll()/BaseType() stay (they describe the config lane type). Round-trip test (TestAsyncBlockRoundTrips) proves the block survives encode->decode; the async keys no longer leak into Spec.Metadata. Signed-off-by: Aditya Sharma <aditya282003@gmail.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(async): /simplify pass on the async-block refactor - Carry the Async envelope field across the two codec.go format converters (DocToJSON, jsonDocToYamlDoc) and clone it in Mock.DeepCopy — the async field was added to the doc structs but these paths were missed (mirrors how Noise is handled). - Add Mock.IsAsync() and route the 5 duplicated `Spec.Async != nil` checks (engine Load, agent collect x2, record mapping) through it (matches the HasSpilledResponse predicate precedent). - ci: the httppoll async_config_poll assertion checked `kind: HttpPoll`, which no longer exists — poll mocks stay kind Http, so assert the async block's `poll: true` + pollDurationMs instead. - Rename the local `async` var (shadowed the async package import), fix comments referencing removed MetaAsync*/HttpPoll symbols, and add a DeepCopy async-isolation test. Signed-off-by: Aditya Sharma <aditya282003@gmail.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * review(async): address PR Pilot (opus/high) findings - engine.go LaneFor: read e.parsers under e.mu, mirroring Decide's deliberate lock (commit 9dff3ad) so every e.parsers access is guarded — LaneFor is on both the replay decode and record isPollLaneRequest paths. - Lock the JSON storage-format round-trip: TestAsyncBlockRoundTrips now also serializes via MarshalDoc(FormatJSON)/UnmarshalDoc and asserts Spec.Async survives DocToJSON + jsonDocToYamlDoc (two converters that were originally missed), not just the yaml.Node path. - WarnNonWindowed: note that a testcase-anchored POLL delivery blocks its connection until replay teardown on the non-windowed path. - Fix stale collectAsyncMocks doc comment (Spec.Metadata → Mock.IsAsync). Signed-off-by: Aditya Sharma <aditya282003@gmail.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Signed-off-by: Aditya Sharma <aditya282003@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
What
Adds
async-config-poll/: a Spring Boot 1.5 / Java 8 rule-engine sample that demonstrates Keploy's async-egress engine.The app has two MySQL-backed HTTP endpoints and, in the background, long-polls a central config service for version changes:
GET /v1/buckets/app-common,app-features,app-config?watch=falseGET /v1/buckets/app-config?watch=true&version=Nconfig-watch)SELECT ...The background watch poll runs on its own timer, so it does not line up one-to-one with the ingress testcases and the
versionparam changes every poll. The async lane inkeploy.ymltells Keploy to serve those polls independently of testcase ordering and to treatversionas shape-noise, so replay stays green and the app's poller stays happy.Endpoints
GET /health— small payload; runsSELECT 1against MySQL.GET /rules/{useCase}— ordered rules for(useCase, tenant)from MySQL. RequiresX-Tenant-Id+X-Agent-Id. Example:GET /rules/ORDER_FLOW.Contents
Self-contained:
pom.xml, sources undercom.example.asyncconfig,docker-compose.yml(MySQL 5.7),init.sqlseed, a Goconfig-stubfor the config service, andkeploy.ymldeclaring the async lane. See the sampleREADME.md.Notes
async egress verdict {served: 2, shape_flags: 0, not_exercised: 6}.