In the previous chapter, Asynchronous State Synchronization (Polling), we learned how the application acts like a security guard, constantly checking files on the disk to see if a task is running.
But this raises a critical question: How does the data get into those files in the first place? And do we always need to write to a file?
Imagine you are designing a rainwater system for a house.
In our application, Memory (RAM) is the small barrel, and the Disk (File System) is the massive reservoir.
If we store everything in Memory, a large log file will crash the application (Flooding). If we write everything to Disk, simple commands become slow and complex (Over-engineering).
Hybrid Output Management is the intelligent plumbing system that switches between these two automatically.
Consider two different tasks:
echo "Hello World" (Produces 11 bytes).npm install (Produces 5 Megabytes of logs).We want a single system that handles Task A in milliseconds using fast memory, but handles Task B safely by spilling over to the disk without crashing.
We use a class called TaskOutput to act as our traffic controller. It operates in two specific modes:
Let's look at how we use TaskOutput to handle data safely.
When a task starts, we initialize its output manager.
// false = Pipe Mode (Start in memory)
// 8MB limit
const output = new TaskOutput("task-123", null, false, 8 * 1024 * 1024);
Explanation: We create a manager for task-123. We tell it: "Don't write to a file immediately. Use memory until you hit 8MB."
As the task runs, we feed it text.
// Small data
output.writeStdout("Hello World\n");
// Huge data chunk
output.writeStdout("...massive log data...");
Explanation: The output object decides internally: "Is this small enough to keep in variable str? Or should I append it to task-123.output on the disk?"
When we want to show the user the result, we simply ask for it.
const result = await output.getStdout();
console.log(result);
Explanation: We don't care if the data is in RAM or on Disk. The .getStdout() method abstracts that away. If it's on disk, it reads it; if it's in RAM, it returns the variable.
Let's visualize the "Traffic Controller" logic flow.
The magic happens in TaskOutput.ts and diskOutput.ts. Let's break down the logic.
Inside TaskOutput, the writeBuffered method acts as the gatekeeper.
// Inside TaskOutput.ts
#writeBuffered(data: string, isStderr: boolean): void {
// 1. If we are already using disk, just keep appending to it.
if (this.#disk) {
this.#disk.append(data)
return
}
// 2. Check if adding this new data breaks the limit
const totalMem = this.#stdoutBuffer.length + data.length
if (totalMem > this.#maxMemory) {
// 3. Too big! Switch to disk mode.
this.#spillToDisk(null, data)
return
}
// 4. Safe to keep in memory
this.#stdoutBuffer += data
}
Explanation: This is the reservoir logic. If this.#disk exists, the "overflow channel" is already open. If not, check if the water level (totalMem) is too high. If yes, open the channel (spillToDisk).
When the limit is breached, we move data from RAM to Disk using DiskTaskOutput.
#spillToDisk(stderrChunk: string | null, stdoutChunk: string | null): void {
// Create the disk writer
this.#disk = new DiskTaskOutput(this.taskId)
// Empty the current memory buffer into the file
if (this.#stdoutBuffer) {
this.#disk.append(this.#stdoutBuffer)
this.#stdoutBuffer = '' // Clear RAM
}
// Append the new chunk that caused the overflow
if (stdoutChunk) {
this.#disk.append(stdoutChunk)
}
}
Explanation: This creates the file and drains the bucket. this.#stdoutBuffer becomes empty strings, freeing up memory immediately.
When reading back, we need to know where to look.
async getStdout(): Promise<string> {
// Case A: It was always a file (e.g. Bash command)
if (this.stdoutToFile) {
return this.#readStdoutFromFile()
}
// Case B: It spilled to disk (Pipe Mode Overflow)
if (this.#disk) {
return `Output truncated. Full output saved to: ${this.path}`
}
// Case C: Small enough to stay in memory
return this.#stdoutBuffer
}
Explanation: The consumer of this function receives a string regardless of where it came from. Note that in "Case B", we return a truncation message instead of loading the massive file back into memory (which would defeat the purpose!).
You might wonder why we have a separate "File Mode" (stdoutToFile = true).
For system shells like Bash, it is faster to let the Operating System handle the writing. Instead of piping data into our Node.js process (slow), we tell Bash: "Write directly to task.output."
In this case, TaskOutput doesn't buffer anything. It simply watches the file using the Polling techniques we learned in Chapter 2.
In this chapter, we learned:
TaskOutput class hides this complexity, so the rest of the app just says "Write" and "Get".Now that we know how to manage the flow of data while a task is running, what happens when we need to keep that data effectively forever, or manage the files on the disk more strictly?
Next Chapter: Persistent Disk Storage
Generated by Code IQ