Skip to content

Commit d981643

Browse files
Aditya-eddyclaude
andauthored
feat: add async-config-poll sample (async-egress engine demo) (#146)
* feat(async-config-poll): add async-egress engine sample 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> * review: address Copilot feedback on async-config-poll - 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> --------- Signed-off-by: Aditya Sharma <aditya282003@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent df4ad47 commit d981643

18 files changed

Lines changed: 740 additions & 0 deletions

File tree

async-config-poll/.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
target/
2+
keploy/
3+
.local-logs/
4+
config-stub/config-stub
5+
*.log

async-config-poll/README.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# async-config-poll
2+
3+
A Spring Boot 1.5 / Java 8 rule-engine sample that demonstrates Keploy's
4+
**async-egress engine**.
5+
6+
The app has two HTTP endpoints backed by MySQL, and it depends on a central
7+
config service in two different ways:
8+
9+
| Interaction | When | Keploy treats it as |
10+
|-------------|------|---------------------|
11+
| `GET /v1/buckets/app-common`, `app-features`, `app-config?watch=false` | once, at boot (blocking) | ordinary synchronous mocks — the app cannot boot without them |
12+
| `GET /v1/buckets/app-config?watch=true&version=N` | forever, from a background daemon thread | **async egress** — fires on the app's own schedule, not tied to any ingress testcase |
13+
| `SELECT ...` on MySQL | per request | ordinary synchronous mocks |
14+
15+
The background watch poll is the interesting part. Because it runs on a timer in
16+
its own thread, it does not line up one-to-one with the recorded testcases. A
17+
naive replay would fail: the app polls at replay time too, and the request
18+
(`?version=17`, `?version=18`, …) never matches a recorded one exactly.
19+
20+
Keploy's async-egress engine handles this. The lane declared in `keploy.yml`
21+
tells Keploy that this endpoint is async:
22+
23+
```yaml
24+
async:
25+
lanes:
26+
- name: config-watch
27+
type: http
28+
match:
29+
pathRegex: "^/v1/buckets/app-config$"
30+
matchQuery:
31+
watch: "true" # only the background watch polls, not the boot call
32+
volatileParams: ["version"] # the version query param varies every poll — treat as noise
33+
```
34+
35+
At replay the engine serves the recorded watch responses back to the poller
36+
independently of testcase ordering, treats the changing `version` param as
37+
shape-noise, and keep-alives the poller when there is nothing left to serve — so
38+
the app stays happy and the ingress tests still pass. At the end of replay Keploy
39+
prints an `async egress verdict` line (served / shape_flags / not_exercised).
40+
41+
## Endpoints
42+
43+
- `GET /health` — small health payload; runs `SELECT 1` against MySQL.
44+
- `GET /rules/{useCase}` — ordered rules for `(useCase, tenant)` read from MySQL.
45+
Requires headers `X-Tenant-Id` and `X-Agent-Id`.
46+
Example: `GET /rules/ORDER_FLOW` with `X-Tenant-Id: ACME`, `X-Agent-Id: 957`.
47+
48+
## Run it locally
49+
50+
Prerequisites: JDK 8, Maven, Docker, Go (for the config stub), and a Keploy
51+
build that includes the async-egress engine.
52+
53+
```bash
54+
# 1. dependencies
55+
docker compose up -d # MySQL 5.7 seeded from init.sql
56+
go run ./config-stub & # config service stub on :9100
57+
58+
# 2. build the app
59+
mvn -B clean package -Dmaven.test.skip=true
60+
61+
# 3. record
62+
sudo -E keploy record -c "java -jar target/async-config-poll.jar"
63+
# drive traffic, then Ctrl-C keploy:
64+
curl localhost:8080/health
65+
curl -H "X-Tenant-Id: ACME" -H "X-Agent-Id: 957" localhost:8080/rules/ORDER_FLOW
66+
67+
# 4. replay (deps down — Keploy serves everything from mocks)
68+
docker compose down
69+
sudo keploy test -c "java -jar target/async-config-poll.jar" --delay 20
70+
```
71+
72+
To make a watch poll land in the *middle* of a testcase at replay (so the async
73+
lane is actively exercised rather than drained between tests), lower the poll
74+
interval and widen the request window:
75+
76+
```bash
77+
WATCH_INTERVAL_MS=150 RULES_DELAY_MS=800 sudo -E keploy record -c "java -jar target/async-config-poll.jar"
78+
```
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module config-stub
2+
3+
go 1.21
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Command config-stub is a stand-in for a central config service. It backs the
2+
// app's boot-blocking config fetch and its background watch long-poll:
3+
//
4+
// - GET /v1/buckets/{name} -> current config (version 1)
5+
// - GET /v1/buckets/app-config?watch=true&version=N -> long-poll: returns the
6+
// NEXT version (N+1), simulating a config change on each watch poll.
7+
//
8+
// It is hit only during `keploy record`. At replay time Keploy serves the
9+
// recorded responses instead, so this stub does not need to be running.
10+
package main
11+
12+
import (
13+
"encoding/json"
14+
"log"
15+
"net/http"
16+
"strconv"
17+
"strings"
18+
)
19+
20+
func main() {
21+
http.HandleFunc("/v1/buckets/", func(w http.ResponseWriter, r *http.Request) {
22+
name := strings.TrimPrefix(r.URL.Path, "/v1/buckets/")
23+
q := r.URL.Query()
24+
25+
version := 1
26+
if q.Get("watch") == "true" {
27+
cur, _ := strconv.Atoi(q.Get("version"))
28+
version = cur + 1 // deliver the next version -> a "change" per poll
29+
}
30+
31+
w.Header().Set("Content-Type", "application/json")
32+
w.WriteHeader(http.StatusOK)
33+
_ = json.NewEncoder(w).Encode(map[string]interface{}{
34+
"name": name,
35+
"version": version,
36+
"keys": map[string]string{
37+
"feature.enabled": "true",
38+
},
39+
})
40+
})
41+
log.Println("config-stub listening on :9100")
42+
log.Fatal(http.ListenAndServe(":9100", nil))
43+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
services:
2+
mysql:
3+
# MySQL 5.7 (not 8.x): Spring Boot 1.5 manages MySQL Connector/J to 5.1.x,
4+
# which cannot speak MySQL 8's default caching_sha2_password auth plugin.
5+
image: mysql:5.7
6+
command: --default-authentication-plugin=mysql_native_password
7+
environment:
8+
MYSQL_ROOT_PASSWORD: rootpass
9+
MYSQL_DATABASE: ruledb
10+
MYSQL_USER: app
11+
MYSQL_PASSWORD: app
12+
ports:
13+
- "3306:3306"
14+
volumes:
15+
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
16+
# mysql:5.7 can trip an fd-limit config check on some Docker hosts; pin a
17+
# sane nofile limit so mysqld starts and stays up under connection load.
18+
ulimits:
19+
nofile:
20+
soft: 65535
21+
hard: 65535

async-config-poll/init.sql

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
-- Rule-engine schema + seed for the (ORDER_FLOW, ACME) use case.
2+
-- The app reads these rows over the MySQL wire; Keploy captures that traffic
3+
-- as mocks and serves it back on replay.
4+
CREATE DATABASE IF NOT EXISTS ruledb;
5+
USE ruledb;
6+
7+
CREATE TABLE IF NOT EXISTS rules (
8+
rule_id BIGINT PRIMARY KEY,
9+
use_case VARCHAR(64) NOT NULL,
10+
tenant VARCHAR(64) NOT NULL,
11+
constraint_expr TEXT NOT NULL,
12+
rule_type VARCHAR(16) NOT NULL,
13+
INDEX idx_uc_tenant (use_case, tenant)
14+
);
15+
16+
CREATE TABLE IF NOT EXISTS rule_actions (
17+
id BIGINT AUTO_INCREMENT PRIMARY KEY,
18+
rule_id BIGINT NOT NULL,
19+
basic_action VARCHAR(255) NOT NULL,
20+
action_details TEXT NOT NULL,
21+
seq INT NOT NULL,
22+
INDEX idx_rule (rule_id)
23+
);
24+
25+
INSERT INTO rules (rule_id, use_case, tenant, constraint_expr, rule_type) VALUES
26+
(14,'ORDER_FLOW','ACME','status == "COMPLETED" && type.equals("CHECKOUT")','POST'),
27+
(15,'ORDER_FLOW','ACME','status == "COMPLETED" && type.equals("PAYMENT")','POST'),
28+
(16,'ORDER_FLOW','ACME','status == "COMPLETED" && type.equals("SHIPMENT")','POST'),
29+
(17,'ORDER_FLOW','ACME','status == "IN_PROGRESS" && type.equals("REFUND")','POST'),
30+
(18,'ORDER_FLOW','ACME','status == "COMPLETED" && type.equals("FULFILLMENT")','PRE');
31+
32+
INSERT INTO rule_actions (rule_id, basic_action, action_details, seq) VALUES
33+
(14,'com.example.rules.handlers.ForceSyncHandler','{}',1),
34+
(15,'com.example.rules.handlers.PaymentTaskHandler','{}',1),
35+
(15,'com.example.rules.handlers.NotifyHandler','{"optional":"true","channel":"email"}',2),
36+
(16,'com.example.rules.handlers.ValidateHandler','{"optional":"true"}',1),
37+
(16,'com.example.rules.handlers.ShipmentHandler','{}',2),
38+
(17,'com.example.rules.handlers.RefundHandler','{}',1),
39+
(18,'com.example.rules.handlers.ImageSyncHandler','{"optional":"true"}',1);

async-config-poll/keploy.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Keploy config for the async-config-poll sample.
2+
#
3+
# The only non-default section is `async.lanes`. It declares the config-service
4+
# watch long-poll as async egress, so Keploy's async-egress engine records and
5+
# replays it independently of the ingress testcase ordering.
6+
#
7+
# Lane "config-watch":
8+
# - match.pathRegex : only the /v1/buckets/app-config endpoint
9+
# - matchQuery.watch : "true" -> only the background watch polls (the
10+
# one-time boot "get current version" call uses
11+
# watch=false and stays an ordinary blocking mock)
12+
# - volatileParams : ["version"] -> the version query param changes every
13+
# poll, so it is treated as shape-noise, not a mismatch
14+
async:
15+
lanes:
16+
- name: config-watch
17+
type: http
18+
match:
19+
pathRegex: "^/v1/buckets/app-config$"
20+
matchQuery:
21+
watch: "true"
22+
volatileParams: ["version"]

async-config-poll/pom.xml

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0"
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
5+
<modelVersion>4.0.0</modelVersion>
6+
7+
<!-- async-config-poll: a Spring Boot 1.5 / Java 8 rule-engine sample that
8+
demonstrates Keploy's async-egress engine.
9+
10+
The app serves two HTTP endpoints backed by MySQL (/health,
11+
/rules/{useCase}) and, in the background, long-polls a central config
12+
service for version changes (GET /v1/buckets/app-config?watch=true).
13+
That watch poll fires from a daemon thread on the app's own schedule —
14+
async relative to the ingress testcases — so Keploy records and
15+
replays it through the async-egress engine (lane "config-watch"). -->
16+
17+
<groupId>com.example</groupId>
18+
<artifactId>async-config-poll</artifactId>
19+
<version>1.0.0</version>
20+
<packaging>jar</packaging>
21+
22+
<parent>
23+
<groupId>org.springframework.boot</groupId>
24+
<artifactId>spring-boot-starter-parent</artifactId>
25+
<version>1.5.22.RELEASE</version>
26+
<relativePath/>
27+
</parent>
28+
29+
<properties>
30+
<java.version>1.8</java.version>
31+
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
32+
<start-class>com.example.asyncconfig.Application</start-class>
33+
</properties>
34+
35+
<dependencies>
36+
<!-- Jersey / JAX-RS REST layer. The jersey starter pulls
37+
spring-boot-starter-web (Tomcat); exclude Tomcat and run on
38+
embedded Jetty instead. -->
39+
<dependency>
40+
<groupId>org.springframework.boot</groupId>
41+
<artifactId>spring-boot-starter-jersey</artifactId>
42+
<exclusions>
43+
<exclusion>
44+
<groupId>org.springframework.boot</groupId>
45+
<artifactId>spring-boot-starter-tomcat</artifactId>
46+
</exclusion>
47+
</exclusions>
48+
</dependency>
49+
<dependency>
50+
<groupId>org.springframework.boot</groupId>
51+
<artifactId>spring-boot-starter-jetty</artifactId>
52+
</dependency>
53+
54+
<!-- MySQL primary datastore (rules are read from here). -->
55+
<dependency>
56+
<groupId>org.springframework.boot</groupId>
57+
<artifactId>spring-boot-starter-jdbc</artifactId>
58+
</dependency>
59+
<dependency>
60+
<groupId>mysql</groupId>
61+
<artifactId>mysql-connector-java</artifactId>
62+
</dependency>
63+
</dependencies>
64+
65+
<build>
66+
<finalName>async-config-poll</finalName>
67+
<plugins>
68+
<plugin>
69+
<groupId>org.springframework.boot</groupId>
70+
<artifactId>spring-boot-maven-plugin</artifactId>
71+
</plugin>
72+
</plugins>
73+
</build>
74+
</project>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.example.asyncconfig;
2+
3+
import org.springframework.boot.SpringApplication;
4+
import org.springframework.boot.autoconfigure.SpringBootApplication;
5+
6+
/**
7+
* async-config-poll — a small rule engine that serves /health and
8+
* /rules/{useCase} on port 8080 (backed by MySQL), fetches boot-blocking
9+
* config at startup, and then long-polls a config service in the background
10+
* for version changes (see {@link com.example.asyncconfig.config.ConfigWatchService}).
11+
*/
12+
@SpringBootApplication
13+
public class Application {
14+
public static void main(String[] args) {
15+
SpringApplication.run(Application.class, args);
16+
}
17+
}

0 commit comments

Comments
 (0)