dissaly runs a real gRPC system on one host while applying simulated latency, failures, resource caps, and scale reduction. It uses the reduced run to project the system at full size.
The repository contains:
- the student's code, running with real gRPC, real allocation, and real threads
- dissaly, which sits around it and models nodes, time, failures, and scaling
An assignment uses YAML for the system and protobuf plus Java for the implementation. A gRPC service starts the work.
The node is made small, so a design that would fail at 16 GiB can fail here at 16 MiB for the same reason. The failure belongs to the program under test.
bin/dissaly dev test # dissaly's own checks: every phase's acceptance criteria
bin/dissaly dev suite # the reference suite: gRPC systems, run the way a student runs them
bin/dissaly simulate dissaly/test/simulations/wordcount.yaml \
--cp build/test-classes --out build/wordcount.json
bin/dissaly bill build/wordcount.json
bin/dissaly compare build/a.json build/b.jsonThe repository includes the toolchain. The commands work on a laptop, in the devcontainer, and in a Codespace without downloading dependencies during the build.
An ordinary gRPC service starts from a .proto with no dissaly types in its API. An
adapter converts grpc-java's void map(Chunk, StreamObserver<Counts>) into a
value-returning method that a unit test can call directly.
public final class Mapper extends WorkerBase {
@Override protected Counts map(Chunk request) {
var counts = count(request.getText());
Dissaly.current().reveal("emitted", counts.size()); // silent in a bare test
return Counts.newBuilder().putAllCounts(counts).build();
}
}The signature contains no dissaly type. Remove the optional reveal call and the
service source still compiles without dissaly on its classpath.
What the call costs is declared in the simulation, under the file that runs it:
simulatedDuration:
dissaly/test/src/Mapper.java: { Map: { fixed: 2 refMs } }The unit is reference-node time. The interceptor sleeps
fixed * nodeFactor / k_time. The loader checks that a node runs the file and that
the class serves the named RPC.
Some durations only the running program knows, such as a backoff or a poll interval.
For those cases there is Dissaly.current().sleep(refMs). It uses the same unit and
the same k_time scaling. Waiting is not work, so it does not make a node busy.
Thread.sleep is different, and the verifier flags it.
Nodes communicate through gRPC. Fire-and-forget uses an Empty-returning method on
an async stub, so dissaly records its costs, failures, telemetry, and bytes like any
other call.
A handler calls a peer the same way the job does, by what it serves rather than by hostname, over a channel dissaly made:
var here = Dissaly.current();
Channel to = here.channelTo(here.peersServing("Worker").get(0));
return WorkerGrpc.newBlockingStub(to).map(request);The result is an io.grpc.Channel, and the call site remains plain gRPC. dissaly adds
the interceptor that records latency, byte counts, spans, failures, and retries.
The simulation file contains the nodes, failures, and timing. Code is referenced
by .java path, which lets separate designs use separate simulation files.
seed: 7
nodes:
master:
instance: m5.large
zone: eu-central-1a
runs: { dissaly.Job: dissaly/test/src/WordCountJob.java }
workers:
count: 6
prefix: w
instance: m5.large
zone: [eu-central-1a, eu-central-1b]
runs: { Counter: dissaly/test/src/Counter.java }
overrides:
w2: { memoryMb: 4 }
w5:
failures:
- { kill: true, at: 400 refMs }
input:
unit: line
count: 8000Exactly one node runs dissaly.Job, which starts the work. Failures are nested under
the node or RPC they affect, so the loader can validate their targets.
Every duration is reference-node time and has to say so. A bare 900 is refused,
and so is 900ms, because those are ambiguous between the simulated world and the
host.
The loader reports other errors with the source line: an unknown instance type, a
missing runs: path, a partition naming an absent node, or a misspelled key.
wordcount.yaml:14: retrying dissaly.t.Volley.Hit is refused — its .proto declares no
idempotency_level, so running it twice is not known to be safe. Declare 'option
idempotency_level = IDEMPOTENT;' on the rpc if it is, or write 'unsafe: true' here
if you mean to retry it anyway.
The simulation above runs its declared input. The following simulation declares a larger input, so dissaly shrinks the workload and nodes by the same factor:
scale: 5000The scale value identifies a model run. Job.Run receives a Workload and no
other scale information, so the workload size is explicit in the request.
wordcount-scaled.yaml seed 5 scaled 8,000 -> 40,000,000 records (x5,000), k_time 40
memoryMb = 0.0597 + 0.00187 * revealed.distinctKeys^1.013 (R2 1.0000, wobble 0.000)
wireMb = 0.0 + 0.000151 * records^0.952 (R2 1.0000, wobble 0.004)
diskMb REFUSED: over the lower half it grows as records^0.59
and over the upper half as records^0.87 ...
makespan REFUSED: its exponent moves by 0.66 between independent seed sets of the
same workload, which over a factor of 5000 is an error bar of x268 ...
observed projected
memoryMb 6.823 34.07 k +-x1.10
wireMb 0.460 2.63 k +-x1.04
Memory was attributed to distinct keys. Peak reducer memory follows vocabulary size, which grows more slowly than the record count. Fitting the law against that variable keeps the projection close to the direct run; fitting against records does not.
Two resources were refused because their measurements did not support a projection. The engine leaves a field empty and records the reason when its confidence is too low.
The engine replays the timeline with the observed call graph, projected durations, and each node's concurrency. Four calls into eight cores occupy one wave; thirty-two occupy four.
The plan travels in the trace, so projected = f(observed) is recomputable by
whoever reads it, and is cached against the simulation and the code it profiles.
dissaly's numbers mean something only if the code stays inside the simulated world.
A handler that reads System.nanoTime gets the host's time rather than the
compressed clock; one that writes a real file bypasses the disk model; one that
hands its work to the common pool is charged to nobody.
The verifier reads the project's compiled classes before the run. It flags rather than refuses affected measurements, lets the run continue, and records the nodes:
trust: 4 nodes report figures that are not reliable
w0, w1, w2
each reads the real clock, so its timeline cannot be projected
Peeker.java:19 System.nanoTime() in map
Peeker.java:22 System.nanoTime() in map
spiller
writes to a real disk, which the disk model never sees; its disk figure
is a lower bound
Scribbler.java:21 Files.writeString() in map
The run continues, but these measurements are wrong.
The flags go on the nodes in the trace, and above scale: 1 they sit next to the
projection they undermine.
Generated code is skipped, including protobuf's superclass and grpc-java's own
@GrpcGenerated. A constant is not shared state. static final Map M = new HashMap<>() is one map for eight nodes and is flagged, while static final String[] WORDS = {"a", "the"} is a table and is not. What the call sites do not
say, the declarations do: System::nanoTime appears in no instruction anywhere,
only in a bootstrap argument, and a class that extends Thread starts itself
through a method on itself.
Unseeded Random and identity-hash iteration order are allowed because runs are not
reproducible. Raw threads are also allowed. Work outside the node's pool is
unattributed, and the trace records that fact.
The bill prints three buckets separately because each describes a different cost. Replication triples capacity and adds to build. What broke is counted under them and never priced: how many calls went unanswered is a fact about the run, what that costs an organisation is not a number this course has.
model quantity
build services carried 0.1180 service-months CHF 29.50
capacity the nodes, for the period - CHF refused
its exponent moves by 0.793 between independent seed sets of the same workload.
Over a factor of 6, the error bar is x4.1.
consumption same-zone egress 67.81 GB CHF 0.34
consumption intermediate data on disk 0.0006000 GB-month CHF 0.00
One second of the simulated clock is billed as a day of operation, so a two-minute run prices as four months of a system rather than as four centimes of one.
A run above scale: 1 produces an observed bill and a bill for the modeled size.
The engine refuses quantities whose measurements cannot support projection. Capacity
depends on the timeline, which is the noisiest quantity dissaly measures.
The result lists byte and storage costs directly. Capacity remains absent when the timeline is too uncertain to project.
Prices are course data and live in prices/, outside the simulator. What a node costs to rent is deliberately not there: that belongs to the instance catalogue, beside its vCPUs and its memory.
The suite contains gRPC systems run through
the command line a student types and asserted against the trace it wrote. The
systems compile against build/dissaly.jar and the vendored gRPC alone, and every
assertion reads the trace JSON off disk.
Nine of them are systems. Four test the engine rather than the systems: a projection checked against a run at full size, a matrix that varies the system size independently of the data, two workloads the engine has to refuse, and one ladder fitted at four levels of instrumentation.
The reference suite has found bugs that the phase suites did not cover. One example:
a job could not run at NO_PAYLOAD because a span recorded its
result as null when payloads were off and span details are a concurrent map, which
rejects a null value.
CI runs everything twice, on a plain runner and inside the image a Codespace boots, and then compares the two traces. What has to agree is structure and attribution. The measurements are allowed to differ.
The system includes one in-process server and one executor per node, gRPC interceptors around every call, simulations, scaling, trust checks, billing, and a reference suite of gRPC systems in CI.
| real concurrency | each node's pool is its vCPU model, so four calls into a two-vCPU node really do queue |
| a compressed clock | every declared duration divided by k_time, calibrated per host, with sub-floor costs owed rather than lost |
| a network | latency by zone, jitter, loss, partitions, and a dead node, a cut link and a lost packet are one event from the caller |
| three-channel telemetry | events, spans that carry a parent across the RPC boundary, and dense series, with every call's real argument and real result |
| memory, measured twice | allocation per node, exactly; and a retained-heap walk, because only one of those decides an out-of-memory |
| failures | kill, freeze, degrade, spot reclaim with notice, partition, restart, and per-RPC status, slowdown, or dropped request |
| retries | refused unless the .proto declares the method idempotent or the simulation sets unsafe: true |
| two scales, per measurement | what happened, and what it is a model of, with an error bar or with a reason it is absent |
| dissaly's own cost, excluded | everything dissaly does on a node's threads is metered and subtracted, so what is reported is the program's |
| trust markers | real clocks, real files, real sockets, shared statics and unattributed threads, found in the compiled classes at the line they were written on, flagged, never refused |
| a bill, at both scales | three buckets over the quantities the run produced, what broke counted beside them and never priced, and at full scale a capacity line absent with a reason, because it depends on the one thing the engine would not project |
The reveal calls do affect the raw measurement. A thousand
calls per handler move the unsubtracted memory exponent by 0.026, while
the reported one by 0.0004. The law a student's code is projected by does not
depend on how much they instrumented it.
dissaly/src/dissaly/api/ what a handler may say to dissaly and all it can reach
dissaly/src/dissaly/runtime/ the nodes, the servers, the two interceptors
dissaly/src/dissaly/trace/ the three-channel recorder and the trace it writes
dissaly/src/dissaly/time/ the compressed clock and failure timing
dissaly/src/dissaly/res/ instance types, the heap walk, dissaly's own meter
dissaly/src/dissaly/scale/ the probe grid, fitted laws, solves, and refusals
dissaly/src/dissaly/sim/ a system and its failure conditions as data
dissaly/proto/dissaly/ dissaly.Job — the one service dissaly ships
dissaly/src/dissaly/verify/ what makes a number stop meaning what it says
dissaly/src/dissaly/price/ cost buckets and excluded quantities
dissaly/src/dissaly/cli/ dissaly simulate | bill | compare
dissaly/test/ every phase's acceptance criteria, run by `dissaly dev test`
tests/ the reference suite: gRPC systems, run by `dissaly dev suite`
bin/dissaly the CLI: build the simulator, then run it
prices/ course data — what egress costs, what being late costs
vendor/ grpc 1.83.1, protobuf 4.36.0, protoc for two platforms
An assignment compiles against build/dissaly.jar and the vendored jars alone, never
against these sources.
A handler is debugged on its own, in plain JUnit, with nothing simulating anything. See dissaly/test/junit/HandlerTest.java.
The manual is a Mintlify site in docs/. It covers the quickstart, simulation grammar, trace format, scale engine, bill, and viewer.
bin/dissaly serve docs # preview at http://localhost:3000
bin/dissaly dev docs check # the manual's own checkdissaly dev docs check checks the manual. This repository ships worked
solutions to the coursework, and the manual must not contain them. Every page is
scanned against a rule set in docs-check/, and the check tests
itself before it scans. Every rule has a sample it must catch and a nearby sample
it must not. It also checks the site's own shape: every page reachable, every
internal link resolving. It runs in CI on every push.
It lives outside docs/. A non-page file under a Mintlify docs directory is
served as a static asset, and this check's fixtures contain worked assignment prose
on purpose. docs/ holds only what is meant to be published.