Skip to content

Spaces and flow state

A space is one imposter partitioned by a flow id, so concurrent tests can share a single imposter without seeing each other's stubs, scenario state or recorded traffic. The engine resolves the flow id from each incoming request — normally a header your system under test already propagates, like a tenant or correlation id — and keeps everything scoped to it separate.

This is what lets a suite run in parallel against one engine instead of one imposter per test.

Configuring the flow id

An imposter opts in by declaring where the flow id comes from:

Imposter imp = rift.create(imposter("orders")
        .record()
        .flowState(inMemoryFlowState().flowIdFromHeader("X-Tenant"))
        .stub(onGet("/orders").willReturn(okJson("[]"))));

Without an explicit source the engine falls back to the imposter port, which means every request resolves to the same flow — so space-scoped stubs would never match. The SDK warns when it sees an imposter that uses spaces but declares no header source, because that combination is almost always a mistake rather than a choice.

redisFlowState(url) swaps the in-memory store for Redis when flow state has to outlive a single engine or be shared across several.

Working in a space

imposter.space(flowId) is the flow-scoped view of the imposter:

Space acme = imp.space("acme");

acme.addStub(onGet("/orders").willReturn(okJson("[{\"id\":1}]")));  // only for X-Tenant: acme
List<RecordedRequest> seen = acme.recorded();                        // only acme's traffic
acme.verify(onGet("/orders"), times(1));                             // only acme's calls
acme.delete();                                                       // drop this flow's state

Everything on a Space — stubs, recorded requests, verification — is confined to that flow. A stub added to the default view still serves every flow; a stub added to a space serves only its own.

The DSL can place a stub in a space at creation time instead:

rift.create(imposter("orders")
        .flowState(inMemoryFlowState().flowIdFromHeader("X-Tenant"))
        .stub(onGet("/orders").willReturn(okJson("[]")).inSpace("acme")));

Flow-scoped scenario state

Scenarios are per-flow too, so two tests can drive the same state machine to different points concurrently:

imp.scenarios().setState("checkout", "PAID", "acme");   // acme's scenario only
imp.scenarios().setState("checkout", "NEW");            // the default flow
List<Scenarios.State> acmeStates = imp.scenarios().list("acme");

The three-argument setState(name, state, flowId) and list(flowId) are the flow-scoped forms; the shorter overloads operate on the default flow.

Key/value flow state

imposter.flowState(flowId) reaches the same per-flow store the engine's scripts read and write:

FlowState state = imp.flowState("acme");
state.put("cartId", "c-123");
Optional<JsonValue> cartId = state.get("cartId");
state.delete("cartId");

Cursor reads over the journal

Polling recorded() re-reads everything every time. The cursor API reads only what is new:

RecordedPage page = imp.recordedPage();          // baseline: everything retained, plus a cursor
long cursor = page.nextIndex().orElse(0);

RecordedPage next = imp.recordedSince(cursor);   // only what arrived since
cursor = next.nextIndex().orElse(cursor);

nextIndex() is opaque — pass it back verbatim and never derive one from a list offset. An empty nextIndex() means the engine cannot offer a cursor; keep the one you hold and poll again. A present 0 is a real cursor meaning "nothing recorded yet", not an absent one.

truncated() says the retention cap evicted entries you had not seen — a hole in your view, so re-baseline with recordedPage(). Deleting traffic you asked to delete is not a hole, so a cursor held across a clearRecorded(...) is never flagged.

Spaces get the same cursor, pre-scoped to their flow:

Space acme = imp.space("acme");
RecordedPage acmePage = acme.recordedPage();
RecordedPage acmeNext  = acme.recordedSince(acmePage.nextIndex().orElse(0));

The index is still the imposter's journal index, so a space cursor and an imposter cursor are the same number and interchangeable. A space tail's cursor therefore advances even while only other flows are recording — the engine moves it past entries the filter rejected, so a filtered tail never re-scans a range it has already judged, and an empty page with a moved cursor is normal.

Filtering with MatchClause

MatchClause filters a journal read engine-side, so the page costs only what it returns:

imp.recordedPage(MatchClause.method("POST"), MatchClause.path("/orders"));
imp.recordedSince(cursor, MatchClause.header("X-Tenant", "acme"));
imp.clearRecorded(MatchClause.flowId("acme"));      // scoped delete
Clause Matches Notes
MatchClause.header(name, value) a request header name case-insensitive, value exact
MatchClause.flowId(id) the engine-resolved flow id what a Space injects for you
MatchClause.method(verb) the request method case-sensitivemethod("get") does not match a GET
MatchClause.path(path) the bare request path exact, still percent-encoded, and without the query string

method and path require a rift engine ≥ 0.15.0. Against an older engine the clause is rejected with a 400 surfaced as InvalidDefinition — it never quietly returns an unfiltered page, because a filter that silently widened would mix in exactly the traffic it was meant to separate.

Clauses AND together, and they are applied after the cursor cut. The grammar is closed on purpose: only the four forms above are constructible, so a clause that the engine would reject — or one that could never match, like a path carrying a query string — fails at construction rather than becoming a filter that quietly returns nothing forever.

Filtered reads need server-side filtering, and every transport can reach it — the in-process embedded transport delegates them to its own admin server rather than evaluating clauses client-side. A transport that could do neither would refuse with UnsupportedOperationException rather than answer with the entries you asked to exclude; widening a filter is never the fallback.

Flow ids are never blank

Every flow-scoped call rejects a blank flow id with IllegalArgumentExceptionspace(""), flowState(" "), scenarios().setState(name, state, ""), scenarios().list(""), and MatchClause.flowId("") alike.

A blank id is not "the default flow". It is a distinct, silently-wrong partition: stubs written there never match real traffic, reads come back mysteriously empty, and on the flow-state delete path it would target the wrong partition destructively. Because the failure is quiet and looks like "the feature doesn't work", it is rejected at the facade boundary rather than sent to the wire.

See also

  • Event stream — push notification of the same recorded requests, with flowId on each event.
  • API design — the full reference for Space, MatchClause and the cursor contract.