Skip to content

Commit 49cf71c

Browse files
committed
chore(antithesis): Frechet Distance and intake rework
This commit introduces the Frechet distance calculation into the differential scenario, allowing us to determine if ADP-on and ADP-off emit equivalent series for all input sequences. The distance calculation has been chosen to be mathematically defensible and also to have the right visual check for our latent understanding of what 'equivalent' series look like in practice.
1 parent aaebc86 commit 49cf71c

26 files changed

Lines changed: 2887 additions & 485 deletions

test/antithesis/harness/src/lib.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
33
use std::time::Duration;
44

5+
use serde::{Deserialize, Serialize};
6+
57
pub mod config;
68
pub mod contexts;
79
pub mod dogstatsd;
@@ -13,3 +15,14 @@ pub mod rand;
1315
/// How long a context may take to appear on both lanes before it counts as a
1416
/// divergence.
1517
pub const ACCEPTABLE_FLUSH_DELAY: Duration = Duration::from_secs(30);
18+
19+
/// Which differential check posted to an oracle. The intake picks its assertion name from this, so a
20+
/// divergence under load reports apart from one that outlives the drain.
21+
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
22+
#[serde(rename_all = "snake_case")]
23+
pub enum Phase {
24+
/// The check that runs while load is still arriving.
25+
Eventually,
26+
/// The check that runs once load has drained.
27+
Finally,
28+
}

test/antithesis/intake/README.md

Lines changed: 155 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@ are invasive.
1010

1111
This document is the specification for an abstract DogStatsD Agent. We assert
1212
that for any given input stream ADP emits to the intake data that is correctly
13-
shaped and, in a future update, that the aggregation model of ADP is accurate to
14-
the reference implementation of Datadog Agent DogStatsD.
13+
shaped and that the aggregation model of ADP is accurate to the reference
14+
implementation of Datadog Agent DogStatsD.
1515

16-
The differential scenario adds one narrower oracle. For the same generated
16+
The differential scenario adds two narrower oracles. For the same generated
1717
configuration and workload, ADP and the Datadog Agent must eventually report the
18-
same metric contexts.
18+
same metric contexts, and each shared context must carry the same aggregation
19+
curve on both lanes.
1920

2021
## On Decoding and Correctness
2122

@@ -50,6 +51,27 @@ that will happen -- the goal is to _find faults_. Many payloads below, for
5051
example Pyld26, are vacuous on a properly functional Datadog Agent and will only
5152
fire in the prescence of a misbehaving Agent.
5253

54+
## Endpoints
55+
56+
This intake supports many endpoints. The following table lists them, their
57+
supported methods and, briefly, their purpose.
58+
59+
| Method | Path | Purpose |
60+
|--------|----------------------------------------|----------------------------------------------------------------------------|
61+
| POST | `/api/v2/series` | v2 metric series |
62+
| POST | `/api/intake/metrics/v3/series` | v3 native series |
63+
| POST | `/api/beta/sketches` | Distribution sketches |
64+
| POST | `/api/v1/events_batch` | event batches, currently catch and discard |
65+
| POST | `/api/v1/events` | JSON events, currently catch and discard |
66+
| POST | `/intake/` | events and metadata, currently catch and discard |
67+
| POST | `/api/v1/check_run` | service checks, currently catch and discard |
68+
| GET | `/api/v1/validate` | Datadog Agent connectivity probe |
69+
| POST | `/antithesis/metrics/contexts` | Contexts oracle: computes symmetric difference of lane contexts, see below |
70+
| POST | `/antithesis/metrics/frechet_distance` | Series oracle: computes Frechet distance of lane time series, see below |
71+
| GET | `/contexts?n=N` | Serves the load generator bounded `N` contexts |
72+
73+
All other paths respond with a 404 for every method.
74+
5375
## Properties
5476

5577
### Payloads
@@ -143,27 +165,142 @@ These properties hold exclusively for v3:
143165
| Pyld58 | Origin | OriginInfo Triples | `len(dictOriginInfo)` is a multiple of 3 |
144166
| Pyld59 | Metadata | Resources Even | payload `Metadata.resources` has even length |
145167

146-
### Differential context capture
168+
### Differential Equivalence
147169

148-
The differential scenario uses the same intake binary for both lanes. Both take the series API the
149-
timeline sampled, so a lane splitting off the other's encoding is a finding:
170+
The differential scenario compares ADP and Datadog Agent on the same input
171+
stream, confirming that they are "roughly equivalent". What this means varies by
172+
the precise check, discussed below. The differential scenario uses the same
173+
intake for both lanes. Each check POSTs its parameters and the intake makes the
174+
Antithesis SDK calls, see below.
150175

151-
- Datadog Agent lane: `POST /api/v2/series` or `POST /api/intake/metrics/v3/series`, plus `POST /api/beta/sketches`
152-
- ADP lane: the same two series routes, plus `POST /api/beta/sketches`
153-
- Private control API: `GET /antithesis/metrics/agent`
154-
- Private control API: `GET /antithesis/metrics/adp`
176+
Both lanes take the series API the timeline sampled, so a lane that ships the
177+
other's encoding is a finding rather than a configured difference.
178+
179+
#### Contexts
155180

156181
For context equivalence, a metric context is:
157182

158183
- metric name
159184
- canonical tag list
160185
- metric type
161186

162-
The intake folds each captured metric down to its canonical context and stores the
163-
deduplicated set per lane, but it does not compare them. The control API returns those context
164-
sets. The differential workload command fetches both sets and owns the Antithesis assertion for
165-
eventual equivalence.
187+
The intake exposes `/antithesis/metrics/contexts`. A POST to this endpoint
188+
computes the [symmetric
189+
difference](https://en.wikipedia.org/wiki/Symmetric_difference) of the observed
190+
contexts per-lane to that point. If a context C enters on lane A at time T-0 it
191+
will be emitted for all subsequent times, even if C never enters on lane A
192+
again. _Contexts do not expire and we do not tally how often contexts have
193+
arrived._ Call the symmetric difference `D`. Let `age` be the difference between
194+
the current time -- from intake's frame of reference -- and the timestamp that
195+
context first ingressed with. We claim that:
196+
197+
* _eventually_ for every member `m` in `D` `age <= acceptable_flush_delay`
198+
* _finally_ `D == {}` after waiting for a period of `acceptable_flush_delay` once load is quiescent
199+
200+
The POST body sets calculation parameters, which are:
201+
202+
* `acceptable_flush_delay` -- number of seconds before which both lanes are allowed to diverge
203+
* `phase` -- `eventually` or `finally`, which check posted
204+
205+
The `phase` picks the assertion name, either
206+
`differential.contexts_eventually_equivalent` or
207+
`differential.contexts_finally_converged`. It does not change the condition. The
208+
two phases claim different things and a report that merged them could not tell a
209+
lane that diverges under load from one that never converges.
210+
211+
These are transmitted by eventually/finally checks and are a matter of scenario
212+
configuration, ultimately.
213+
214+
#### Series
215+
216+
The concern of this section is the equivalence of time series of a context,
217+
which we'll call 'series' for shorthand. Our goal is to demonstrate that both
218+
lanes, if given the same input stream, _aggregate_ to an equivalent
219+
aggregation. Implied in this are two concepts, first, the operations by which
220+
aggregation happens per kind and, second, the definition of equivalence.
221+
222+
Points are stored raw, per lane, with a `seq` number to distinguish points that
223+
arrive at the same time interval. Conceptually they are stored as tuples:
224+
225+
`(name, tagset, kind, timestamp, seq, interval, value)`
226+
227+
`timestamp` is the time recorded from the ingress frame of reference. Recording
228+
from intake's frame of reference subjects stored points to network jitter
229+
effects, which we wish to avoid. For convenience we do not store any known self-telemetry, so for
230+
instance Datadog Agent lane's `datadog.*` is not stored.
231+
232+
Queries over the point storage are done in terms of a bucketing width `w`, a
233+
fold operation per `kind` and a 'resubmit' rule to break ties on a timestamp:
234+
keep last, keep first by `seq` or summation. Queries are executed per-lane, that
235+
is, a query must be made over one lane's store and then the other. Queries are
236+
executed like so:
237+
238+
0. Collapse points sharing a timestamp by the resubmit rule.
239+
1. Assign each point to a bucket `k = floor(timestamp / w)`.
240+
2. Fold bucket points by the kind's fold operation, which are:
241+
* `count` -- `sum`
242+
* `rate` -- `sum(value * interval) / sum(interval)`
243+
* `gauge` -- last by timestamp
244+
* `sketch` -- dd-sketch merge, then projection to scalar series: count, sum, min, max, p75, p95 and p99
245+
* `other` -- none, drop
246+
3. Finally, buckets without values are filled like so:
247+
* `count` -- 0-valued
248+
* `rate` -- 0-valued
249+
* `gauge` -- carry forward previous value
250+
* `sketch` -- count series is 0-valued, quantile series are not emitted
251+
252+
Note, for sketches we require that both lanes maintain the same bin
253+
quantization. We consider this a difference if they do not, that is, a failure
254+
of equivalence.
255+
256+
The equivalence comparison is then done like so. First, truncate both series to
257+
the range both lanes could have contributed to so far:
258+
259+
```
260+
k_start = max(first bucket on A, first bucket on B)
261+
k_end = floor(min(newest_A, newest_B) / w) - 1
262+
```
263+
264+
Note that `k_end` drops a bucket to avoid reading out buckets that are still
265+
filling. Distance `d` is:
266+
267+
`d(a,b) = |b-a| / max(|a|,|b|)`
268+
269+
where `a` is value of lane A for a bucket and `b` is the value of lane B for
270+
that same bucket, with `d(x,x) = 0` by definition. The Fréchet measure is
271+
defined over pairs of buckets, one from each lane. Let `k` index lane A's
272+
buckets and `k'` lane B's, running from `k_start` to `k_end`. Then:
273+
274+
```
275+
F(k_start, k') = d(A_k_start, B_k')
276+
F(k, k') = max( d(A_k, B_k'), min(F(k-1,k'), F(k-1,k'-1), F(k,k'-1)) )
277+
```
278+
279+
A pair is admissible only when `|k - k'| <= W` where `W` is the 'leash width' in
280+
buckets. An inadmissible pair is not present in the calculation. Note that `F(k,
281+
k')` is not the distance between buckets `k` and `k'` it is the running
282+
best-so-far result, that is, of the walks that were possible to reach `k` and
283+
`k'` what is the smallest required 'leash'? We say that both lanes are
284+
equivalent if `min(F(k_end, k')) < equivalence_threshold` for all `k'`.
285+
286+
This means then that `W` and `equivalence_threshold` have outsized influence on
287+
the calculation. As of this writing we hold `W=1` and
288+
`equivalence_threshold=0.02` until such time as empirical results suggest
289+
different values are warranted.
290+
291+
The intake exposes `/antithesis/metrics/frechet_distance`. A POST to this
292+
endpoint runs the query described above for both lanes and makes necessary
293+
Antithesis SDK calls. The POST body sets distance calculation parameters, which
294+
are:
295+
296+
* `bucket_width` -- `w` from above, the bucketing width in seconds
297+
* `leash_width` -- `W` from above, the 'leash' width in buckets
298+
* `equivalence_threshold` -- the value `min(F(k_end, k'))` is compared with
299+
* `phase` -- `eventually` or `finally`, which check posted
300+
301+
As with contexts, the `phase` picks the assertion name, either
302+
`differential.series_eventually_equivalent` or
303+
`differential.series_finally_converged`.
166304

167-
The context oracle intentionally does not assert aggregate values, sketch values, event payloads, or
168-
service-check payloads. Those remain covered by the normal workload generation and payload structural
169-
assertions rather than by the context-equivalence check.
305+
These are transmitted by eventually/finally checks -- similar to how context
306+
above works -- and are a matter of scenario configuration, ultimately.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Seeds for failure cases proptest has generated in the past. It is
2+
# automatically read and these particular cases re-run before any
3+
# novel cases are generated.
4+
#
5+
# It is recommended to check this file in to source control so that
6+
# everyone who runs the test benefits from these saved cases.
7+
cc 8dab1f880af0b8d09273c30ea2ba9c912a839310f0d2fec84c782513de652a78 # shrinks to (a, b) = ([0.0, -895704.2304703512], [-12133.999497634417, -670314.8510917522]), w = 1
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Seeds for failure cases proptest has generated in the past. It is
2+
# automatically read and these particular cases re-run before any
3+
# novel cases are generated.
4+
#
5+
# It is recommended to check this file in to source control so that
6+
# everyone who runs the test benefits from these saved cases.
7+
cc 3dea5a08b32349942ce8d6b744c2150621b6b50af092a8dad5a4a7c56e060ecd # shrinks to mut parts = [(1, 649.8835394053948, [(1, 1)]), (1, 151.307057744116, [(1, 1)]), (1, 262.37966517249293, [(1, 1)])]

test/antithesis/intake/src/bin/intake.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ mod unix_intake {
9898
adp_result.context("ADP-target intake server stopped unexpectedly")?;
9999
Ok(())
100100
} else {
101+
// Single-lane mode. The general scenario runs this way and has no differential to void.
102+
// The differential oracles refuse to answer here rather than compare a lane with itself.
101103
let listener = TcpListener::bind(&config.listen_addr)
102104
.await
103105
.context("Failed to bind HTTP intake listener.")?;

0 commit comments

Comments
 (0)