Skip to main content

Writing a service generator

The http generator lets a service you wrote decide values. This page is the other half: how to write that service, in Node, Python, or Java, so that it is fast, correct, and reproducible. Reproducibility is the one that takes deliberate work, because a service is free to answer differently every time it is asked.

Below is one working service per language. Each one covers both modes: it invents account numbers when asked for values, and appends a Luhn check digit when handed values to process.

What the service must do

The full contract lives on the generator's page. Four lines of it are all you need to write one:

  • a POST arrives, carrying X-TDC-Count: N and X-TDC-Seed: <hex>;
  • the body is N values, one per line — or empty, which means "invent them";
  • answer with exactly N lines, in the same order;
  • plain text, no JSON needed anywhere.

The service, in five languages

import { createServer } from 'node:http';

/** FNV-1a (32-bit). The same three lines in every language — that is the point. */
function fnv1a(text) {
let h = 0x811c9dc5;
for (const ch of Buffer.from(text, 'utf8')) {
h ^= ch;
h = Math.imul(h, 0x01000193) >>> 0; // Math.imul keeps it 32-bit
}
return h >>> 0;
}

/** Source mode: an 8-digit account for row `i`, decided only by (seed, i). */
function accountFor(seed, i) {
return String(fnv1a(`${seed}#${i}`) % 100000000).padStart(8, '0');
}

/** Handler mode: the Luhn check digit of what was sent. */
function luhn(number) {
let sum = 0;
let dbl = true;
for (let i = number.length - 1; i >= 0; i--) {
let d = number.charCodeAt(i) - 48;
if (d < 0 || d > 9) continue;
if (dbl) {
d *= 2;
if (d > 9) d -= 9;
}
sum += d;
dbl = !dbl;
}
return String((10 - (sum % 10)) % 10);
}

createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const count = Number(req.headers['x-tdc-count'] ?? '0');
const seed = String(req.headers['x-tdc-seed'] ?? '');
const body = Buffer.concat(chunks).toString('utf8');

const out =
body.length === 0
? Array.from({ length: count }, (_, i) => accountFor(seed, i)) // source
: body.split('\n').map((line) => line + luhn(line)); // handler

const payload = out.join('\n');
res.writeHead(200, {
'Content-Type': 'text/plain',
'Content-Length': Buffer.byteLength(payload),
});
res.end(payload);
});
}).listen(5701, '127.0.0.1');

Run it with node service.mjs, then point src at http://127.0.0.1:5701/.

Any of the five, against the same config:

<env count="3" seed="demo">
<sequence name="Payload"><gen type="number" value="10000000..99999999"/></sequence>
<sequence name="Card"><gen type="http" src="http://127.0.0.1:5701/" in="Payload"/></sequence>
<sequence name="Acct"><gen type="http" src="http://127.0.0.1:5701/"/></sequence>
</env>
./run demo.tdc
77737493 -> 777374935   |  account: 71102997
14850763 -> 148507635   |  account: 54325378
87262332 -> 872623327   |  account: 37547759

Card went through the handler — the payload came back with its check digit. Acct was invented from the seed alone. Swap the port for 5702, 5703, 5704 or 5705 and the output is character for character the same.

Reproducibility: what the seed is for

The http generator is the one place TDC gives up its guarantee: the service decides the values, so the engine can't promise that a re-run produces the same data. Your service can promise it — and X-TDC-Seed is what makes that possible.

The rule is one line: derive every value from the seed, never from a clock or a random number generator.

accountFor(seed, i); // reproducible — same seed, same row, same answer
Math.random(); // not
new Date(); // not

The seed TDC sends is stable across runs and different for every sequence, so two http sequences pointed at one service never receive the same stream.

Written this way, the run reproduces:

./run demo.tdc — twice
run 1:  71102997  54325378  37547759
run 2:  71102997  54325378  37547759

Compute each row directly, don't iterate

Note the shape of accountFor(seed, i): it takes the row index and returns that row's value, with no state carried between calls. That's deliberate, and worth copying.

A generator that walks a sequence — "call next() N times" — has to be called in the right order, from the start, exactly once. A service can't guarantee any of that: the engine may retry a request, and requests may arrive concurrently. A stateless function of (seed, i) is immune to all of it, and it's no harder to write.

The trap: 32 bits in five languages

For all five to agree, the arithmetic has to agree. This is where a naive port breaks, and it comes down to one line per language:

LanguageWhat keeps the hash 32-bit
NodeMath.imul(h, prime) >>> 0 — a plain * would go through a double and lose the low bits
Python& 0xFFFFFFFF — integers are arbitrary-precision, so nothing overflows on its own
Javanothing — int multiplication already wraps
C#unchecked { … } — outside it, .NET throws on overflow instead of wrapping
Rustwrapping_mul — a plain * panics on overflow in a debug build

Miss it in Python and the numbers grow forever, silently producing different values from the rest. TDC's own engine has to solve exactly this problem: its PRNG is written in terms of Math.imul and 32-bit operations precisely so every bindings agree — which means the constraint isn't an artifact of this example.

The implementations above were run and compared:

shasum -a 256 out.*.txt
875cd44fe86e15d7  out.cs.txt
875cd44fe86e15d7  out.java.txt
875cd44fe86e15d7  out.node.txt
875cd44fe86e15d7  out.py.txt

Identical, not merely similar. If you port this to a fifth language, do the same check before trusting it.

The handler is usually reproducible already

Worth noting: luhn() never touches the seed. A handler computes its answer from the value you sent it, so it's a pure function by nature — same input, same output, every run. Only the source mode has to work at reproducibility.

Before you point TDC at it

A short list, each item learned from the way this generator actually fails:

  • Answer with exactly N lines. One too many or too few and the run stops with returned N line(s) for a batch of M. That check exists because a length mismatch means the answers no longer line up with the rows — silent corruption otherwise.
  • Keep the order. Line i of the response must answer line i of the request. TDC can't detect a shuffle; you'd just get wrong data.
  • Values must not contain a newline, in either direction — the protocol is line-delimited, so an embedded newline breaks the count. It fails loudly rather than corrupting anything, but it fails.
  • Be safe to call concurrently, or run the generation with --jobs 1. TDC doesn't coordinate its workers for you.
  • Handle the whole batch in one request. Don't fan out internally to one call per value; the batch is what keeps this fast.

See also