OS·V Operating system Chapter 40 of 65

The rescue operation

In 1998 a single command on a Pixar server erased almost all of Toy Story 2, and the backups turned out to be useless. The film was saved by a computer that one of the staff kept at home. Ahead is a rescue drill with five disasters (a power cut, a dead disk, silent bit rot, human error and a lost version) and a way to survive each one, up to git, which you will build yourself.

University 60 minutes Operating systems Practice History
OS·V

Operating system

  1. 36 The OS
  2. 37 Scheduling
  3. 38 Virtual memory
  4. 39 Concurrency
  5. 40 Storage you are here

Builds on: 36 · A tour of a living system 17 · A garden of search trees 16 · Hash tables: attack and defense

What you will take away

  • save a file so that a crash halfway through doesn’t leave half of it behind: a temporary file, fsync and an atomic replace
  • rebuild a lost disk from XOR parity and check data with checksums
  • understand how git stores versions (objects addressed by their hash, trees and commits) and build a small git of your own

Chapter 39 ended with all our carefully counted page views living in the memory of a process, where they vanish as soon as the power goes off. The memory of flip-flops we built in Chapter 31 remembers only while current flows. To outlast a shutdown, data is put where it stays without electricity: on a magnetic disk or in flash memory. But putting it there is not enough. The power fails in the middle of a write, disks break, bits go bad on their own, and people make mistakes more often than hardware does. This chapter is a drill: five disasters, and after each one we look at what survived and what should have been done for everything to survive.

Pixar, 1998. The film disappears

The film, as it happens, was later largely remade anyway, but by the studio’s own decision. In the fourth drill we’ll need two lessons from this story. A backup that nobody checks may have stopped working long ago. And what saves you is the copy that lies far from the scene of the accident. But first, where and how does data lie on a disk at all?

Where the data lives

To a program, a disk looks like a huge array of numbered blocks, like the memory of Chapter 14, except that it has to be read and written in whole blocks, usually 4,096 bytes each. How the disk works inside doesn’t matter to the program. In a hard disk the blocks are magnetized patches on spinning platters: to read one, the head has to travel to the right track and wait for the right spot to float underneath. That takes milliseconds, an eternity by the processor’s standards, as we worked out in Chapter 34. In a solid-state drive the blocks live in flash memory, nothing moves, and a block is read about a hundred times faster. Flash memory has a temper of its own, though: it can be erased only in large chunks, and each cell survives a limited number of rewrites. So the SSD’s controller keeps its own table, “block number as the program sees it → place on the chip,” and spreads the writes evenly over all the cells. It is the same idea as the page table of Chapter 38.

A file always occupies a whole number of blocks, even if it holds a single byte.

One byte takes up 4,096 bytes, and 4,097 bytes take up 8,192: a second block has been started, and all of it is ours. The last line is a confession. When this chapter was written, the sandbox’s /tmp directory was a tmpfs, a file system that lives in RAM. Everything your cells write there disappears along with the sandbox. For this chapter’s experiments that is even convenient: our disasters will be make-believe, and the laws that blocks and files live by stay the same.

The table of contents: the file system

An array of blocks is not yet a collection of files. Something has to record which blocks belong to which file, what the file is called, who owns it and when it was last changed. That is the job of the file system. In Unix every file has an inode, an index node: a record with the file’s size, owner, permissions, modification time and the list of blocks that hold its contents. The inodes are numbered. Dennis Ritchie later admitted that he didn’t know for certain where the i came from either: “‘Index’ is my best guess,” since the number of an inode served as an index into the array of inodes on the disk.

The inode holds no name. Names live in directories, and a directory is a special file whose contents are a table, “name → inode number.” Directories sit inside directories, and the result is a tree from Chapter 17: the path /tmp/film/woody.txt is a descent from the root along branches named tmp, film and woody.txt. And since a name is only a line in a table, one inode can have several names.

Both names lead to the same inode, and its count of names is 2. The rm command erases a line in a directory, not a file, and decreases the count. As long as the inode has names left, its contents live on; when the count reaches zero, the file system marks the inode and its blocks as free. The bytes themselves usually aren’t wiped: they stay where they are until something new is written in their place. That is why deleted files can sometimes be recovered with special programs, and why the first thing Pixar did was shut down the servers: any new write could land on top of the erased scenes. You can use this too: if you notice you’ve deleted something you need from a flash drive, don’t write anything to it.

Drill 1. The power goes out

Scenario one: a program is saving a document, and halfway through the write the power fails. What ends up on the disk?

Writing a file takes several actions. open(path, "w") truncates the file to zero at once: the old version is gone, and the new one doesn’t exist yet. Then the new contents go out piece by piece. And even what has been “written” isn’t necessarily on the disk: write returns as soon as the kernel has put the bytes into its cache in memory, and they go to the disk later, whenever it suits the kernel. This is the cache of Chapter 34 again, this time between memory and the disk. We can’t switch off the power in the sandbox, but we can do nearly the same thing: kill a process in the middle of a write. A child process saves a new version of a scene, and after 0.05 seconds its parent kills it with a signal that can’t be caught.

The naive save left a stump on the disk: no old lines at all, and as many new ones as had been written (when this chapter was written, 500 out of a thousand, and sometimes 400, one piece fewer). Both versions are lost. The safe save left the old version untouched: the crash hit the temporary file, and that one is no loss.

The recipe for a safe save is worth remembering; text editors, databases and installers all use it. Write the new version into a temporary file in the same directory. Call os.fsync, which doesn’t return until the kernel has sent the file’s bytes to the disk. Only then does os.replace rename the temporary file so that it takes the old one’s place. What makes the recipe work is an atomic rename: within one file system, renaming is a single change to one line of a directory, and anyone who opens the file sees either the whole old version or the whole new one. After a crash the disk holds the old file and perhaps an unfinished temporary one, but never a stump. To be completely sure, careful programs also call fsync on the directory itself, so that the rename reaches the disk as well.

Killing a process and cutting the power are not the same thing. When a process dies, the kernel is still alive, and everything it has taken into its cache will reach the disk sooner or later. When the power goes, the kernel’s cache dies too. So when a process is killed, the naive save loses what it hadn’t finished writing, and when the power fails, it can lose even what was “written,” unless there was an fsync.

The journal

The same trouble lies in wait for the file system itself. To append one block to a file, it has to change several places on the disk: the data block itself, the bitmap of free blocks (that block is now taken) and the inode (the block is now on its list, and the size is different). The power can fail between any two of these writes. A block is marked as taken but belongs to no file: the space has leaked. An inode points to a block into which the data was never written: the file holds someone else’s garbage. In the old days, after every crash a checking program was run that went over the whole disk and reconciled everything; on large disks it ran for hours.

Modern file systems keep a journal. Before changing anything in place, they write all the intended changes as one record into a separate area and put a “done” mark at its end. Then they make the changes in place and erase the record. If the power fails before the mark, the record in the journal is incomplete: it is thrown away, and the disk stays in its “before” state. If the power fails after the mark, the records are replayed at the next start, and the disk reaches its “after” state. There is no middle. The same idea, “first write down what you are about to do,” will turn up in databases in Chapter 46.

Pull the plug. The file system is appending a new block to a file, which takes several writes to different places on the disk. Press “Pull the plug” at any moment, and the check after power-on shows what has become of the disk. Then turn on the journal and try again: wherever you pull the plug, the disk ends up either “before” or “after.”

Drill 2. The disk burns out

Scenario two: the disk dies for good. A hard disk’s mechanics wear out, an SSD’s cells wear out, and either kind can have its controller burn out. For a single disk this is rare, but in big storage systems with tens of thousands of disks, replacing dead ones is ordinary daily work. A disk will certainly break; the only question is what happens to the data when it does.

The first thing that comes to mind is a mirror: two disks with the same contents. When one dies, you carry on with the other, put in a new disk and copy everything onto it. That is reliable, but half the space goes to the copy. In 1988 David Patterson, Garth Gibson and Randy Katz of Berkeley published a paper called A Case for Redundant Arrays of Inexpensive Disks (RAID), and that is where the word RAID comes from. Their idea was to replace one big expensive disk with many small cheap ones. But the more disks there are, the more often one of them breaks, so the array needs redundancy, and not necessarily twofold. One extra disk for the whole array is enough, if it holds parity.

Parity is the exclusive or of Chapter 29 applied to whole blocks, bit by bit. Everything else rests on two properties of XOR: $a \oplus a = 0$ and $a \oplus 0 = a$. Suppose three disks hold blocks $d_1, d_2, d_3$, and a fourth holds $p = d_1 \oplus d_2 \oplus d_3$. If $d_2$ is lost, XOR together everything that is left: $d_1 \oplus d_3 \oplus p = d_1 \oplus d_3 \oplus d_1 \oplus d_2 \oplus d_3 = d_2$, because each surviving block appears twice and cancels itself out.

The parity bytes mean nothing on their own, but together with the two surviving disks they put BUZZ back together. This is how RAID-5 works. The data is cut into stripes: in each stripe every disk but one holds a data block, and the remaining disk holds their parity. The parity moves from disk to disk, stripe by stripe, so that no disk becomes a bottleneck: parity is recomputed on every write, and if it all lived on one disk, that disk would be doing the work of all the others.

RAID-5 of four disks. Colored cells are data blocks; cells marked ⊕ hold the parity of their stripe. Tap a disk to make it burn out: the array keeps serving data, restoring each lost block as the XOR of the survivors. “Replace the disk” starts a rebuild, one stripe after another. Try burning out a second disk while the first one is still being rebuilt.

RAID-5 has a weak spot: the rebuild time. To recompute a dead disk, you have to read all the others from beginning to end, and on large disks that takes hours, even days. If a second disk dies during that time, the data is gone: one equation with two unknowns can’t be solved. So large arrays keep two independent parities (RAID-6) and survive the death of any two disks.

RAID protects against broken hardware, not against mistakes. The array will carry out rm -r -f * on all its disks at once, as fast as a single disk would, and the parity will faithfully recompute the emptiness. RAID is not a backup.

Drill 3. Silent corruption

Scenario three is the most insidious one: the disk is alive and answers requests, but in one of its blocks a bit has flipped. The culprit may be a particle from a cosmic ray, an aging flash cell or a bug in the controller’s firmware. The disk hands the block over as if nothing had happened, and the damaged photo or spreadsheet lives on for years until somebody opens it. Meanwhile it is copied into the backups, and the damage spreads to all of them.

The defense is a checksum: a short number computed from a block when it is written and stored next to it, then computed again on reading and compared. The simplest checksum is an old acquaintance, the parity bit of Chapter 31, which guarded the core rope memory of Apollo. Here is what it catches and what it misses, next to two stronger checksums.

One flipped bit changes the parity, and two flip it back: a parity bit is blind to any even number of errors. CRC-32, a cyclic code computed in Ethernet, ZIP archives and PNG images, changes in both cases. It is built to be certain of catching every short burst of consecutive bad bits, and it lets random damage through about once in four billion cases. SHA-256 is a cryptographic hash function, a relative of the hashes of Chapter 16, but built so that nobody knows how to find two different blocks with the same hash, even on purpose. It is longer and slower, but it guards against forgery as well as against accidental damage.

The ZFS and Btrfs file systems store a checksum for every block and verify it on every read. If the disk returns a damaged block and the array has a mirror or parity, the file system takes the good copy and rewrites the bad one: the damage heals itself before anyone notices it. And the checksums of downloadable files, published next to the links, answer the same question for the network: did the file arrive the way it was sent? In the task “A sum with a memory” you’ll compute a checksum of your own, the one that works inside every zlib stream.

Drill 4. Human error

Scenario four is the one that happened at Pixar. The hardware is fine, the bits are intact, the journal is kept. A person gave a command, and the system carried it out. Neither the journal nor RAID nor checksums will save you from that: they all keep the data in whatever form they were told to keep it. The only thing that saves you is a copy the command couldn’t reach. Ransomware that encrypts your files, a stolen laptop and a fire belong here too: the trouble comes from outside and hits everything nearby.

This is where both lessons from Pixar come in. The studio had a backup, but it hadn’t worked for a month, and nobody knew, because nobody had ever tried to restore anything from it. A backup you have never restored from is not a backup but a hope. The check is simple and dull: every so often, take a few files out of the backup and open them. The second lesson: what saved the film was a copy that lay far from the server, on another computer in another house, where the rm command couldn’t reach.

Both ideas are gathered in the rule known as “3-2-1,” usually credited to the photographer Peter Krogh: keep at least three copies of your data, on two different kinds of media, with one of them off-site. Three copies, because two can die at once, like two disks of a RAID-5 during a rebuild. Different kinds of media, because identical disks from one batch suffer from identical ailments. And the off-site copy saves you from fire and thieves, who don’t choose which of the disks in the room to take. The drill below lets you test which copies survive what.

The drill. Tick the copies you have; the original is always on the laptop. Then stage disasters one at a time or all at once. For each disaster you see which copy saved the data and how much work was lost. Find a set that survives all six.

The drill soon reveals two traps. Sync, a folder that repeats itself on another computer or in the cloud, is not a backup: it diligently repeats deletion and encryption too. A mirror and a disk that is always plugged in also repeat whatever happens to the original, or perish along with it. What saves you are copies with a history of versions, from which you can get back yesterday’s state even when today’s is ruined, and copies that stay unplugged most of the time. Many systems take such snapshots by themselves: the ZFS and Btrfs file systems can save the state of a whole disk in a fraction of a second, and backup programs keep versions from hours, days and months ago.

Drill 5. Bring back yesterday

Scenario five: nothing burned and nobody erased anything. Yesterday the program worked, after today’s edits it doesn’t, and it’s unclear which of the forty edits is to blame. Or two people edited the same file, and their work has to be brought together. This calls for history: every version, signed with who changed what and why. Today almost every programmer in the world keeps that history in git, and git itself was born as a rescue operation.

Git could be written in a few days because it rests on one simple idea. A file is stored under an address computed from its contents: the SHA-1 of a short header and of the bytes themselves. This is called content addressing. Git itself isn’t installed in the sandbox, but its addresses can be computed by hand, and they will match what git hash-object prints on any computer in the world.

The string hello world with a newline lives at the address 3b18e51… in every repository, and an empty file always lives at e69de29…. Three properties follow from this idea at once. Identical files are stored once, however many versions and copies mention them: their address is the same. A changed file gets a new address, and the old version stays where it was, so nothing is ever overwritten. And if a byte in the store goes bad, the hash stops matching the address and the damage gives itself away: the checksum of the third drill is built into the store itself.

Git builds everything else out of the same bricks. A directory is a tree object: a list of lines, each with a file’s address and its name, and a tree has a hash for an address too. A commit is an object with the address of a tree, the address of the parent commit, an author and a message. Here is a toy version, in which the store is a dictionary and a commit is a piece of text.

Two commits, and only seven objects: Woody’s and Buzz’s files didn’t change in the second version, and the second tree points to the same addresses. The commit shows the whole construction: the address of the tree, the address of the parent and the message. The last lines show what happens if one byte goes bad in an old file: the check finds the object whose contents no longer match its address.

A commit’s address vouches for far more than one snapshot. It includes the address of the tree, and so the addresses of all the files, and the address of the parent, which in turn includes the address of its own parent, and so on back to the very first commit. Change a single byte in any past version of any file, and the addresses of all the commits after it change. Forty hexadecimal digits vouch for the whole history of a project. Commits with links to their parents form the directed acyclic graph of Chapter 19: an ordinary commit has one parent and a merge commit has two, while a branch is only a name that points to some commit and moves on to the new one with every commit made on it.

A toy git. Change files and make commits, start branches, switch between them and merge them back into main. Next to the commit graph are the working files and the object store, where each object is labeled with the beginning of its address. Count how many new objects appear after a commit in which one file has changed.

Git stores full snapshots rather than the differences between versions; it computes a difference when you ask for one, by the same search for the longest common subsequence of lines as the diff program of Chapter 22, only with a faster variant of it. The snapshots don’t bloat the store, because unchanged files are shared, everything is compressed, and when git packs objects, it does store similar ones as differences from each other. One more thing: every clone of a repository is a complete copy of the whole history. The server across the world that holds your project is the off-site copy of the 3-2-1 rule. In the task “A git of your own” you’ll build one that can save versions, bring them back and read the history.

Tasks

Four tasks, four tools of the rescue team: parity, rebuilding an array, a checksum and a version store.

Write parity(blocks): the blocks are a nonempty list of byte strings (bytes) of equal length, and the answer is their XOR, byte by byte, also bytes of the same length. If the list is empty or the blocks differ in length, raise ValueError. The tests also check the property that parity is kept for: the XOR of all the blocks but one, together with the parity, gives the missing block. Six blocks of 200 KB get three seconds.

A bytearray can be changed by index: result[i] ^= block[i]. Go through all the blocks and all the positions this way. Put the checks for an empty list and for different lengths first: blocks[0] fails with IndexError on an empty list.

A fast way without a loop over the bytes: int.from_bytes(block, "big") turns a whole block into one big integer, the XOR of such integers is the same as the XOR of the bytes, and to_bytes(len, "big") turns the number back into bytes.

XOR has no carries and never looks at the neighboring digits, so the XOR of big numbers is the same as the XOR of their bytes taken one at a time. The length of the answer has to be given explicitly: if the leading bytes are zeros, the number “forgets” them, and to_bytes(size, ...) puts them back.

A RAID-5 array of $n \ge 3$ disks is laid out like this. The data is cut into blocks of block bytes. Stripe number $s$ (counting from zero) is a piece of length block on every disk, starting at byte $s \cdot \text{block}$. In stripe $s$ the parity lies on disk number $(n - 1 - s) \bmod n$, and the $n - 1$ data blocks lie on the other disks in increasing order of their numbers. Write read_array(disks, block): disks is a list with the contents of the disks (bytes), and a dead disk is None. Return all the data in order: stripe after stripe, and within each stripe the data blocks from left to right, without the parity. If more than one disk is dead, raise ValueError. An array of six disks of 120 KB each must be read in under four seconds. An example with three disks, block = 2 and the data b"ABCDEFGH": in stripe 0 the parity is on disk 2, and in stripe 1 on disk 1.

It is easiest to restore a whole disk at once rather than block by block: the XOR of all the surviving disks gives the contents of the dead one, because in every stripe the data blocks and the parity together give zero. This is your parity from the previous task applied to whole disks.

The number of stripes is len(disk) // block for any live disk disk. In stripe s, the block of disk d is the slice disk[s * block:(s + 1) * block]; add it to the answer unless d is the parity disk.

The rebuild doesn’t care which disk holds the parity in a given stripe: a dead block is the XOR of all the other blocks of its stripe, whether it held data or parity. Where the parity lies matters only when reading, because it mustn’t be passed off as data. RAID controllers rebuild the same way, but one stripe at a time, so as not to hold whole disks in memory.

The Adler-32 checksum sits at the end of every zlib stream; zlib is a compression format used, for example, inside PNG images. The checksum was invented by Mark Adler, and it is computed like this: $a$ starts at 1 and $b$ at 0; for each byte in turn, $a$ grows by the value of the byte and $b$ by the new value of $a$; both sums are taken modulo 65521. The answer is the number $b \cdot 65536 + a$. Write adler32(data) for a byte string. The tests compare your answer with the standard library, but you may not use it yourself: the modules zlib and binascii are forbidden in the solution. A megabyte of data must take less than three seconds.

The starter has only $a$, the plain sum of the bytes. It doesn’t notice when bytes swap places: b"ab" and b"ba" give the same sum. Add the second sum, $b$: after each byte, add the current $a$ to it.

Don’t forget the modulus for $b$ too: without it, $b$ grows far beyond 16 bits on long data. The two halves can be joined with a shift: (b << 16) | a.

Here is why $b$ notices swaps. The byte at position $i$ out of $n$ enters $b$ as many times as there are steps left after it, so it carries the weight $n - i$. Swap two different bytes and their weights swap too, and $b$ changes with them. The modulus 65521 is the largest prime below $2^{16}$: with a prime modulus the sums mix better. Adler-32 is faster to compute than CRC-32 but weaker on short data, a few hundred bytes long. A PNG file uses both: Adler-32 at the end of the zlib stream checks all the decompressed data, and each separate chunk of the file carries its own CRC-32 as well.

Build a version store. The class Repo keeps all its objects in the dictionary objects: an address, the 40 hexadecimal digits of a SHA-1, leads to the object’s bytes, and every object lies under the SHA-1 of its own bytes. The methods:

  • put(data) stores a file the way git does, as the object b"blob " + length + b"\0" + data, and returns its address. The address must match the one git itself produces.
  • commit(files, parent, message): files is a dictionary “name → bytes,” and parent is the address of the previous commit or None. Store the files, the tree and the commit, and return the commit’s address. The format of the tree and of the commit is up to you, but the commit’s address must change with any change in the files, their names, the parent or the message, and must not depend on the order of the files in the dictionary or on the time.
  • checkout(address) returns a dictionary “name → bytes” from that commit.
  • log(address) returns the list of messages from that commit back to the very first.

The last test builds a chain of 2,000 commits: the commits themselves and a log from the last one get four seconds.

Start from the cell “toy-git.py”: it already has store and commit. To keep the address independent of the order of files, go through the names in sorted(files). To read an object back, cut off its header up to the first zero byte: data[data.index(b"\0") + 1:].

For checkout, take the commit apart: the line tree … holds the address of the tree, and in the tree every line is “address name.” For log, follow the parent … lines until there is no parent. If the first commit records its parent as None, parsing will give you the string "None"; agree on a clear sign instead, such as -.

The store doesn’t know the word “version”: all it holds is objects under their addresses, and the history arises from the links between commits and their parents. Real git is built the same way, except that a tree also records the files’ permissions and keeps nested directories as nested trees, a commit records the author and the time, and the objects are compressed. Because git puts the time into a commit, two identical commits made in different seconds get different addresses; here we leave the time out on purpose, so that the result can be checked.

What next

All five drills are done, and every disaster has met its remedy, from fsync and the journal to git, where every version is vouched for by a hash.

Think back to the rescue of Toy Story 2. The copy on Galyn Susman’s home computer didn’t get there by itself: the studio kept sending her the changes over a phone line. The off-site copy of the 3-2-1 rule also has to get there somehow, and git push sends commits to a server on the other side of the world. The common thread is that there is more than one computer. Two machines share no memory and no disk; between them there is only a wire or a radio wave carrying signals, and often a dozen intermediaries along the way. How two machines exchange data, who finds the route across half the world, and why bytes reach their destination at all is the story of the next chapter.