Design an In-Memory File System
Build an In-Memory File System
"Design an in-memory file system" sounds like a warm-up: mkdir, addContentToFile, readContentFromFile, ls — four methods, done in ten minutes. It's the same shape as LeetCode's classic Design In-Memory File System problem, and most candidates reach for the same shortcut: a single Map<String, String> from full path to content. It passes every example the interviewer types out by hand. Then two questions land, and the map falls apart: "list everything directly under /usr" and "rename /usr/local to /opt/local — what does that cost you?" A map has no idea which paths are "under" which other paths — it can only find out by comparing every key against every other key. This kata makes you feel that failure first, then build the fix — a Composite tree of File/Directory nodes — in Java and Go, and prove both its correctness and its cost advantage with real numbers, not hand-waving.
The Trap
The obvious first model: one flat map, full path in, content out.
class NaiveFileSystem {
Map<String, String> pathToContent = new HashMap<>(); // "/usr/local/bin/go" -> "..."
}
addContentToFile and readContentFromFile are trivial — a single get/put by the full path string, O(1). It even feels elegant: no tree to build, no path-walking code, just a hash map doing what hash maps do. Then implement ls("/usr"). The map has entries for /usr/local/bin/go, /usr/local/lib/libc.so, /usr/share/doc/readme.txt — and you need exactly ["local", "share"] back: the direct children only, not the whole subtree, not duplicates. There is no field anywhere that says "these two keys are siblings under the same parent" — the only way to find out is to walk every key in the map, string-compare it against the prefix "/usr/", and pick out the next path segment. A filesystem with 2 million files scattered across it means 2 million string comparisons to answer "what's in this one folder," no matter how small that folder is.
Now try the follow-up: rename /usr/local to /opt/local. Every single key that starts with /usr/local/ — could be one file, could be 200,000 — has to be found (another full scan) and rewritten with a new prefix. There is no "move a subtree" operation on a flat map; there is only "notice, after the fact, which keys happen to share a string prefix, and edit every one of them." The trap is treating "contains" as a string relationship instead of a structural one. Real hierarchy — this directory owns these children — has to be a pointer you can follow, not a substring you have to search for.
Scope it like a senior
Before writing a line of tree code, pin the contract down:
- The four core ops, exact signatures?
ls(path) -> List<String>(children, sorted; or the file's own name ifpathis a file),mkdir(path)(creates every missing intermediate directory —mkdir -psemantics, not a single-level mkdir that fails on a missing parent),addContentToFile(path, content)(creates the file, and its parent directories, if absent; appends if it already exists — this is the detail everyone gets wrong first),readContentFromFile(path) -> String. - Path format? Absolute, Unix-style:
/is the root, segments separated by/, no./../relative paths in scope (name it as an extension). Repeated slashes and a trailing slash should resolve the same as the clean path. - What does
lsreturn for a file path, not a directory? POSIX behavior: just that file's own name in a single-element list — don't throw, and don't return its (nonexistent) children. - rm and mv/rename — in scope? Yes, explicitly: they're the two operations whose cost is the entire point of this kata.
rm(path)deletes a file or an entire directory subtree.moveTo(src, dest)relocates (and can rename) a subtree. - Concurrency? Yes, and scoped precisely: concurrent
mkdirof the exact same nested path from multiple threads must never corrupt the tree or silently lose a directory that was created and populated in the same race window. Full path-level locking for arbitrary concurrent readers/writers is a named extension, not this kata's core. - Out of scope (name these as extensions, don't build them yet): symlinks, permissions/ACLs, hard links, file size limits, persistence to disk. All come up as "what would you add?" follow-ups — see Defend below.
- Scale target? Millions of files, deeply nested directories, a small number of files directly under any one folder (the realistic shape of an actual filesystem) —
lsandmoveTomust scale with the size of what you're operating on, not the size of the whole filesystem.
Answer: a tree of nodes rooted at /, where a directory's children are a first-class map living on that directory — not inferred from string prefixes — plus a per-directory lock so concurrent mkdir of the same path can't orphan anything.
Reason to the design
Simplest thing that could work is the flat map — the trap above. Why it fails, precisely: ls and moveTo both need to answer "what's structurally under this path," and a flat map stores no structure, only strings that happen to share prefixes. Answering a structural question by re-deriving it from scratch, over the ENTIRE dataset, every single call, is the bug — not a performance nitpick, a design smell.
The fix, iterated: make "X is a child of Y" an actual pointer instead of a shared string prefix. A Directory node holds a Map<String, Node> of its own direct children; a File node holds content and no children. Both are a Node. This is the Composite pattern: a File (leaf) and a Directory (composite, itself holding more Nodes) are handled through one shared shape, so path-resolution code — "walk to this path" — never needs to know or care which kind of node it's standing on until the one place that legitimately differs: ls, where a file answers "just me" and a directory answers "here are my direct children." Everything else — mkdir, addContentToFile, rm, moveTo — is the same walk regardless of what's at the far end.
Path resolution, concretely: split the path on /, drop empty segments (handles leading/trailing/doubled slashes for free), and walk from the root, following children.get(segment) one hop at a time. mkdir's "create every missing intermediate directory" is exactly this walk, except a missing segment gets a brand-new Directory instead of returning "not found."
Why ls becomes cheap: once "children of /usr" is a real map living on the /usr node, listing them is just reading that one map's keys — cost proportional to how many children this one directory has, never to how many files exist anywhere else in the tree.
Why rm/moveTo become cheap: the map entry is the parent-child relationship. Deleting a directory is one children.remove(name) on its parent — the whole subtree underneath goes with it (unreferenced, garbage-collected), because nothing else in the tree ever stored a full path string that would need to be found and edited. Moving a subtree is a detach-and-reattach of one pointer: remove the node from its old parent's children map, rename it, insert it into the new parent's children map. The size of the subtree being moved never enters the cost — that's the whole payoff of representing "contains" structurally instead of textually.
The concurrency wrinkle, iterated: the naive "walk the tree, and if a segment is missing, create it" is a classic check-then-act: get(name) returns null, so you build a new Directory and put it — but two threads racing on the exact same missing segment can BOTH see null, BOTH build their own Directory, and whichever put lands last wins the map slot. The other thread's Directory object still exists in memory — anything written into it (a file the losing thread added, believing it owned that folder) is now unreachable from the root, silently gone. The fix is making "get-or-create" a single atomic step, not two: Java's ConcurrentHashMap.computeIfAbsent guarantees only one Directory is ever created and installed per key even under a race; Go's equivalent is a mutex per directory node guarding its own children map.
Build it — milestones
Attempt-first: the contract is mkdir(path), addContentToFile(path, content) (creates-or-appends), readContentFromFile(path) -> String, ls(path) -> List<String> (sorted), plus rm(path) and moveTo(src, dest). Try each milestone before reading the reference implementation below.
- M1 — Composite skeleton.
Node(abstract/interface),FileNode,DirNodewith a children map.mkdircreates every missing intermediate directory;lswalks to a path and lists a directory's children (or a file's own name). - M2 — Content, with append semantics.
addContentToFilecreates the file (and any missing parents) if absent, and appends on repeat calls;readContentFromFilereturns the accumulated content. - M3 — rm. Detach a file OR an entire directory subtree from its parent in one call — prove it's O(1) regardless of how much was under it.
- M4 — moveTo. Detach from the old parent, rename, re-attach under the new parent — one pointer move, content and structure intact, regardless of subtree size.
- M5 — Thread-safe mkdir. Concurrent
mkdircalls for the exact same nested path, from many threads at once, must never orphan a directory (and lose whatever was written into it). Fix it with an atomic get-or-create; prove the naive get-then-put version loses data under load.
Reference implementation — Java
One file, three pieces: the Composite tree (Node/FileNode/DirNode/FileSystem) that's the real answer; FlatFileSystem, the naive alternative from The Trap, kept alongside so the break-it section can measure it directly; and UnsafeFileSystem, a deliberately-unsynchronized twin of the tree kept only to reproduce the concurrency race. Save as FileSystem.java:
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.ConcurrentHashMap;
/** Node is the Composite: File and Directory both extend it, so path
* resolution walks the tree without ever type-switching -- the ONE place
* that legitimately cares which subtype it has is ls(), because a file
* and a directory answer "what's here" differently. */
abstract class Node {
String name; // mutable -- moveTo() renames in place instead of copying
Node(String name) { this.name = name; }
}
final class FileNode extends Node {
final StringBuilder content = new StringBuilder();
FileNode(String name) { super(name); }
}
final class DirNode extends Node {
// ConcurrentHashMap + computeIfAbsent (see FileSystem.walkToParent) is
// what makes concurrent mkdir of the SAME path safe -- see UnsafeFileSystem
// below for what happens with plain get-then-put instead.
final Map<String, Node> children = new ConcurrentHashMap<>();
DirNode(String name) { super(name); }
}
/** The Composite tree file system: mkdir/addContentToFile/readContentFromFile/
* ls/rm/moveTo, all implemented as a walk down Node.children pointers. */
final class FileSystem {
private final DirNode root = new DirNode("");
private static List<String> split(String path) {
List<String> parts = new ArrayList<>();
for (String p : path.split("/")) if (!p.isEmpty()) parts.add(p);
return parts;
}
/**
* Walks to the DIRECTORY that should contain the LAST path segment.
* computeIfAbsent is atomic PER KEY on a ConcurrentHashMap: concurrent
* mkdir("/a/b/c") calls racing on the same missing segment all converge
* on the SAME DirNode instance -- nobody's directory is silently
* orphaned, which is exactly the bug UnsafeFileSystem has below.
*/
private DirNode walkToParent(List<String> parts, boolean createIntermediate) {
DirNode cur = root;
for (int i = 0; i < parts.size() - 1; i++) {
String seg = parts.get(i);
Node child;
if (createIntermediate) {
child = cur.children.computeIfAbsent(seg, DirNode::new);
} else {
child = cur.children.get(seg);
if (child == null) return null;
}
if (!(child instanceof DirNode)) {
throw new IllegalStateException(seg + " exists and is a file, not a directory");
}
cur = (DirNode) child;
}
return cur;
}
private Node resolve(List<String> parts) {
Node cur = root;
for (String seg : parts) {
if (!(cur instanceof DirNode)) return null;
cur = ((DirNode) cur).children.get(seg);
if (cur == null) return null;
}
return cur;
}
/** ls on a directory: O(children at THIS node) -- never a scan of the
* whole filesystem. ls on a file returns just its own name (POSIX ls
* semantics). */
List<String> ls(String path) {
Node n = resolve(split(path));
if (n == null) throw new NoSuchElementException("no such path: " + path);
if (n instanceof FileNode) return Arrays.asList(n.name);
List<String> names = new ArrayList<>(((DirNode) n).children.keySet());
Collections.sort(names);
return names;
}
/** mkdir -p semantics: create every missing intermediate directory. */
void mkdir(String path) {
List<String> parts = split(path);
if (parts.isEmpty()) return; // mkdir("/") is a no-op
DirNode parent = walkToParent(parts, true);
String last = parts.get(parts.size() - 1);
Node created = parent.children.computeIfAbsent(last, DirNode::new);
if (!(created instanceof DirNode)) throw new IllegalStateException(last + " exists as a file");
}
/** Creates the file (and any missing parent directories) if absent, then
* APPENDS content -- classic addContentToFile semantics. */
void addContentToFile(String path, String content) {
List<String> parts = split(path);
if (parts.isEmpty()) throw new IllegalArgumentException("cannot write to root");
DirNode parent = walkToParent(parts, true);
String last = parts.get(parts.size() - 1);
Node node = parent.children.computeIfAbsent(last, FileNode::new);
if (!(node instanceof FileNode)) throw new IllegalStateException(last + " is a directory");
FileNode file = (FileNode) node;
synchronized (file) { file.content.append(content); }
}
String readContentFromFile(String path) {
Node n = resolve(split(path));
if (n == null) throw new NoSuchElementException("no such path: " + path);
if (!(n instanceof FileNode)) throw new IllegalStateException(path + " is not a file");
FileNode file = (FileNode) n;
synchronized (file) { return file.content.toString(); }
}
/** rm: detach the node from its parent. O(1) -- the whole subtree (if a
* directory) becomes unreachable and is reclaimed by the GC in one map
* removal; nothing else in the tree stores full paths, so nothing else
* needs to change. */
void rm(String path) {
List<String> parts = split(path);
if (parts.isEmpty()) throw new IllegalArgumentException("cannot rm root");
DirNode parent = walkToParent(parts, false);
if (parent == null) throw new NoSuchElementException("no such path: " + path);
String last = parts.get(parts.size() - 1);
if (parent.children.remove(last) == null) throw new NoSuchElementException("no such path: " + path);
}
/** moveTo: detach the node from its old parent, rename it in place, and
* re-attach under the new parent. O(1) regardless of subtree size --
* the WHOLE subtree moves as one pointer, because no path is stored
* anywhere else that would need rewriting. */
void moveTo(String srcPath, String destPath) {
List<String> srcParts = split(srcPath);
if (srcParts.isEmpty()) throw new IllegalArgumentException("cannot move root");
DirNode srcParent = walkToParent(srcParts, false);
if (srcParent == null) throw new NoSuchElementException("no such path: " + srcPath);
String srcName = srcParts.get(srcParts.size() - 1);
Node moved = srcParent.children.remove(srcName);
if (moved == null) throw new NoSuchElementException("no such path: " + srcPath);
List<String> destParts = split(destPath);
DirNode destParent = walkToParent(destParts, true);
String destName = destParts.get(destParts.size() - 1);
moved.name = destName; // O(1): rename in place
destParent.children.put(destName, moved); // O(1): re-parent -- the whole subtree rides along
}
}
/**
* The NAIVE alternative most candidates reach for first: one flat map from
* full path string to file content, plus a set of directory paths. Point
* lookups (readContentFromFile) are O(1) -- but ls() and moveTo() must SCAN
* every entry in the entire filesystem, because "this path is a child of
* that path" is not stored anywhere; it can only be recovered by
* string-prefix-matching every key against every other key.
* lastScanCount is instrumentation ONLY, to make that cost measurable.
*/
final class FlatFileSystem {
private final Map<String, String> files = new HashMap<>(); // full path -> content (files only)
private final Set<String> dirs = new HashSet<>(); // full path -> "this is a directory" marker
int lastScanCount;
FlatFileSystem() { dirs.add("/"); }
void mkdir(String path) {
StringBuilder cur = new StringBuilder();
for (String seg : path.split("/")) {
if (seg.isEmpty()) continue;
cur.append("/").append(seg);
dirs.add(cur.toString());
}
}
void addContentToFile(String path, String content) {
files.merge(path, content, String::concat);
}
String readContentFromFile(String path) {
String c = files.get(path);
if (c == null) throw new NoSuchElementException("no such file: " + path);
return c;
}
/** Must scan EVERY key in BOTH maps to find direct children of `path` --
* there is no structural pointer from a directory to its children. */
List<String> ls(String path) {
if (files.containsKey(path)) { lastScanCount = 1; return Arrays.asList(lastSegment(path)); }
String prefix = path.equals("/") ? "/" : path + "/";
Set<String> names = new TreeSet<>();
int scanned = 0;
for (String p : files.keySet()) {
scanned++;
if (p.startsWith(prefix)) names.add(directChild(p, prefix));
}
for (String p : dirs) {
scanned++;
if (!p.equals(path) && p.startsWith(prefix)) names.add(directChild(p, prefix));
}
lastScanCount = scanned; // == size of BOTH maps combined -- O(n), not O(children)
return new ArrayList<>(names);
}
/** No pointer exists from "the subtree" to "its entries" either -- every
* key in the ENTIRE filesystem must be examined to find which ones fall
* under srcPath, then rewritten with destPath as the new prefix. */
void moveTo(String srcPath, String destPath) {
String srcPrefix = srcPath + "/";
List<String> matchedFiles = new ArrayList<>();
List<String> matchedDirs = new ArrayList<>();
int scanned = 0;
for (String p : new ArrayList<>(files.keySet())) {
scanned++;
if (p.equals(srcPath) || p.startsWith(srcPrefix)) matchedFiles.add(p);
}
for (String p : new ArrayList<>(dirs)) {
scanned++;
if (p.equals(srcPath) || p.startsWith(srcPrefix)) matchedDirs.add(p);
}
lastScanCount = scanned; // == total entries, REGARDLESS of how big the moved subtree is
for (String p : matchedFiles) files.put(destPath + p.substring(srcPath.length()), files.remove(p));
for (String p : matchedDirs) { dirs.remove(p); dirs.add(destPath + p.substring(srcPath.length())); }
}
private static String lastSegment(String path) {
int i = path.lastIndexOf('/');
return path.substring(i + 1);
}
private static String directChild(String fullPath, String prefix) {
String rest = fullPath.substring(prefix.length());
int slash = rest.indexOf('/');
return slash == -1 ? rest : rest.substring(0, slash);
}
}
/**
* UNSAFE TWIN of FileSystem -- reuses the exact same DirNode/FileNode
* classes (and their ConcurrentHashMap-backed children), but does the
* get-or-create as TWO separate steps (get, then put) instead of ONE atomic
* computeIfAbsent. This proves a subtle point: making the MAP thread-safe is
* not enough -- get-then-put is still a check-then-act race even when each
* individual map operation is internally safe. Exists ONLY for the break-it
* demo below -- never call from real code.
*/
final class UnsafeFileSystem {
private final DirNode root = new DirNode("");
DirNode mkdirUnsafe(String name) {
Node child = root.children.get(name); // CHECK -- no lock
if (child == null) {
try { Thread.sleep(1); } catch (InterruptedException ignored) { } // widen the window
child = new DirNode(name);
root.children.put(name, child); // ACT -- last writer wins; earlier winners' DirNode instances are orphaned
}
return (DirNode) child;
}
void addContentToFileUnsafe(DirNode dir, String name, String content) {
Node existing = dir.children.get(name);
FileNode file;
if (existing == null) { file = new FileNode(name); dir.children.put(name, file); }
else file = (FileNode) existing;
synchronized (file) { file.content.append(content); }
}
List<String> ls(String name) {
Node n = root.children.get(name);
if (n == null) throw new NoSuchElementException("no such path: " + name);
List<String> names = new ArrayList<>(((DirNode) n).children.keySet());
Collections.sort(names);
return names;
}
}
Reference implementation — Go
Same shape, three files in one package. Node is an interface satisfied by *FileNode and *DirNode; a mutex per directory node guards its own children map (that's the concurrency fix, in place from the start rather than bolted on). Save as filesystem/filesystem.go:
// Package filesystem is the reference implementation for the "Design an
// In-Memory File System" LLD kata: a Composite tree of File/Directory nodes,
// path resolution by walking child pointers, and a per-directory lock that
// makes concurrent mkdir of the same path safe.
package filesystem
import (
"errors"
"sort"
"strings"
"sync"
)
// Node is the Composite: FileNode and DirNode both satisfy it, so path
// resolution never type-switches except at Ls(), the one place a file and a
// directory legitimately answer "what's here" differently.
type Node interface {
Name() string
}
// FileNode is a leaf: one name, one piece of content, guarded by its own lock.
type FileNode struct {
mu sync.Mutex
name string
content strings.Builder
}
func (f *FileNode) Name() string { return f.name }
// DirNode is a composite: a name plus a map of children, guarded by mu.
// mu is what makes get-or-create for THIS directory's children atomic --
// see walkToParent below, and UnsafeFileSystem for what happens without it.
type DirNode struct {
mu sync.Mutex
name string
children map[string]Node
}
func (d *DirNode) Name() string { return d.name }
func newDir(name string) *DirNode { return &DirNode{name: name, children: make(map[string]Node)} }
// FileSystem is the Composite tree file system.
type FileSystem struct {
root *DirNode
}
func NewFileSystem() *FileSystem { return &FileSystem{root: newDir("")} }
func splitPath(path string) []string {
raw := strings.Split(path, "/")
out := make([]string, 0, len(raw))
for _, p := range raw {
if p != "" {
out = append(out, p)
}
}
return out
}
// walkToParent walks to the directory that should contain the LAST path
// segment. Each level's get-or-create is one atomic "lock, check, create if
// absent, unlock" step under THAT directory's own lock -- concurrent Mkdir
// calls racing on the same missing segment always converge on the SAME
// DirNode, never a duplicate. See UnsafeFileSystem for the naive version
// that skips this lock.
func (fs *FileSystem) walkToParent(parts []string, createIntermediate bool) (*DirNode, error) {
cur := fs.root
for i := 0; i < len(parts)-1; i++ {
seg := parts[i]
cur.mu.Lock()
child, ok := cur.children[seg]
if !ok {
if !createIntermediate {
cur.mu.Unlock()
return nil, errors.New("no such directory: " + seg)
}
child = newDir(seg)
cur.children[seg] = child
}
cur.mu.Unlock()
dir, isDir := child.(*DirNode)
if !isDir {
return nil, errors.New(seg + " exists and is a file, not a directory")
}
cur = dir
}
return cur, nil
}
func (fs *FileSystem) resolve(parts []string) (Node, error) {
var cur Node = fs.root
for _, seg := range parts {
d, ok := cur.(*DirNode)
if !ok {
return nil, errors.New("no such path")
}
d.mu.Lock()
child, ok := d.children[seg]
d.mu.Unlock()
if !ok {
return nil, errors.New("no such path: " + seg)
}
cur = child
}
return cur, nil
}
// Ls on a directory is O(children at THIS node) -- never a scan of the whole
// filesystem. Ls on a file returns just its own name (POSIX ls semantics).
func (fs *FileSystem) Ls(path string) ([]string, error) {
n, err := fs.resolve(splitPath(path))
if err != nil {
return nil, err
}
if f, ok := n.(*FileNode); ok {
return []string{f.Name()}, nil
}
d := n.(*DirNode)
d.mu.Lock()
defer d.mu.Unlock()
names := make([]string, 0, len(d.children))
for name := range d.children {
names = append(names, name)
}
sort.Strings(names)
return names, nil
}
// Mkdir creates every missing intermediate directory (mkdir -p semantics).
// THREAD-SAFE: see walkToParent.
func (fs *FileSystem) Mkdir(path string) error {
parts := splitPath(path)
if len(parts) == 0 {
return nil // mkdir("/") is a no-op
}
parent, err := fs.walkToParent(parts, true)
if err != nil {
return err
}
last := parts[len(parts)-1]
parent.mu.Lock()
defer parent.mu.Unlock()
if existing, ok := parent.children[last]; ok {
if _, isDir := existing.(*DirNode); !isDir {
return errors.New(last + " exists as a file")
}
return nil
}
parent.children[last] = newDir(last)
return nil
}
// AddContentToFile creates the file (and any missing parent directories) if
// absent, then APPENDS content -- classic addContentToFile semantics.
func (fs *FileSystem) AddContentToFile(path, content string) error {
parts := splitPath(path)
if len(parts) == 0 {
return errors.New("cannot write to root")
}
parent, err := fs.walkToParent(parts, true)
if err != nil {
return err
}
last := parts[len(parts)-1]
parent.mu.Lock()
existing, ok := parent.children[last]
var file *FileNode
if !ok {
file = &FileNode{name: last}
parent.children[last] = file
} else {
f, isFile := existing.(*FileNode)
if !isFile {
parent.mu.Unlock()
return errors.New(last + " is a directory")
}
file = f
}
parent.mu.Unlock()
file.mu.Lock()
file.content.WriteString(content)
file.mu.Unlock()
return nil
}
func (fs *FileSystem) ReadContentFromFile(path string) (string, error) {
n, err := fs.resolve(splitPath(path))
if err != nil {
return "", err
}
f, ok := n.(*FileNode)
if !ok {
return "", errors.New(path + " is not a file")
}
f.mu.Lock()
defer f.mu.Unlock()
return f.content.String(), nil
}
// Rm detaches path from its parent. O(1): the whole subtree (if a directory)
// becomes unreferenced and is reclaimed by the GC in one map delete --
// nothing else in the tree stores full paths, so nothing else needs to change.
func (fs *FileSystem) Rm(path string) error {
parts := splitPath(path)
if len(parts) == 0 {
return errors.New("cannot rm root")
}
parent, err := fs.walkToParent(parts, false)
if err != nil {
return err
}
last := parts[len(parts)-1]
parent.mu.Lock()
defer parent.mu.Unlock()
if _, ok := parent.children[last]; !ok {
return errors.New("no such path: " + path)
}
delete(parent.children, last)
return nil
}
// MoveTo moves/renames a subtree in O(1): detach the node from its old
// parent, rename it, and re-attach under the new parent. The subtree under
// it moves as a single pointer -- no path is stored anywhere else to rewrite.
func (fs *FileSystem) MoveTo(srcPath, destPath string) error {
srcParts := splitPath(srcPath)
if len(srcParts) == 0 {
return errors.New("cannot move root")
}
srcParent, err := fs.walkToParent(srcParts, false)
if err != nil {
return err
}
srcName := srcParts[len(srcParts)-1]
srcParent.mu.Lock()
moved, ok := srcParent.children[srcName]
if ok {
delete(srcParent.children, srcName)
}
srcParent.mu.Unlock()
if !ok {
return errors.New("no such path: " + srcPath)
}
destParts := splitPath(destPath)
destParent, err := fs.walkToParent(destParts, true)
if err != nil {
return err
}
destName := destParts[len(destParts)-1]
switch m := moved.(type) {
case *FileNode:
m.name = destName
case *DirNode:
m.name = destName
}
destParent.mu.Lock()
destParent.children[destName] = moved
destParent.mu.Unlock()
return nil
}
The naive flat-map alternative, kept alongside for the break-it comparison. Save as filesystem/flat.go (same package):
package filesystem
import (
"sort"
"strings"
)
// FlatFileSystem is the NAIVE alternative most candidates reach for first:
// one flat map from full path string to file content, plus a set of
// directory paths. Point lookups (ReadContentFromFile) are O(1) -- but Ls
// and MoveTo must SCAN every entry in the entire filesystem, because "this
// path is a child of that path" is not stored anywhere; it can only be
// recovered by string-prefix-matching every key against every other key.
// LastScanCount is instrumentation ONLY, to make that cost measurable.
type FlatFileSystem struct {
files map[string]string // full path -> content (files only)
dirs map[string]bool // full path -> "this is a directory" marker
LastScanCount int
}
func NewFlatFileSystem() *FlatFileSystem {
fs := &FlatFileSystem{files: map[string]string{}, dirs: map[string]bool{}}
fs.dirs["/"] = true
return fs
}
func (fs *FlatFileSystem) Mkdir(path string) {
cur := ""
for _, seg := range strings.Split(path, "/") {
if seg == "" {
continue
}
cur += "/" + seg
fs.dirs[cur] = true
}
}
func (fs *FlatFileSystem) AddContentToFile(path, content string) {
fs.files[path] = fs.files[path] + content
}
func (fs *FlatFileSystem) ReadContentFromFile(path string) string {
return fs.files[path]
}
// Ls must scan EVERY key in BOTH maps to find direct children of `path` --
// there is no structural pointer from a directory to its children.
func (fs *FlatFileSystem) Ls(path string) []string {
if _, ok := fs.files[path]; ok {
fs.LastScanCount = 1
return []string{lastSegment(path)}
}
prefix := path + "/"
if path == "/" {
prefix = "/"
}
names := map[string]bool{}
scanned := 0
for p := range fs.files {
scanned++
if strings.HasPrefix(p, prefix) {
names[directChild(p, prefix)] = true
}
}
for p := range fs.dirs {
scanned++
if p != path && strings.HasPrefix(p, prefix) {
names[directChild(p, prefix)] = true
}
}
fs.LastScanCount = scanned // == size of BOTH maps combined -- O(n), not O(children)
out := make([]string, 0, len(names))
for n := range names {
out = append(out, n)
}
sort.Strings(out)
return out
}
// MoveTo: no pointer exists from "the subtree" to "its entries" either --
// every key in the ENTIRE filesystem must be examined to find which ones
// fall under srcPath, then rewritten with destPath as the new prefix.
func (fs *FlatFileSystem) MoveTo(srcPath, destPath string) {
srcPrefix := srcPath + "/"
var matchedFiles, matchedDirs []string
scanned := 0
for p := range fs.files {
scanned++
if p == srcPath || strings.HasPrefix(p, srcPrefix) {
matchedFiles = append(matchedFiles, p)
}
}
for p := range fs.dirs {
scanned++
if p == srcPath || strings.HasPrefix(p, srcPrefix) {
matchedDirs = append(matchedDirs, p)
}
}
fs.LastScanCount = scanned // == total entries, REGARDLESS of how big the moved subtree is
for _, p := range matchedFiles {
fs.files[destPath+p[len(srcPath):]] = fs.files[p]
delete(fs.files, p)
}
for _, p := range matchedDirs {
delete(fs.dirs, p)
fs.dirs[destPath+p[len(srcPath):]] = true
}
}
func lastSegment(path string) string {
i := strings.LastIndex(path, "/")
return path[i+1:]
}
func directChild(fullPath, prefix string) string {
rest := fullPath[len(prefix):]
if i := strings.Index(rest, "/"); i != -1 {
return rest[:i]
}
return rest
}
The unsynchronized twin for the concurrency break-it. Save as filesystem/unsafe.go (same package):
package filesystem
import (
"sort"
"time"
)
// unsafeNode is a minimal directory node with NO lock at all -- not even a
// thread-safe map read/write. Exists ONLY for the break-it demo.
type unsafeNode struct {
name string
children map[string]*unsafeNode
}
// UnsafeFileSystem reproduces the naive get-or-create most people write
// first: check whether the child exists, THEN create and insert it, with no
// synchronization between the check and the act. Go's map runtime detects
// concurrent mutation of the SAME map and kills the process outright --
// unlike Java's collections, which can silently corrupt or drop entries
// instead of crashing (see UnsafeFileSystem.mkdirUnsafe in the Java
// reference implementation for that contrast). Never use this in real code.
type UnsafeFileSystem struct {
root *unsafeNode
}
func NewUnsafeFileSystem() *UnsafeFileSystem {
return &UnsafeFileSystem{root: &unsafeNode{name: "", children: make(map[string]*unsafeNode)}}
}
// MkdirUnsafe creates (or returns the existing) child directory of root
// named `name` -- CHECK then ACT, no lock between them.
func (fs *UnsafeFileSystem) MkdirUnsafe(name string) *unsafeNode {
child, ok := fs.root.children[name] // CHECK -- no lock
if !ok {
time.Sleep(time.Millisecond) // widen the window
child = &unsafeNode{name: name, children: make(map[string]*unsafeNode)}
fs.root.children[name] = child // ACT -- concurrent writers here crash the Go runtime
}
return child
}
func (fs *UnsafeFileSystem) AddMarkerUnsafe(dir *unsafeNode, marker string) {
dir.children[marker] = &unsafeNode{name: marker, children: nil}
}
func (fs *UnsafeFileSystem) LsUnsafe(name string) []string {
dir := fs.root.children[name]
names := make([]string, 0, len(dir.children))
for n := range dir.children {
names = append(names, n)
}
sort.Strings(names)
return names
}
Break it
This is the M1–M5 correctness suite plus both break-it demonstrations for Java. Save as FileSystemBreakIt.java, alongside FileSystem.java above:
import java.util.Arrays;
import java.util.List;
public final class FileSystemBreakIt {
static int pass = 0, fail = 0;
static void check(String name, boolean cond) {
if (cond) { pass++; System.out.println("PASS: " + name); }
else { fail++; System.out.println("FAIL: " + name); }
}
public static void main(String[] args) throws InterruptedException {
// --- M1: mkdir + addContentToFile + readContentFromFile + ls, on a fresh tree ---
{
FileSystem fs = new FileSystem();
fs.mkdir("/a/b/c"); // mkdir -p semantics: creates a, b, c
check("M1: ls(/) shows the top-level dir created by mkdir -p", fs.ls("/").equals(Arrays.asList("a")));
check("M1: ls(/a) shows b", fs.ls("/a").equals(Arrays.asList("b")));
check("M1: ls(/a/b) shows c", fs.ls("/a/b").equals(Arrays.asList("c")));
fs.addContentToFile("/a/b/c/file.txt", "hello");
fs.addContentToFile("/a/b/c/file.txt", " world"); // addContentToFile APPENDS
check("M1: addContentToFile appends across calls", fs.readContentFromFile("/a/b/c/file.txt").equals("hello world"));
check("M1: ls(/a/b/c) now shows the file too, sorted", fs.ls("/a/b/c").equals(Arrays.asList("file.txt")));
check("M1: ls on a FILE path returns just that file's own name", fs.ls("/a/b/c/file.txt").equals(Arrays.asList("file.txt")));
}
// --- M2: multiple siblings, ls returns them SORTED ---
{
FileSystem fs = new FileSystem();
fs.mkdir("/docs");
fs.addContentToFile("/docs/zebra.txt", "z");
fs.addContentToFile("/docs/apple.txt", "a");
fs.mkdir("/docs/mango");
check("M2: ls returns files AND subdirectories, sorted together",
fs.ls("/docs").equals(Arrays.asList("apple.txt", "mango", "zebra.txt")));
}
// --- M3: rm detaches a whole subtree in one shot ---
{
FileSystem fs = new FileSystem();
fs.mkdir("/a/b/c");
fs.addContentToFile("/a/b/c/f.txt", "x");
fs.addContentToFile("/a/b/g.txt", "y");
fs.rm("/a/b/c"); // removes c AND everything under it
check("M3: rm(/a/b/c) removes the whole subtree", fs.ls("/a/b").equals(Arrays.asList("g.txt")));
boolean threw = false;
try { fs.readContentFromFile("/a/b/c/f.txt"); } catch (Exception e) { threw = true; }
check("M3: content under the removed subtree is gone", threw);
}
// --- M4: moveTo relocates a whole subtree, content intact, in one call ---
{
FileSystem fs = new FileSystem();
fs.mkdir("/project/src");
fs.addContentToFile("/project/src/main.go", "package main");
fs.addContentToFile("/project/src/util.go", "package main // util");
fs.moveTo("/project/src", "/archive/src");
check("M4: after moveTo, the OLD path is gone", fs.ls("/project").isEmpty());
check("M4: the NEW path has both files, content intact",
fs.ls("/archive/src").equals(Arrays.asList("main.go", "util.go"))
&& fs.readContentFromFile("/archive/src/main.go").equals("package main"));
}
// --- Break-it #1: FLAT map vs TREE -- cost of ls() and moveTo() ---
{
final int noiseFiles = 5000; // files scattered elsewhere in the filesystem
FlatFileSystem flat = new FlatFileSystem();
FileSystem tree = new FileSystem();
flat.mkdir("/target");
tree.mkdir("/target");
flat.addContentToFile("/target/only.txt", "x");
tree.addContentToFile("/target/only.txt", "x");
for (int i = 0; i < noiseFiles; i++) {
String p = "/noise" + i + "/f.txt";
flat.mkdir("/noise" + i);
flat.addContentToFile(p, "x");
tree.mkdir("/noise" + i);
tree.addContentToFile(p, "x");
}
List<String> flatLs = flat.ls("/target");
List<String> treeLs = tree.ls("/target");
check("break-it setup: flat and tree agree on ls(/target) contents",
flatLs.equals(treeLs) && flatLs.equals(Arrays.asList("only.txt")));
System.out.println("ls(/target): FLAT scanned " + flat.lastScanCount + " entries (noiseFiles="
+ noiseFiles + " elsewhere in the fs); TREE examined only " + treeLs.size() + " child(ren) of /target");
check("break-it: FLAT map's ls() scans ~the WHOLE filesystem, not just /target's own children",
flat.lastScanCount > noiseFiles);
check("fix: TREE's ls() only ever touches /target's OWN children -- 1 here, independent of noiseFiles",
treeLs.size() == 1);
flat.moveTo("/target", "/moved");
tree.moveTo("/target", "/moved");
System.out.println("moveTo(/target, /moved): FLAT scanned " + flat.lastScanCount
+ " entries to find & rewrite the ones under /target (== whole fs)");
check("break-it: FLAT map's moveTo() rewrites/scans EVERY key in the filesystem, not just the moved subtree",
flat.lastScanCount > noiseFiles);
check("fix: TREE's moveTo() is a constant-size detach+reattach -- the moved data survives intact",
tree.readContentFromFile("/moved/only.txt").equals("x"));
}
// --- Break-it #2: concurrent mkdir of the SAME path (the "make it thread-safe" milestone) ---
{
final int threads = 200;
UnsafeFileSystem unsafe = new UnsafeFileSystem();
Thread[] pool = new Thread[threads];
for (int t = 0; t < threads; t++) {
final int id = t;
pool[t] = new Thread(() -> {
DirNode dir = unsafe.mkdirUnsafe("racedir"); // racing, unsynchronized get-or-create
unsafe.addContentToFileUnsafe(dir, "marker" + id, "x");
});
}
for (Thread th : pool) th.start();
for (Thread th : pool) th.join();
int unsafeSeen = unsafe.ls("racedir").size();
System.out.println("BREAK-IT (concurrent mkdir): " + unsafeSeen + " / " + threads
+ " markers survived (want " + threads + ") -- the rest were written into an orphaned DirNode instance");
check("break-it: UNSAFE concurrent mkdir loses marker files to orphaned directory instances",
unsafeSeen < threads);
FileSystem safe = new FileSystem();
Thread[] safePool = new Thread[threads];
for (int t = 0; t < threads; t++) {
final int id = t;
safePool[t] = new Thread(() -> {
safe.mkdir("/racedir");
safe.addContentToFile("/racedir/marker" + id, "x");
});
}
for (Thread th : safePool) th.start();
for (Thread th : safePool) th.join();
int safeSeen = safe.ls("/racedir").size();
System.out.println("FIX (computeIfAbsent): " + safeSeen + " / " + threads + " markers survived (want " + threads + ")");
check("fix: computeIfAbsent-based mkdir never orphans a directory under concurrent creation",
safeSeen == threads);
}
System.out.println();
System.out.println(pass + " passed, " + fail + " failed");
if (fail > 0) System.exit(1);
}
}
Measured in this session (javac FileSystem.java FileSystemBreakIt.java && java FileSystemBreakIt): all 18/18 assertions pass. With noiseFiles = 5000, ls(/target) on the FLAT map scanned 10,003 entries (the whole filesystem) to answer a question about ONE directory with ONE child; the TREE examined exactly 1. moveTo(/target, /moved) on the FLAT map scanned the same 10,003 entries again to find and rewrite the handful that actually mattered. The concurrent-mkdir race is genuinely nondeterministic and reproduces on every run: repeated executions measured 75, 115, 125, and 168 of 200 marker files surviving under the UNSAFE twin (never all 200 — some are always written into a DirNode that lost the race for the "racedir" slot and became unreachable from the root); the safe, computeIfAbsent-based version landed on exactly 200/200 every single run.
For Go, this is the M1–M5 correctness suite plus the flat-vs-tree cost comparison and the SAFE concurrency test, all runnable with go test. Save as filesystem/filesystem_test.go:
package filesystem
import (
"strconv"
"sync"
"testing"
)
func mustLs(t *testing.T, fs *FileSystem, path string) []string {
t.Helper()
names, err := fs.Ls(path)
if err != nil {
t.Fatalf("Ls(%s): %v", path, err)
}
return names
}
func eq(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// --- M1: mkdir + addContentToFile + readContentFromFile + ls, on a fresh tree ---
func TestBasicMkdirAddReadLs(t *testing.T) {
fs := NewFileSystem()
if err := fs.Mkdir("/a/b/c"); err != nil {
t.Fatalf("Mkdir: %v", err)
}
if got := mustLs(t, fs, "/"); !eq(got, []string{"a"}) {
t.Fatalf("expected [a], got %v", got)
}
if got := mustLs(t, fs, "/a"); !eq(got, []string{"b"}) {
t.Fatalf("expected [b], got %v", got)
}
if got := mustLs(t, fs, "/a/b"); !eq(got, []string{"c"}) {
t.Fatalf("expected [c], got %v", got)
}
if err := fs.AddContentToFile("/a/b/c/file.txt", "hello"); err != nil {
t.Fatalf("AddContentToFile: %v", err)
}
if err := fs.AddContentToFile("/a/b/c/file.txt", " world"); err != nil { // APPENDS
t.Fatalf("AddContentToFile: %v", err)
}
content, err := fs.ReadContentFromFile("/a/b/c/file.txt")
if err != nil || content != "hello world" {
t.Fatalf("expected 'hello world', got %q err=%v", content, err)
}
if got := mustLs(t, fs, "/a/b/c"); !eq(got, []string{"file.txt"}) {
t.Fatalf("expected [file.txt], got %v", got)
}
if got := mustLs(t, fs, "/a/b/c/file.txt"); !eq(got, []string{"file.txt"}) {
t.Fatalf("Ls on a file should return just its own name, got %v", got)
}
}
// --- M2: multiple siblings, Ls returns them SORTED ---
func TestLsReturnsSortedSiblings(t *testing.T) {
fs := NewFileSystem()
fs.Mkdir("/docs")
fs.AddContentToFile("/docs/zebra.txt", "z")
fs.AddContentToFile("/docs/apple.txt", "a")
fs.Mkdir("/docs/mango")
got := mustLs(t, fs, "/docs")
if !eq(got, []string{"apple.txt", "mango", "zebra.txt"}) {
t.Fatalf("expected sorted [apple.txt mango zebra.txt], got %v", got)
}
}
// --- M3: Rm detaches a whole subtree in one shot ---
func TestRmRemovesWholeSubtree(t *testing.T) {
fs := NewFileSystem()
fs.Mkdir("/a/b/c")
fs.AddContentToFile("/a/b/c/f.txt", "x")
fs.AddContentToFile("/a/b/g.txt", "y")
if err := fs.Rm("/a/b/c"); err != nil {
t.Fatalf("Rm: %v", err)
}
if got := mustLs(t, fs, "/a/b"); !eq(got, []string{"g.txt"}) {
t.Fatalf("expected [g.txt] after rm, got %v", got)
}
if _, err := fs.ReadContentFromFile("/a/b/c/f.txt"); err == nil {
t.Fatalf("expected error reading content under a removed subtree")
}
}
// --- M4: MoveTo relocates a whole subtree, content intact, in one call ---
func TestMoveToRelocatesSubtree(t *testing.T) {
fs := NewFileSystem()
fs.Mkdir("/project/src")
fs.AddContentToFile("/project/src/main.go", "package main")
fs.AddContentToFile("/project/src/util.go", "package main // util")
if err := fs.MoveTo("/project/src", "/archive/src"); err != nil {
t.Fatalf("MoveTo: %v", err)
}
if got := mustLs(t, fs, "/project"); len(got) != 0 {
t.Fatalf("expected /project empty after move, got %v", got)
}
got := mustLs(t, fs, "/archive/src")
if !eq(got, []string{"main.go", "util.go"}) {
t.Fatalf("expected [main.go util.go], got %v", got)
}
content, _ := fs.ReadContentFromFile("/archive/src/main.go")
if content != "package main" {
t.Fatalf("expected content intact after move, got %q", content)
}
}
// --- Break-it #1: FLAT map vs TREE -- cost of Ls() and MoveTo() ---
func TestFlatVsTreeLsAndMoveCost(t *testing.T) {
const noiseFiles = 5000
flat := NewFlatFileSystem()
tree := NewFileSystem()
flat.Mkdir("/target")
tree.Mkdir("/target")
flat.AddContentToFile("/target/only.txt", "x")
tree.AddContentToFile("/target/only.txt", "x")
for i := 0; i < noiseFiles; i++ {
p := "/noise" + strconv.Itoa(i) + "/f.txt"
flat.Mkdir("/noise" + strconv.Itoa(i))
flat.AddContentToFile(p, "x")
tree.Mkdir("/noise" + strconv.Itoa(i))
tree.AddContentToFile(p, "x")
}
flatLs := flat.Ls("/target")
treeLs := mustLs(t, tree, "/target")
if !eq(flatLs, []string{"only.txt"}) || !eq(treeLs, []string{"only.txt"}) {
t.Fatalf("expected both to agree on [only.txt], flat=%v tree=%v", flatLs, treeLs)
}
t.Logf("Ls(/target): FLAT scanned %d entries (noiseFiles=%d); TREE examined only %d child(ren)",
flat.LastScanCount, noiseFiles, len(treeLs))
if flat.LastScanCount <= noiseFiles {
t.Fatalf("expected FLAT map's Ls to scan ~the whole filesystem (>%d), got %d", noiseFiles, flat.LastScanCount)
}
if len(treeLs) != 1 {
t.Fatalf("expected TREE's Ls to touch only /target's own child, got %d", len(treeLs))
}
flat.MoveTo("/target", "/moved")
if err := tree.MoveTo("/target", "/moved"); err != nil {
t.Fatalf("MoveTo: %v", err)
}
t.Logf("MoveTo(/target, /moved): FLAT scanned %d entries (== whole fs)", flat.LastScanCount)
if flat.LastScanCount <= noiseFiles {
t.Fatalf("expected FLAT map's MoveTo to scan ~the whole filesystem (>%d), got %d", noiseFiles, flat.LastScanCount)
}
content, err := tree.ReadContentFromFile("/moved/only.txt")
if err != nil || content != "x" {
t.Fatalf("expected moved content intact, got %q err=%v", content, err)
}
}
// TestConcurrentMkdirIsSafe hammers the LOCKED Mkdir path for the SAME
// nested directory from many goroutines. Run with `go test -race`: it must
// report zero races, in contrast to UnsafeFileSystem (exercised by the
// separate cmd/breakit_concurrentmkdir program on purpose, since it crashes
// the process).
func TestConcurrentMkdirIsSafe(t *testing.T) {
const goroutines = 200
fs := NewFileSystem()
var wg sync.WaitGroup
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
fs.Mkdir("/racedir")
fs.AddContentToFile("/racedir/marker"+strconv.Itoa(i), "x")
}(i)
}
wg.Wait()
names, err := fs.Ls("/racedir")
if err != nil {
t.Fatalf("Ls: %v", err)
}
if len(names) != goroutines {
t.Fatalf("expected all %d markers to survive concurrent mkdir, got %d", goroutines, len(names))
}
}
And the separate break-it binary that reproduces the concurrency race with Go's naive, lock-free twin — it has to be its own program, not a go test case, because the expected outcome is a crash. Save as cmd/breakit_concurrentmkdir/main.go, in a module that imports the filesystem package above:
// Command breakit_concurrentmkdir hammers UnsafeFileSystem.MkdirUnsafe (the
// naive check-then-act, no lock) with 200 goroutines all racing to create
// the SAME directory "racedir" off the root and then write their own marker
// file into whichever *unsafeNode they got back. Expect either:
// - a crash: Go's map runtime detects the concurrent writes to the shared
// root.children map and calls fatal() -- "fatal error: concurrent map
// writes" -- not recoverable, which is why this is its own binary; or
// - if you get unlucky and it doesn't crash this run, fewer than 200
// markers survive, because some goroutines wrote their marker into an
// *unsafeNode that lost the race and was never reachable from root.
//
// go run ./cmd/breakit_concurrentmkdir # usually crashes: fatal error: concurrent map writes
// go run -race ./cmd/breakit_concurrentmkdir # prints "WARNING: DATA RACE" first
package main
import (
"fmt"
"strconv"
"sync"
"filesystemkata/filesystem"
)
func main() {
const goroutines = 200
fs := filesystem.NewUnsafeFileSystem()
var wg sync.WaitGroup
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
dir := fs.MkdirUnsafe("racedir") // racing, unsynchronized get-or-create
fs.AddMarkerUnsafe(dir, "marker"+strconv.Itoa(i))
}(i)
}
wg.Wait()
// Reached only if the runtime didn't catch the race THIS run -- still
// wrong, just not caught this time. That's the danger of an unguarded map.
names := fs.LsUnsafe("racedir")
fmt.Printf("no crash this run, but only %d / %d markers survived (want %d) -- the rest were written into an orphaned directory instance\n",
len(names), goroutines, goroutines)
}
Measured in this session: go build ./... and go vet ./... are clean; go test ./... passes 6/6, and go test -race ./filesystem/... -count=3 passes with zero races across 3 repeated runs. go run ./cmd/breakit_concurrentmkdir crashed with fatal error: concurrent map writes on every one of 4 repeated runs; go run -race ./cmd/breakit_concurrentmkdir printed WARNING: DATA RACE, pinpointing the exact unsynchronized read (mapaccess1_faststr) and write (mapaccess2_faststr) inside MkdirUnsafe, before the same crash. The same missing lock produces two different failure signatures across languages, just like in the library kata: Java's ConcurrentHashMap stays internally consistent under the race — it just silently drops the losing thread's directory, a wrong-but-quiet answer — while Go's built-in map detects the concurrent write and kills the process outright, a loud non-answer. Neither is "safer"; both mean the same thing: this code needed one atomic get-or-create and instead had two separate steps.
Optimise — trade-offs
| Decision | Option A | Option B | When A wins | When B wins |
|---|---|---|---|---|
| Representing hierarchy | Flat path→content map (The Trap) | Composite tree (this kata) — note this is a trie keyed by path segment: a DirNode is a trie node whose edges are named by the next path component, not by one character. You don't need a separate "trie" implementation for this problem; the tree you build already is one | You genuinely only ever do point reads/writes by full path and NEVER list a directory or move a subtree — vanishingly rare for anything called a "file system" | Any workload that needs "what's in this folder" or "move this folder" to cost proportional to the folder, not the whole filesystem — which is the actual definition of a hierarchical file system |
| rm / moveTo cost | Flat map: scan+match every key against the target prefix, O(total entries in the fs) | Tree: one map remove + one map insert on the parent(s), O(1) regardless of subtree size | Never, for these two operations specifically — there's no scenario where re-deriving structure via string scan beats reading a pointer | Always — this is the entire reason the tree exists |
| Composite pattern itself | Composite (File/Directory share one Node shape) | Type-checked / instanceof-scattered design (separate, unrelated File and Directory classes, client code branches on type everywhere) | You need a NEW node type added often (symlink, mount point, device file) and want adding it to cost "implement one interface," not "hunt down every instanceof site in the codebase" — Composite's real win | Almost never for THIS problem; but be honest about Composite's own cost: a FileNode "pretends" to have no children, and operations that only make sense for one subtype (only directories have children; only files have content) still need a cast or a type check somewhere at the edges. Composite gives you uniform traversal, not fully uniform operations |
| Concurrency on directory creation | Two-step check-then-act (get, then create-and-put) — the naive version, whether backed by a plain map (Go: crashes) or even a thread-safe map (Java: silently drops the loser) | One atomic get-or-create (Java: ConcurrentHashMap.computeIfAbsent; Go: a mutex around the whole "check, create if absent" step per directory) | Never, once concurrent writers are possible — a thread-safe collection is not the same guarantee as an atomic compound operation on it | Always, whenever more than one thread can call mkdir on overlapping paths |
| Where a real filesystem differs | A real OS filesystem separates the name (a directory entry / dentry) from the data + metadata (an inode): several directory entries can point at the SAME inode (hard links), content lives in fixed-size disk blocks referenced by the inode rather than one contiguous in-memory buffer, and permissions/ownership/timestamps live on the inode, not the tree position. This kata's FileNode conflates "the name at this tree position" with "the content" into one object — perfectly fine for interview scope, but it's exactly why hard links don't fall out for free (see Defend below): you'd need to add that inode-style indirection deliberately. | |||
Defend under drilling
- "Composite vs. flat map — what's the actual cost difference for ls and rename?" — On the flat map, both require a full scan of every entry in the filesystem:
lsto string-match every key's prefix against the target path,moveTo/rename to find every key under the source prefix and rewrite it with the destination prefix — O(total entries), regardless of how small the folder or subtree actually is. Measured in this session with 5,000 unrelated files elsewhere in the tree: the flat map scanned 10,003 entries to list ONE directory with ONE child. On the Composite tree, a directory's children live in a real map ON that directory node —lsreads that one map's keys (O(children), here exactly 1, independent of the other 5,000 files), andmoveTois a detach-and-reattach of one pointer (O(1), verified by moving the same subtree and re-checking content integrity, never re-scanning anything). - "Walk me through resolving a path." — Split the path string on
/and drop empty segments (this quietly handles a leading slash, a trailing slash, and doubled slashes the same way, for free). Starting at the rootDirNode, followchildren.get(nextSegment)one hop at a time. If a segment is missing and the caller wants creation semantics (mkdir,addContentToFile), install a newDirNodethere and continue; if it's missing and the caller wants a plain lookup (ls,readContentFromFile,rm), fail immediately. If you land on aFileNodebefore you've consumed the whole path (i.e. something is trying to treat a file as if it had children), that's an error, not a silent no-op — the reference implementation throws exactly there. - "How would you add symlinks?" — A new
SymlinkNodeholding a target path string, satisfying the sameNodeshape. The interesting part isn't creating it, it's resolving through it: any time path resolution lands on a symlink, re-run resolution against the symlink's target instead of treating it as a leaf. Two hazards that follow immediately: (1) a chain of symlinks pointing at each other forms a cycle, so resolution needs a hop-count limit or a visited-set to avoid an infinite loop; (2) a relative symlink target has to be resolved relative to the symlink's OWN directory, not the caller's, which means carrying "current directory context" through the resolution call, not just the target string. - "How would you add permissions?" — Attach an owner + a mode (or ACL) to each node — more naturally on an inode-style layer than directly on the tree node, per the trade-off row above, since permissions are metadata about the DATA, not about this one directory entry's name. Every operation (
ls, read, write,mkdir,rm) needs a permission check against the calling principal before proceeding, AND you need to decide whether traversing through a parent directory requires its own "execute" permission independent of read/write on the target — real Unix semantics say yes. Also decide inheritance: does a newly created file get a default ACL derived from its parent directory (umask-style), or does the caller have to set it explicitly every time? - "Concurrency on the tree — what's the actual guarantee, and where could it still deadlock?" — Each directory's own lock guards get-or-create on ITS children map; that's what makes concurrent
mkdirof the same nested path converge on oneDirNodeinstead of orphaning one. Note precisely what this implementation does NOT do:moveTolocks the source parent, releases it, THEN locks the destination parent — it never holds two directory locks at once. That's a deliberate trade: it avoids the classic two-directories-crossing deadlock (thread A moving/x→/ywhile thread B moves/y→/x, each holding one lock and blocking on the other) at the cost of a brief window where the moved node is detached from both parents and invisible to a concurrentls. If a requirement forced holding both locks for a truly atomic cross-directory move, the fix is a fixed global lock ordering (e.g., always lock the directory with the lexicographically smaller canonical path first) — the same discipline as avoiding deadlock in any multi-lock resource-ordering problem. - "What breaks at 100x — millions of files?" — Three things, roughly in the order you'd hit them. First, path resolution here is already iterative, not recursive, so depth doesn't blow the call stack — but the naive instinct to implement
rmon a huge directory by recursively walking every descendant to free it explicitly is unnecessary work; detaching the top node and letting the garbage collector reclaim the subtree is both correct and cheap, and this kata'srmalready does that. Second, a single in-memory tree in one process has a hard ceiling — millions of files with real content exceeds one machine's heap, so the metadata (the tree shape, one inode-like record per file) has to separate from the data (file bytes, sharded across storage nodes) — this is exactly the NameNode/DataNode split in a real distributed filesystem like HDFS, and the NameNode's in-memory metadata limit is precisely this kata's tree hitting its ceiling. Third, once the tree is sharded or replicated across machines, "one mutex per directory" stops being enough — mkdir-of-the-same-path becomes a distributed compare-and-set or a lease on that path, the same race this kata reproduces, just moved out of a single process's memory.
You can now defend
- You can name the flat-map trap precisely — "contains" as a string-prefix relationship instead of a structural pointer — and quote real numbers for why it fails: a full-filesystem scan (10,003 entries measured, for a 1-child directory) on every
lsand everymoveTo, regardless of how small the operation actually touches. - You can derive the Composite tree from that failure, explain why File and Directory sharing one
Nodeshape lets path resolution stay type-agnostic until the one place it must differ (ls), and show why the tree turnsls/rm/moveTofrom O(whole filesystem) into O(what you're actually operating on) — verified, not asserted. - You've reproduced the concurrent-mkdir race in BOTH Java and Go, seen the two different failure signatures the same missing atomic-get-or-create produces in each runtime (Java: silent data loss, measured 75–168 of 200 markers surviving; Go: a hard
fatal error: concurrent map writescrash on every run), and fixed both with a single-step get-or-create (computeIfAbsent/ a per-directory mutex) that always lands on 200/200. - You can defend the Composite pattern's actual cost (uniform traversal, not fully uniform operations) and extend the design under pressure — symlinks (resolve-through with a cycle guard), permissions (inode-style metadata + traversal checks), and the distributed escalation (metadata/data split, per-path leases) — before an interviewer has to ask.
Re-authored/Deepened for this guide. Concept: the Composite Pattern. Reference implementations (Java + Go) compiled and tested in-session: javac clean, 18/18 assertions pass (including both break-it demonstrations — the flat map scanned 10,003 entries against the tree's 1 for both ls and moveTo, and the unsafe concurrent mkdir measured 75–168 of 200 markers surviving across repeated runs, never all 200, while the fixed version landed on 200/200 every run); go build + go vet + go test clean (6/6), go test -race clean across 3 repeated runs, and cmd/breakit_concurrentmkdir crashed with fatal error: concurrent map writes on every one of 4 runs, with go run -race additionally pinpointing the exact unsynchronized map access before each crash.
🤖 Don't fully get this? Learn it with Claude
Stuck on Design an In-Memory File System? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Design an In-Memory File System** (Hands-On Builds) and want to truly understand it. Explain Design an In-Memory File System from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Design an In-Memory File System** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Design an In-Memory File System** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Design an In-Memory File System** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.