Katkat

Implement buffered file writer with concurrency support

DatadogSoftware Engineer · Technical Screen

Last updated

This question evaluates a candidate's ability to implement buffered I/O and design concurrency-safe APIs, exercising skills in data buffering, performance optimization, and thread-safety.

You are given a simple file writer class that writes data directly to disk:

class FileWriter {
public:
 // Append `data` to the file on disk immediately (no buffering).
 // May be relatively slow because it calls the OS for each write.
 void write(const std::string& data);

 // Flush any OS-level buffers to disk.
 void flush();
};

This class is already implemented for you — you do not need to implement its real file I/O. Each call to FileWriter::write is relatively expensive because it goes to the OS, so the goal of this exercise is to reduce how often it is called.

Constraints & Assumptions

  • FileWriter::write and FileWriter::flush are the only operations you may call on the underlying writer; their signatures are fixed and non-virtual.
  • buffer_capacity is given in bytes.
  • All input strings passed to write are reasonably small compared to buffer_capacity.
  • Memory allocation does not fail.
  • Single-threaded for Part 1; concurrent (multiple threads) for Part 2.

Clarifying Questions to Ask

  • Should the capacity-triggered flush call flush() on the underlying writer (durability) or only forward the bytes (batching)? — i.e. what exactly does "flushed to the underlying FileWriter" mean versus "flushed to disk"?
  • Is the threshold inclusive — flush when the buffer is >= buffer_capacity, or strictly >?
  • Am I allowed to modify the FileWriter interface (e.g. make its methods virtual), or must it stay exactly as given? This affects how I can write test doubles.
  • What should happen if a single write is larger than buffer_capacity, or if buffer_capacity is 0?
  • For Part 2: what ordering guarantee is required across concurrent calls — full serialization, or just data integrity (no corruption / loss)?

Part 1: Implement a buffered file writer

Implement a BufferedFileWriter that uses an internal in-memory buffer to reduce the number of calls to the underlying FileWriter. Composition is preferred over inheritance (BufferedFileWriter has-a FileWriter).

class BufferedFileWriter {
public:
 BufferedFileWriter(FileWriter& underlying, size_t buffer_capacity);

 // Append `data` to an in-memory buffer.
 // Only when certain conditions are met (e.g., the buffer is full),
 // data should be flushed to the underlying FileWriter.
 void write(const std::string& data);

 // Force all currently buffered data to be written to the
 // underlying FileWriter and then flush it to disk.
 void flush();
};

Behavior:

  1. The constructor receives a reference to an existing FileWriter and a buffer_capacity in bytes.
  2. write(data) appends data to an internal buffer; if after appending the buffer size is greater than or equal to buffer_capacity, it writes the buffered data to the underlying FileWriter and clears the buffer.
  3. flush() writes any remaining buffered data to the underlying FileWriter (only if non-empty), then calls flush() on the underlying FileWriter.

Also write (or describe) unit tests that validate:

  • Writing data smaller than buffer_capacity, then calling flush().
  • Writing multiple chunks that together exceed buffer_capacity, asserting data is forwarded at the right times.
  • Calling flush() multiple times with an empty buffer is safe.

What This Part Should Cover

  • Correct buffering logic: >= threshold, clearing the buffer after forwarding, and a no-op forward when the buffer is empty.
  • The distinction between forwarding bytes and durably flushing (capacity trigger forwards but does not fsync; flush() does both).
  • Composition over inheritance, with the underlying writer held by reference.
  • Tests that assert timing/behavior (data withheld until flush; capacity trigger does not fsync), not just final concatenated bytes — which forces a deliberate, working test-double strategy.

Part 2: Make BufferedFileWriter thread-safe

Now assume BufferedFileWriter may be used from multiple threads concurrently — different threads may call write() and flush() at the same time. Extend your design so it is thread-safe, and provide pseudocode (any language) showing how shared state is protected and how write() and flush() coordinate.

Clarifying Questions for this Part

  • What durability scope must flush() guarantee for writes that are racing with it (started but not yet returned) versus writes that completed before flush() was called?
  • Is byte-on-disk order required to match the order in which write calls returned, or only that each write's bytes land contiguously?
  • Is a dedicated background-flush thread or a max-buffer backpressure limit in scope (these would justify a condition variable), or is a synchronous design sufficient?

What This Part Should Cover

  • A precisely stated thread-safety guarantee (what is and is not promised) before any code.
  • A single mutex guarding all access to the buffer, with the critical section covering the full append-and-conditionally-forward sequence.
  • An argument for why this is race-free (no torn buffer, no lost/duplicated bytes, each forwarded chunk is exactly one thread's accumulated buffer).
  • Awareness of the I/O-under-lock cost and the buffer-swap optimization with its ordering caveat; recognizing a plain mutex suffices and a condition variable is only needed for waiting.

Hints

Hint 1

Where to start: This is the classic **batching** pattern (think stdio `setvbuf` / Rust `BufWriter`): accumulate small writes in a `std::string` member and forward to the underlying writer only when a threshold is crossed.

Hint 2

The flush contract — keep two things separate: There are *two* distinct actions: (a) forwarding buffered bytes to `underlying_.write(...)`, and (b) calling `underlying_.flush()` for durability. The capacity trigger in `write` should do **only (a)** — forwarding without an fsync — otherwise you defeat the purpose of buffering. `flush()` does both (a) then (b).

Hint 3

Testing against a non-virtual interface: Because `FileWriter::write/flush` are **non-virtual** and you hold a `FileWriter&`, a derived "mock : public FileWriter" override is dispatched statically and never reached. To get an inspectable recorder *without* changing the given interface, template the class on the writer type and instantiate it with a duck-typed recording double in tests.

Hint 4

What to protect: The buffer (and the conditional "forward then clear" sequence) is the shared mutable state. The whole append-and-maybe-forward is a multi-step read-modify-write, so a single `std::mutex` held across the entire critical section is the simplest correct mechanism — atomics alone cannot make a multi-step string operation indivisible.

Hint 5

Pin down the guarantee before coding: State the contract: each `write`/`flush` takes effect atomically in *some* serial order; one `write(data)`'s bytes are forwarded contiguously (never spliced with another thread's), and nothing is lost or duplicated. Decide explicitly whether you also promise wall-clock ordering across concurrent calls (you usually shouldn't).

Hint 6

Senior refinement (and its trap): Holding the lock across the slow underlying `write` serializes all I/O. You can shrink the critical section by *swapping the full buffer out under the lock* and doing the syscall outside it — but call out the hazard: two swapped chunks can then race in `underlying.write`, reordering bytes on disk. The fix is a second I/O-ordering lock, or just accept serialized I/O.

Follow-up questions

  • How would you add a dedicated background flush thread (or a time-based flush) so callers never block on the syscall? What synchronization primitive does that require, and how do you shut it down cleanly?
  • Suppose a single `write` is *larger* than `buffer_capacity`. What does your Part 1 code do, and how would you special-case an oversized chunk to bypass the buffer?
  • If the process can crash, what data can be lost, and how do `flush()` semantics (and the OS `flush`) bound that window? How would you offer an `O_DIRECT`/fsync-per-write durability mode?
  • In Part 2, how would you add **backpressure** so a fast producer can't grow memory unbounded while the underlying writer is slow? Where does a condition variable enter, and what deadlock must you avoid?