Skip to content
academia.sh

Lesson 15 / 19

The File System

A measurement of the repetition and order sensitivity of appending, full replacement, and directory creation where larger data is written to files.

Published Updated

In this lesson

  • Separates the right directory to choose for each type of data
  • Measures that appending to a file is sensitive to both repetition and order
  • Verifies that an atomic write solves repetition but not order
  • Shows that directory creation is robust and that operations depending on file existence are sensitive to the start
Contents

The preference store was for small values, and the keychain for secret values. Larger data, such as a document, an export file, a log, or a downloaded image, is written to the file system. What the file system offers is not a key-value mapping but a path and a sequence of bytes. So its operations fall into two groups: those that change the content of a file and those that change the directory structure. The question of the course is the same for both: if this operation is repeated or reordered, does the result change?

// Replay.swift — classifies an operation's sensitivity to repetition and order
struct Trial<State: Equatable & CustomStringConvertible> {
    let label: String
    let once: State
    let twice: State
    let forward: State
    let backward: State

    var sensitivity: String {
        switch (once != twice, forward != backward) {
        case (false, false): return "robust"
        case (true, false): return "repeat-sensitive"
        case (false, true): return "order-sensitive"
        case (true, true): return "doubly-sensitive"
        }
    }

    func report() -> String {
        print("\(label) | once=\(once) twice=\(twice) ab=\(forward) ba=\(backward) -> \(sensitivity)")
        return sensitivity
    }
}

func replay<State>(_ label: String, from start: State, _ a: (State) -> State, then b: (State) -> State) -> Trial<State> {
    Trial(label: label, once: a(start), twice: a(a(start)), forward: b(a(start)), backward: a(b(start)))
}

func countSensitivities(_ results: [String]) {
    for name in ["robust", "repeat-sensitive", "order-sensitive", "doubly-sensitive"] {
        print("\(name): \(results.filter { $0 == name }.count)")
    }
}

once is the operation applied to the starting state one time, twice is two times; ab is a first and then b, and ba is the reverse order. The operations measured in this lesson are applied to a pure Swift model of a directory and file tree. The model writes nothing to disk and is not the platform itself.

Which Data Goes to Which Directory

The places where an app can write files are separated by purpose. The documents directory is for data the user produces and must not lose; it is included in the backup. The support directory is for data that the app does not show to the user but cannot regenerate. The cache directory is for data that can be regenerated, and the system can clear it when it needs space. The temporary directory is shorter-lived still.

import Foundation

let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]

var exports = documents.appendingPathComponent("exports", isDirectory: true)
try FileManager.default.createDirectory(at: exports, withIntermediateDirectories: true)

var values = URLResourceValues()
values.isExcludedFromBackup = true
try exports.setResourceValues(values)

let target = exports.appendingPathComponent("settings")
try Data("v1".utf8).write(to: target, options: .atomic)

This fragment is SOURCE; it needs a real app directory, so it does not run here. Four details are read from it. The createDirectory call with withIntermediateDirectories: true does not fail if the directory already exists. The property corresponding to the isExcludedFromBackupKey key leaves a file or a directory out of the backup; it is used for data that can be downloaded again. The .atomic option writes the data to a temporary file first, then puts it in place of the target. The system chooses when the cache directory is cleared; this lesson does not measure that.

The Model of the File Tree

The model keeps a set of directories and, for each file, a list of lines. In the output the directories and files are printed sorted; the empty set appears as -.

// FolderModel.swift — a pure Swift model of a directory and file tree
struct FolderBox: Equatable, CustomStringConvertible {
    private(set) var directories: Set<String>
    private(set) var files: [String: [String]]

    init(directories: Set<String> = [], files: [String: [String]] = [:]) {
        self.directories = directories
        self.files = files
    }

    var description: String {
        let dirText = directories.isEmpty ? "-" : directories.sorted().joined(separator: ",")
        let fileText = files.isEmpty ? "-" : files.keys.sorted().map { "\($0)=\(files[$0]?.joined(separator: "+") ?? "")" }.joined(separator: ",")
        return "dirs:\(dirText) files:\(fileText)"
    }

    mutating func ensureDirectory(_ path: String) { directories.insert(path) }
    mutating func append(_ line: String, to path: String) { files[path, default: []].append(line) }
    mutating func appendIfPresent(_ line: String, to path: String) {
        if files[path] != nil { files[path]?.append(line) }
    }
    mutating func writeAtomically(_ text: String, to path: String) { files[path] = [text] }
}

func ensuring(_ path: String) -> (FolderBox) -> FolderBox {
    { box in
        var next = box
        next.ensureDirectory(path)
        return next
    }
}

func appending(_ line: String, to path: String) -> (FolderBox) -> FolderBox {
    { box in
        var next = box
        next.append(line, to: path)
        return next
    }
}

func appendingIfPresent(_ line: String, to path: String) -> (FolderBox) -> FolderBox {
    { box in
        var next = box
        next.appendIfPresent(line, to: path)
        return next
    }
}

func writing(_ text: String, to path: String) -> (FolderBox) -> FolderBox {
    { box in
        var next = box
        next.writeAtomically(text, to: path)
        return next
    }
}

The model separates two ways of writing. append adds a line to the end of the file; the existing content is kept. writeAtomically replaces the content of the file completely with the new one; a half-written intermediate state is never visible. On the real platform, an atomic write is done with a temporary file and a replacement. In the model, only the result of this operation is built.

PS7. The model assumes that a write is either seen completely or not seen at all. The app being interrupted during a write and leaving a half file was not built.

PS8. The model keeps file names as plain path strings. The directory does not have to exist before the file; in a real write, the call fails if the parent directory does not exist.

Measuring Four Operations

Four operations are built. The first is appending the same line twice. The second is appending two different lines. The third is writing two different contents atomically. The fourth is creating two different directories.

// main.swift — appending, atomic write, directory creation
let sameLine = replay("append-same-line", from: FolderBox(), appending("a1", to: "log"), then: appending("a1", to: "log")).report()
let twoLines = replay("append-two-lines", from: FolderBox(), appending("a1", to: "log"), then: appending("b2", to: "log")).report()
let atomic = replay("atomic-write", from: FolderBox(), writing("v1", to: "settings"), then: writing("v2", to: "settings")).report()
let folders = replay("create-directory", from: FolderBox(), ensuring("cache"), then: ensuring("docs")).report()

countSensitivities([sameLine, twoLines, atomic, folders])
append-same-line | once=dirs:- files:log=a1 twice=dirs:- files:log=a1+a1 ab=dirs:- files:log=a1+a1 ba=dirs:- files:log=a1+a1 -> repeat-sensitive
append-two-lines | once=dirs:- files:log=a1 twice=dirs:- files:log=a1+a1 ab=dirs:- files:log=a1+b2 ba=dirs:- files:log=b2+a1 -> doubly-sensitive
atomic-write | once=dirs:- files:settings=v1 twice=dirs:- files:settings=v1 ab=dirs:- files:settings=v2 ba=dirs:- files:settings=v1 -> order-sensitive
create-directory | once=dirs:cache files:- twice=dirs:cache files:- ab=dirs:cache,docs files:- ba=dirs:cache,docs files:- -> robust
robust: 1
repeat-sensitive: 1
order-sensitive: 1
doubly-sensitive: 1

Appending the same line twice grows the file: a1 and a1+a1. Since the two operations are identical, the order is meaningless. The class is repeat-sensitive. If a retry logic runs an append call twice, the line is doubled in the log.

Appending two different lines separates both repetition and order. The file becomes either a1+b2 or b2+a1, and in both cases appending twice grows the file. The class is doubly sensitive. That appending is this sensitive is not surprising: the content of the file is the ordered sum of everything appended up to that moment.

An atomic write solves repetition: writing the same content a second time does not change the file. But it does not solve order with two different contents: ab is v2, ba is v1. The last writer wins. Atomicity prevents a half file; it does not decide which write is the last. The class is order-sensitive.

Directory creation is the only robust one of the four lines. With withIntermediateDirectories: true, the operation does nothing if the directory already exists; whichever order two different directories are created in, the same set results. The class is robust.

The count is one of each class.

Choice Guide

The measurement reduces the decision about writing files to a small rule. If the data is written once and never touched again, an atomic write is chosen; the risk of a half file and of duplication disappears, but if there are two writers, the order decides the winner. If the data is a log that grows over time, appending is chosen, but a key or a counter that prevents duplication is needed. Directory creation can always be considered robust; the app can create the directories it needs at startup without asking.

Common Mistakes

  • Putting regenerable data in the documents directory. It bloats the backup.
  • Putting user data in the cache directory. The system can delete it to free space.
  • Modifying a large file in place without an atomic write. An interruption leaves a half file.
  • Calling an append inside a retry. The line is duplicated.
  • Writing to the same file from two different places and assuming that the winner is known.
  • Checking whether a directory exists before creating it. Checking is unnecessary; creation is already robust.

The Bounding Measurement

Some operations depend on the file existing beforehand. Here, let the operation be an append that adds a line only if the file exists: a call that cannot open the file silently does nothing. The same pair of operations is measured from two starts: where the file does not exist and where it does.

// main.swift — the same operation pair from two different starts
let missing = replay("append-if-present-empty", from: FolderBox(), appendingIfPresent("x", to: "log"), then: appendingIfPresent("x", to: "log")).report()
let present = replay("append-if-present-seeded", from: FolderBox(files: ["log": ["a1"]]), appendingIfPresent("x", to: "log"), then: appendingIfPresent("x", to: "log")).report()

countSensitivities([missing, present])
append-if-present-empty | once=dirs:- files:- twice=dirs:- files:- ab=dirs:- files:- ba=dirs:- files:- -> robust
append-if-present-seeded | once=dirs:- files:log=a1+x twice=dirs:- files:log=a1+x+x ab=dirs:- files:log=a1+x+x ba=dirs:- files:log=a1+x+x -> repeat-sensitive
robust: 1
repeat-sensitive: 1
order-sensitive: 0
doubly-sensitive: 0

The same two operations came out robust from the empty start and repeat-sensitive from the seeded start. When the file does not exist, the operation does nothing and so cannot break anything; when the file exists, every call adds a line. The first line, which measured robust, does not say that the operation is robust, only that it had no effect from that start. The rule sees the inequality; it does not generalize the starting state.

What stays outside the measurement is stated openly. The directory types, the property of excluding from backup, and the time at which the cache directory is cleared remained as SOURCE and prose. They depend on the timing and the policy of the system; they cannot be built in this model as the result of an operation.

Where It Shows Up in a Real App

Appending to a file is the typical use of a log file, and the append-same-line line of the measurement showed that it is unprotected against repetition. The app writes an event to the log; if the code that produces the event runs a second time in a retry or in a lifecycle transition, the same line is in the file twice. An analysis that reads the log later counts one event twice. If the file is always in the append form, the trace of the duplication cannot be erased, because the two lines cannot be told apart.

Appending two different lines in two orders, on the other hand, shows double sensitivity: in the append-two-lines line, both repetition and order changed the result. In a log, this means the order of events slips. The order may look unimportant, but think of how a log is most often read as a narrative: if the “connected” line comes after the “disconnected” line, the reader reaches the wrong conclusion.

Atomic write is the operation of this lesson that was expected to look robust but still came out order-sensitive. Writing the whole file under a temporary name and then moving it over the real name prevents a half file; but if two writes race, the last one wins. This is the measured proof that it solves the half-file problem but does not solve the order problem. Directory creation, on the other hand, is robust for both order and repetition, because an operation that checks existence does not create something a second time that is already there.

A Wrong Setup and Its Fix

A common wrong setup is writing the configuration directly to the real file. If the app is interrupted during the write, the file is left half-written and cannot be read on the next launch. The user sees that the app no longer opens. The fix is the temporary file pattern: the new content is first written under a separate name, verified as complete, and then put in place of the real file in a single step. This ensures that the file holds either the old or the new content. PS7 records that the model assumes this guarantee.

The second mistake is storing accumulating data like a log and overwritten data like a setting in the same way. Each line of the accumulating one is an independent event, and repetition creates duplication. Each write of the overwritten one overwrites the previous value, and repetition is harmless. The right setup is to keep the two in separate files and under separate rules. If you want to protect the accumulating one against duplication, you give each event an identity and do not write the same identity a second time; this still does not remove order sensitivity, it only solves repetition.

The third mistake is assuming that the directory exists. Code that writes a file gets an error if the parent directory does not exist. That directory creation came out robust in the measurement says that you can safely set up the order “create the directory first, then write the file”.

Reading the SOURCE Fragment

The unnamed fragment at the start of the lesson queries the directories in the app’s own area. The documents directory is for the user’s data, the cache directory is for regenerable data, and the temporary directory is for short-lived data. The system treats these directories differently: files in the cache directory can be deleted when space runs low, while files in the documents directory go into the backup. This is the real lesson of the fragment: where a file is written decides how long the file will live.

Because the model keeps directory names as plain path strings, it does not build this distinction; PS8 records that. The measurements therefore tell only the results of the operations, that is, the classes of appending, overwriting, and creating. Which directory is deletable is outside the model and has to be considered separately in the app design.

From Measurement to Design

The common lesson of the four measurements is that file operations are evaluated not by themselves but by what they are used for. Appending is the right operation for a log, but it is unprotected against duplication. Overwriting a file is the right operation for a setting, but it lets the last writer win. So the design question becomes not “which operation should I use” but “which flaw can I accept for this data”. A line that is occasionally duplicated in a log is most often acceptable; a lost change in a settings file most often is not.

For data whose flaw cannot be accepted, there are two ways. The first is to route every write through a single place; that way the order is decided in one place. The second is to add an identity that protects the write against repetition: each line carries an event identity, and the same identity is not written a second time. An identity turns a doubly sensitive operation into only an order-sensitive one; the order problem can be solved only by a single writer.

The file system brings another flaw as well: everything we did not measure. A write fails when the disk is full, the user closes the app during a write, the system clears the cache directory. None of these situations is in the model. The measurement shows the flaw in the logic of the operation, not the flaws of the environment. Thinking about both is the responsibility of any code that writes to a file.

Summary

  • The directory is chosen by the type of data: user data to documents, regenerable data to the cache; excluding from backup is possible for data that can be downloaded again.
  • Appending the same line twice grows the file from a1 to a1+a1; the class is repeat-sensitive.
  • Appending two different lines separates repetition and order together (a1+b2 and b2+a1); the class is doubly sensitive.
  • An atomic write solves repetition but does not solve order: ab is v2, ba is v1.
  • Directory creation is robust; an append that depends on file existence is robust from the empty start and repeat-sensitive from the seeded start.
  • This lesson’s sensitivity: doubly sensitive.

Next Step

Files keep data as a plain sequence of bytes. In the next lesson, Local Databases, we move to where data is stored by rows and primary keys, and we measure whether update-or-add comes out robust this time.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close