make.ts

🔥 Check out this trending post from Hacker News 📖

📂 **Category**:

✅ **What You’ll Learn**:

Sounds familiar? This is how I historically have been running
benchmarks and other experiments requiring a repeated sequence of
commands — type them manually once, then rely on shell history (and
maybe some terminal splits) for reproduction. These past few years
I’ve arrived at a much better workflow pattern — make.ts.
I was forced to adapt it once I started working with multiprocess
applications, where manually entering commands is borderline
infeasible. In retrospect, I should have adapted the workflow years
earlier.

Use a consistent filename for the script. I use make.ts, and so there’s a make.ts in the root
of most projects I work on. Correspondingly, I have make.ts line in project’s .git/info/exclude
— the .gitignore file which is not shared. The fixed
name reduces fixed costs — whenever I need complex interactivity I
don’t need to come up with a name for a new file, I open my
pre-existing make.ts, wipe whatever was there and start
hacking. Similarly, I have ./make.ts in my shell
history, so
fish autosuggestions
work for me. At one point, I had a VS Code task to run make.ts, though I now use
terminal editor.

Start the script with hash bang,
#!/usr/bin/env -S deno run
--allow-all

in my case, and
chmod a+x make.ts
the file, to make it easy to run.

Write the script in a language that:

  • you are comfortable with,
  • doesn’t require huge setup,
  • makes it easy to spawn subprocesses,
  • has good support for concurrency.

For me, that is TypeScript. Modern JavaScript is sufficiently
ergonomic, and structural, gradual typing is a sweet spot that gives
you reasonable code completion, but still allows brute-forcing any
problem by throwing enough stringly dicts at it.

JavaScript’s tagged template syntax is brilliant for scripting
use-cases:

function $(literal, ...interpolated) 💬

const dir = "hello, world";
$`ls $💬`;

prints

What happens here is that $ gets a list of literal
string fragments inside the backticks, and then, separately, a list
of values to be interpolated in-between. It could
concatenate everything to just a single string, but it doesn’t have
to. This is precisely what is required for process spawning, where
you want to pass an array of strings to the exec
syscall.

Specifically, I use dax library with Deno, which is excellent as a single-binary
batteries-included scripting environment (see <3 Deno). Bun has a dax-like library in the box and is a
good alternative (though I personally stick with Deno because of
deno fmt and deno lsp). You could also use
famous zx, though be mindful that it
uses your shell as a middleman, something I consider to be
sloppy (explanation).

While dax makes it convenient to spawn a single
program, async/await is excellent for herding a slither
of processes:

await Promise.all([
    $`sleep 5`,
    $`sleep 10`,
]);

Here’s how I applied this pattern earlier today. I wanted to measure
how TigerBeetle cluster recovers from the crash of the primary. The
manual way to do that would be to create a bunch of ssh sessions for
several cloud machines, format datafiles, start replicas, and then
create some load. I
almost started to split my terminal up, but then figured
out I can do it the smart way.

The first step was cross-compiling the binary, uploading it to the
cloud machines, and running the cluster (using my box from the other week):

await $`./zig/zig build -Drelease -Dtarget=x86_64-linux`;
await $`box sync 0-5 ./tigerbeetle`;
await $`box run 0-5
    ./tigerbeetle format --cluster=0 --replica-count=6 --replica=?? 0_??.tigerbeetle`;
await $`box run 0-5
    ./tigerbeetle start --addresses=?0-5? 0_??.tigerbeetle`;

Running the above the second time, I realized that I need to kill
the old cluster first, so two new commands are “interactively”
inserted:

await $`./zig/zig build -Drelease -Dtarget=x86_64-linux`;
await $`box sync 0-5 ./tigerbeetle`;

await $`box run 0-5 rm 0_??.tigerbeetle`.noThrow();
await $`box run 0-5 pkill tigerbeetle`.noThrow();

await $`box run 0-5
    ./tigerbeetle format --cluster=0 --replica-count=6 --replica=?? 0_??.tigerbeetle`;
await $`box run 0-5
    ./tigerbeetle start --addresses=?0-5? 0_??.tigerbeetle`;

At this point, my investment in writing this file and not just
entering the commands one-by-one already paid off!

The next step is to run the benchmark load in parallel with the
cluster:

await Promise.all([
    $`box run 0-5 ./tigerbeetle start     --addresses=?0-5? 0_??.tigerbeetle`,
    $`box run 6   ./tigerbeetle benchmark --addresses=?0-5?`,
])

I don’t need two terminals for two processes, and I get to
copy-paste-edit the mostly same command.

For the next step, I actually want to kill one of the replicas, and
I also want to capture live logs, to see in real-time how the
cluster reacts. This is where 0-5 multiplexing syntax
of box falls short, but, given that this is JavaScript, I can just
write a for loop:

const replicas = range(6).map((it) =>
    $`box run ${it}
        ./tigerbeetle start --addresses=?0-5? 0_??.tigerbeetle
        &> logs/${it}.log`
        .noThrow()
        .spawn()
);

await Promise.all([
    $`box run 6 ./tigerbeetle benchmark --addresses=?0-5?`,
    (async () => {
        await $.sleep("20s");
        console.log("REDRUM");
        await $`box run 1 pkill tigerbeetle`;
    })(),
]);

replicas.forEach((it) => it.kill());
await Promise.all(replicas);

At this point, I do need two terminals. One runs ./make.ts and shows the log from the benchmark itself, the
other runs tail -f logs/2.log to watch the next replica
to become primary.

I have definitelly crossed the line where writing a script makes
sense, but the neat thing is that the gradual evolution up to this
point. There isn’t a discontinuity where I need to spend 15 minutes
trying to shape various ad-hoc commands from five terminals into a
single coherent script, it was in the file to begin with.

And then the script is easy to evolve. Once you realize that it’s a
good idea to also run the same benchmark against a different,
baseline version TigerBeetle, you replace ./tigerbeetle
with
./${tigerbeetle} and wrap everything into

async function benchmark(tigerbeetle: string) {
    
}

const tigerbeetle = Deno.args[0]
await benchmark(tigerbeetle);
$ ./make.ts tigerbeetle-baseline
$ ./make.ts tigerbeetle

A bit more hacking, and you end up with a repeatable benchmark
schedule for a matrix of parameters:

for (const attempt of [0, 1])
for (const tigerbeetle of ["baseline", "tigerbeetle"])
for (const mode of ["normal", "viewchange"]) {
    const results = $.path(
        `./results/${tigerbeetle}-${mode}-${attempt}`,
    );
    await benchmark(tigerbeetle, mode, results);
}

That’s the gist of it. Don’t let the shell history be your source,
capture it into the file first!

{💬|⚡|🔥} **What’s your take?**
Share your thoughts in the comments below!

#️⃣ **#make.ts**

🕒 **Posted on**: 1769590989

🌟 **Want more?** Click here for more info! 🌟

By

Leave a Reply

Your email address will not be published. Required fields are marked *