Skip to content

Commit cc85766

Browse files
authored
enhancement: Add support for arrays to templated_json payload generator (#1799)
1 parent 96064ee commit cc85766

8 files changed

Lines changed: 264 additions & 16 deletions

File tree

examples/lading-templated.yaml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
generator:
2+
- file_gen:
3+
traditional:
4+
seed: [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53,
5+
59, 61, 67, 71, 73, 79, 83, 89, 97, 101, 103, 107, 109, 113, 127, 131]
6+
path_template: "/path/to/logs/part.%NNN%.json"
7+
rotate: true
8+
duplicates: 3
9+
bytes_per_second: 100MiB
10+
variant:
11+
templated_json:
12+
template_path: "examples/templates/templated_json_features.yaml"
13+
maximum_bytes_per_file: 100MiB
14+
maximum_prebuild_cache_size_bytes: 1GiB
15+
16+
blackhole:
17+
- tcp:
18+
binding_addr: "0.0.0.0:8080"
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Approximate equivalent of the built-in Json generator.
2+
#
3+
# The template system cannot produce byte-identical output because:
4+
#
5+
# - id and name are u64, but !range uses i64, restricting the
6+
# upper bound to i64::MAX (roughly half the u64 range).
7+
#
8+
# - !object emits fields alphabetically (BTreeMap); Json serializes in
9+
# declaration order (id, name, seed, byte_parade).
10+
11+
generator:
12+
!object
13+
id: !range { min: 0, max: 9223372036854775807 }
14+
name: !range { min: 0, max: 9223372036854775807 }
15+
seed: !range { min: 0, max: 65535 }
16+
byte_parade: !array
17+
length: [0, 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048]
18+
element: !range { min: 0, max: 255 }

examples/templates/bench_templated_json.yaml renamed to examples/templates/templated_json_features.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# !weighted - probability-weighted random selection
1313
# !range - random integer sampled from the closed interval [min, max]
1414
# !format - format string; {} placeholders filled by sub-generators
15+
# !array - generate a JSON array with generator-valued elements
1516
# !object - generate a JSON object with generator-valued fields
1617
# !with - bind one or more generated values into a local scope
1718
# !var - read back a value that was bound by an enclosing !with
@@ -71,6 +72,9 @@ definitions:
7172
request_id_suffix:
7273
!range { min: 100000, max: 999999 }
7374

75+
flag_words:
76+
!choose ["one", "two", "three", "four", "five", "six", "seven", "eight"]
77+
7478
generator:
7579
!with
7680
bind:
@@ -121,6 +125,16 @@ generator:
121125
- !var svc
122126
- !reference request_id_suffix
123127

128+
flags:
129+
!array
130+
length: { min: 0, max: 3 }
131+
element: !reference flag_words
132+
133+
bytes:
134+
!array
135+
length: { min: 1, max: 20 }
136+
element: !range { min: 0, max: 255 }
137+
124138
# Nested !with: introduces a new binding (env) visible only inside its body
125139
metadata:
126140
!with
@@ -136,3 +150,4 @@ generator:
136150
args:
137151
- !var pod
138152
- !var env
153+

lading_payload/README.templated_json.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ The config DSL in `opw_tools::json_generator` supports these YAML tags:
7070
- `!with`: bind generated values to variables, then evaluate `in`
7171
- `!var`: read a bound variable
7272
- `!timestamp`: deterministic monotonically-advancing UTC timestamp (whole-second RFC-3339)
73+
- `!array`: generate a JSON array; element count is fixed, a uniform random range, or chosen from a set
7374

7475
## Example template
7576

@@ -115,6 +116,11 @@ generator:
115116
args:
116117
- !var svc
117118
- !range { min: 1, max: 2000 }
119+
data: !array
120+
length: { min: 4, max: 32 }
121+
# or: length: 3
122+
# or: length: [1, 2, 4, 8, 16]
123+
element: !range { min: 0, max: 255 }
118124
```
119125

120126
This produces newline-delimited JSON like:
@@ -137,3 +143,4 @@ For future work:
137143

138144
- Strings of bounded random length range pulled from configurable text generators (hexadecimal,
139145
chosen words, etc).
146+
- Timestamps with sub-second fractions, different increment ranges, other configurations.

lading_payload/benches/templated_json.rs

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,23 +18,26 @@ use rand::{SeedableRng, rngs::SmallRng};
1818

1919
const MIB: usize = 1_048_576;
2020

21-
fn template_path() -> &'static Path {
22-
Path::new(concat!(
23-
env!("CARGO_MANIFEST_DIR"),
24-
"/../examples/templates/bench_templated_json.yaml"
25-
))
26-
}
21+
const TEMPLATE_BUILTIN_PATH: &str = concat!(
22+
env!("CARGO_MANIFEST_DIR"),
23+
"/../examples/templates/templated_json_builtin.yaml"
24+
);
25+
26+
const TEMPLATE_FEATURES_PATH: &str = concat!(
27+
env!("CARGO_MANIFEST_DIR"),
28+
"/../examples/templates/templated_json_features.yaml"
29+
);
2730

2831
fn templated_json_setup(c: &mut Criterion) {
29-
let path = template_path();
32+
let path = Path::new(TEMPLATE_FEATURES_PATH);
3033
c.bench_function("templated_json_setup", |b| {
3134
b.iter(|| TemplatedJson::from_path(path).expect("failed to load template"));
3235
});
3336
}
3437

35-
fn templated_json_throughput(c: &mut Criterion) {
36-
let path = template_path();
37-
let mut group = c.benchmark_group("templated_json_throughput");
38+
fn templated_json_throughput(c: &mut Criterion, path: &str, group_name: &str) {
39+
let path = Path::new(path);
40+
let mut group = c.benchmark_group(group_name);
3841
for size in &[MIB, 10 * MIB, 100 * MIB] {
3942
group.throughput(Throughput::Bytes(*size as u64));
4043
group.bench_with_input(BenchmarkId::from_parameter(size), size, |b, &size| {
@@ -55,6 +58,22 @@ fn templated_json_throughput(c: &mut Criterion) {
5558
group.finish();
5659
}
5760

61+
fn templated_json_builtin_throughput(c: &mut Criterion) {
62+
templated_json_throughput(
63+
c,
64+
TEMPLATE_BUILTIN_PATH,
65+
"templated_json_builtin_throughput",
66+
);
67+
}
68+
69+
fn templated_json_features_throughput(c: &mut Criterion) {
70+
templated_json_throughput(
71+
c,
72+
TEMPLATE_FEATURES_PATH,
73+
"templated_json_features_throughput",
74+
);
75+
}
76+
5877
criterion_group!(
5978
name = setup_benches;
6079
config = Criterion::default()
@@ -68,7 +87,7 @@ criterion_group!(
6887
config = Criterion::default()
6988
.measurement_time(Duration::from_secs(30))
7089
.warm_up_time(Duration::from_secs(1));
71-
targets = templated_json_throughput,
90+
targets = templated_json_builtin_throughput, templated_json_features_throughput,
7291
);
7392

7493
criterion_main!(setup_benches, throughput_benches);

lading_payload/src/templated_json/config.rs

Lines changed: 93 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,95 @@ pub(super) enum Generator {
136136
/// !timestamp
137137
/// ```
138138
Timestamp,
139+
140+
/// Generate a JSON array whose elements are drawn from a sub-generator.
141+
///
142+
/// The number of elements is controlled by the `length` field, which
143+
/// accepts three forms:
144+
///
145+
/// - A plain integer: every array has exactly that many elements.
146+
/// - A range mapping `{min: N, max: M}`: the count is drawn uniformly
147+
/// from `[N, M]` (inclusive) on each call.
148+
/// - A sequence of integers `[N, M, ...]`: one length is chosen uniformly
149+
/// at random from the list on each call.
150+
///
151+
/// ```yaml
152+
/// # Fixed length
153+
/// !array
154+
/// length: 3
155+
/// element: !range { min: 1, max: 100 }
156+
///
157+
/// # Uniform random length
158+
/// !array
159+
/// length: { min: 1, max: 5 }
160+
/// element: !const "hello"
161+
///
162+
/// # Choose length from a set
163+
/// !array
164+
/// length: [2, 3, 5]
165+
/// element: !reference my_def
166+
/// ```
167+
Array(ArraySpec),
168+
}
169+
170+
// ── Array types ───────────────────────────────────────────────────────────────
171+
172+
/// Determines how many elements a `!array` generator produces per call.
173+
///
174+
/// Deserializes from three YAML forms:
175+
/// - scalar integer → `Fixed`
176+
/// - sequence → `Choose`
177+
/// - mapping → `Range`
178+
#[derive(Clone)]
179+
pub(super) enum ArrayLength {
180+
/// Always produce exactly `n` elements.
181+
Fixed(usize),
182+
/// Draw the count uniformly from `[min, max]` inclusive.
183+
Range { min: usize, max: usize },
184+
/// Choose the count uniformly from the given list of values.
185+
Choose(Vec<usize>),
186+
}
187+
188+
impl<'de> Deserialize<'de> for ArrayLength {
189+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
190+
where
191+
D: serde::Deserializer<'de>,
192+
{
193+
// Untagged: integer -> Fixed, sequence -> Choose, mapping -> Range.
194+
#[derive(Deserialize)]
195+
#[serde(untagged)]
196+
enum RawArrayLength {
197+
Fixed(usize),
198+
Choose(Vec<usize>),
199+
Range { min: usize, max: usize },
200+
}
201+
match RawArrayLength::deserialize(deserializer)? {
202+
RawArrayLength::Fixed(n) => Ok(Self::Fixed(n)),
203+
RawArrayLength::Choose(choices) => {
204+
if choices.is_empty() {
205+
return Err(<D::Error as serde::de::Error>::custom(
206+
"!array length list must not be empty",
207+
));
208+
}
209+
Ok(Self::Choose(choices))
210+
}
211+
RawArrayLength::Range { min, max } => {
212+
if min > max {
213+
return Err(<D::Error as serde::de::Error>::custom(format!(
214+
"!array length range min ({min}) must be <= max ({max})"
215+
)));
216+
}
217+
Ok(Self::Range { min, max })
218+
}
219+
}
220+
}
221+
}
222+
223+
/// Configuration for a `!array` generator node.
224+
#[derive(Clone, Deserialize)]
225+
pub(super) struct ArraySpec {
226+
pub length: ArrayLength,
227+
pub element: Box<Generator>,
139228
}
140229

141230
/// Return the JSON string body encoding of `s`: the bytes that would appear
@@ -231,11 +320,11 @@ impl<'de> Deserialize<'de> for RangeSpec {
231320
{
232321
#[derive(Deserialize)]
233322
#[serde(deny_unknown_fields)]
234-
struct Raw {
323+
struct RawRangeSpec {
235324
min: i64,
236325
max: i64,
237326
}
238-
let raw = Raw::deserialize(deserializer)?;
327+
let raw = RawRangeSpec::deserialize(deserializer)?;
239328
if raw.min > raw.max {
240329
return Err(<D::Error as serde::de::Error>::custom(format!(
241330
"!range min ({}) must be <= max ({})",
@@ -279,11 +368,11 @@ impl<'de> Deserialize<'de> for FormatSpec {
279368
{
280369
#[derive(Deserialize)]
281370
#[serde(deny_unknown_fields)]
282-
struct Raw {
371+
struct RawFormatSpec {
283372
template: String,
284373
args: Vec<Generator>,
285374
}
286-
let raw = Raw::deserialize(deserializer)?;
375+
let raw = RawFormatSpec::deserialize(deserializer)?;
287376

288377
// Split at each `{}` placeholder to get N+1 literal parts.
289378
let parts: Vec<&str> = raw.template.split("{}").collect();

lading_payload/src/templated_json/generator.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ pub(super) enum Generator {
4848
/// Index into the context vec.
4949
Var(usize),
5050
Timestamp(Timestamp),
51+
Array(ArraySpec),
5152
}
5253

5354
pub(super) struct RangeSpec {
@@ -103,6 +104,19 @@ impl WithSpec {
103104
}
104105
}
105106

107+
/// How many elements a `!array` generator produces per call.
108+
pub(super) enum ArrayLength {
109+
Fixed(usize),
110+
Range { min: usize, max: usize },
111+
Choose(Vec<usize>),
112+
}
113+
114+
/// Resolved `!array` generator node.
115+
pub(super) struct ArraySpec {
116+
pub(super) length: ArrayLength,
117+
pub(super) element: Box<Generator>,
118+
}
119+
106120
/// State for a single `!timestamp` generator node.
107121
///
108122
/// Wraps a `Cell<Option<NonZeroI64>>` holding the current value in
@@ -200,6 +214,8 @@ impl Generator {
200214
}
201215

202216
Self::Timestamp(ts) => ts.generate(rng, out)?,
217+
218+
Self::Array(spec) => spec.generate(rng, ctx, defs, out)?,
203219
}
204220
Ok(())
205221
}
@@ -291,6 +307,48 @@ impl FormatSpec {
291307
}
292308
}
293309

310+
impl ArraySpec {
311+
/// Generate a JSON array into `out`.
312+
///
313+
/// The element count is determined by `self.length`; then `self.element`
314+
/// is called once per slot.
315+
///
316+
/// # Errors
317+
///
318+
/// Returns an error if the length list is empty or if any element
319+
/// generator fails.
320+
pub(super) fn generate(
321+
&self,
322+
rng: &mut impl Rng,
323+
ctx: &mut Context,
324+
defs: &[Generator],
325+
out: &mut JsonString,
326+
) -> Result<(), Error> {
327+
let count = self.length.choose(rng)?;
328+
out.push_char('[');
329+
for i in 0..count {
330+
if i > 0 {
331+
out.push_char(',');
332+
}
333+
self.element.generate(rng, ctx, defs, out)?;
334+
}
335+
out.push_char(']');
336+
Ok(())
337+
}
338+
}
339+
340+
impl ArrayLength {
341+
fn choose(&self, rng: &mut impl Rng) -> Result<usize, Error> {
342+
Ok(match self {
343+
Self::Fixed(n) => *n,
344+
Self::Range { min, max } => rng.random_range(*min..=*max),
345+
Self::Choose(choices) => *choices
346+
.choose(rng)
347+
.ok_or_else(|| Error::TemplateError("!array length list is empty".to_string()))?,
348+
})
349+
}
350+
}
351+
294352
impl WithSpec {
295353
/// Evaluate this `!with` block into `out`.
296354
///

0 commit comments

Comments
 (0)