Implement buffered file writer with concurrency support

Quick Overview

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.

Implement buffered file writer with concurrency support

Company: Datadog

Role: Software Engineer

Category: Coding & Algorithms

Difficulty: easy

Interview Round: Technical Screen

You are given a simple file writer class that writes data directly to disk: ```cpp 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`). ```cpp 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. ```hint 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 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 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. ``` #### 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. ```hint 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 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 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. ``` #### 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. --- ### What a Strong Answer Covers Across both parts, a strong candidate demonstrates: - **Spec fidelity:** the exact `>=` threshold, the forward-vs-fsync distinction, and composition over inheritance — matching the stated interface without inventing extra operations. - **Test design under a real constraint:** recognizing that the non-virtual interface defeats subclass mocks, and choosing a working double strategy (templating, in-memory sink) rather than hand-waving; asserting behavior/timing, not just final bytes. - **Concurrency reasoning from a stated contract:** defining the guarantee first, then showing the mutex-based mechanism satisfies it, and reasoning about what is *not* guaranteed (cross-thread ordering, races with flush). - **Honest trade-off discussion:** I/O-under-lock vs. buffer-swap, and volunteering the ordering hazard the optimization introduces rather than presenting it as a free win. ### 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?

Overview: 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.

Datadog logo
Datadog
Dec 2, 2025
easySoftware EngineerTechnical ScreenCoding & Algorithms
98
0

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 Guidance

  • 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 Guidance

  • 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 Guidance

  • 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 Guidance

  • 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.

What a Strong Answer Covers Guidance

Across both parts, a strong candidate demonstrates:

  • Spec fidelity: the exact >= threshold, the forward-vs-fsync distinction, and composition over inheritance — matching the stated interface without inventing extra operations.
  • Test design under a real constraint: recognizing that the non-virtual interface defeats subclass mocks, and choosing a working double strategy (templating, in-memory sink) rather than hand-waving; asserting behavior/timing, not just final bytes.
  • Concurrency reasoning from a stated contract: defining the guarantee first, then showing the mutex-based mechanism satisfies it, and reasoning about what is not guaranteed (cross-thread ordering, races with flush).
  • Honest trade-off discussion: I/O-under-lock vs. buffer-swap, and volunteering the ordering hazard the optimization introduces rather than presenting it as a free win.

Follow-up Questions Guidance

  • 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?