Renaming scheme to guard against data loss if system crashes while writing JSON
authorNick Downing <nick@ndcode.org>
Sun, 18 Nov 2018 12:19:01 +0000 (23:19 +1100)
committerNick Downing <nick@ndcode.org>
Sun, 18 Nov 2018 12:19:01 +0000 (23:19 +1100)
JSONCache.js
README.md

index c42fff2..3e55b93 100644 (file)
@@ -25,6 +25,8 @@ let fs = require('fs')
 let util = require('util')
 
 let fs_readFile = util.promisify(fs.readFile)
+let fs_rename = util.promisify(fs.rename)
+let fs_unlink = util.promisify(fs.unlink)
 let fs_writeFile = util.promisify(fs.writeFile)
 
 let JSONCache = function(diag) {
@@ -34,26 +36,33 @@ let JSONCache = function(diag) {
   this.diag = diag || false
 }
 
-let read = (diag, key, default_value) => {
+let read = (pathname, default_value, diag) => {
   if (diag)
-    console.log('reading', key)
+    console.log('reading', pathname)
   let result = {dirty: false}
   result.done = (
     async () => {
+      let text
       try {
-        result.value = JSON.parse(
-          await fs_readFile(key, {encoding: 'utf-8'})
-        )
+        text = await fs_readFile(pathname, {encoding: 'utf-8'})
       }
       catch (err) {
-        if (
-          default_value === undefined ||
-          !(err instanceof Error) ||
-          err.code !== 'ENOENT'
-        )
-          throw err
-        result.value = default_value
+        if (!(err instanceof Error) || err.code !== 'ENOENT')
+          throw err;
+        try {
+          await fs_rename(pathname + '.temp', pathname)
+          text = await fs_readFile(pathname, {encoding: 'utf-8'})
+        }
+        catch (err) {
+          if (
+            default_value === undefined ||
+            !(err instanceof Error) ||
+            err.code !== 'ENOENT'
+          )
+            throw err;
+        }
       }
+      result.value = text === undefined ? default_value : JSON.parse(text)
     }
   )()
   return result
@@ -62,7 +71,7 @@ let read = (diag, key, default_value) => {
 JSONCache.prototype.read = async function(key, default_value) {
   let result = this.map.get(key)
   if (result === undefined) {
-    result = read(this.diag, key, default_value)
+    result = read(key, default_value, this.diag)
     this.map.set(key, result)
     await result.done
     delete result.done
@@ -73,17 +82,26 @@ JSONCache.prototype.read = async function(key, default_value) {
   return result.value
 }
 
-let write = (diag, key, result, timeout) => {
+let write = (pathname, result, timeout, diag) => {
   if (!result.dirty) {
     result.dirty = true
     setTimeout(
       async () => {
         if (diag)
-          console.log('writing', key)
+          console.log('writing', pathname)
         result.dirty = false
+        let temp_pathname = pathname + '.temp'
         let text = JSON.stringify(result.value) + '\n'
         try {
-          await fs_writeFile(key, text, {encoding: 'utf-8'})
+          await fs_writeFile(temp_pathname, text, {encoding: 'utf-8'})
+          try {
+            await fs_unlink(pathname)
+          }
+          catch (err) {
+            if (!(err instanceof Error) || err.code !== 'ENOENT')
+              throw err
+          }
+          await fs_rename(temp_pathname, pathname)
         }
         catch (err) {
           console.err(err.stack || err.message)
@@ -109,7 +127,7 @@ JSONCache.prototype.write = async function(key, value, timeout) {
       await result.done
     result.value = value
   }
-  write(this.diag, key, result, timeout)
+  write(key, result, timeout, this.diag)
 }
 
 JSONCache.prototype.modify = async function(
@@ -122,7 +140,7 @@ JSONCache.prototype.modify = async function(
   // atomically check that result.done === undefined before the modification
   let result = this.map.get(key)
   if (result === undefined) {
-    result = read(this.diag, key, default_value)
+    result = read(key, default_value, this.diag)
     this.map.set(key, result)
     await result.done
     delete result.done
@@ -133,7 +151,7 @@ JSONCache.prototype.modify = async function(
   result.done = (
     async () => {
       await modify_func(result)
-      write(this.diag, key, result, timeout)
+      write(key, result, timeout, this.diag)
     }
   )()
   await result.done
index a4babc9..610f926 100644 (file)
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ The `json_cache` package exports a single constructor `JSONCache(diag)` which
 must be called with the `new` operator. The resulting cache object is intended
 to store arbitrary node.js JSON objects, which are read from disk files and
 modified (repeatedly) during the execution of your program. The cache tracks
-the on-disk path of the object, and writes it back to that path after a delay
+the on-disk pathname of the object, and writes it back to there after a delay
 time. A simple form of locking is implemented to support atomic modifications.
 
 ## Calling API
@@ -159,10 +159,21 @@ let deposit = (account, amount) => {
 }
 ```
 
+## About system crashes
 
+If the system crashes while writing the JSON file, a partially written file
+will unavoidably be left on the disk after the system reboots. To be robust
+against this situation, we write the modified JSON out to a temporary file
+first (whose pathname is the `key` value plus `'.temp'`), and then rename it
+into place. The only problem that can then happen is if the crash occurs after
+deleting the original but before renaming the temporary in its place. To guard
+against this, when opening the file we check for the requested file and then if
+that does not exist, we attempt to rename a temporary file in and then re-try.
 
-the `balances.json` modification inside the `transactions.json`
-modification. A consistent order must be chosen (in here, always acquiring the `bal
+We do not guarantee that atomic modifications spanning several files will be
+atomic across a system crash. The renaming system is only intended to guard
+against data loss. If desynchronization is an issue, then all files concerned
+should be scanned on system startup, and synchronization fixed up as necessary.
 
 ## About asynchronicity
 
@@ -229,17 +240,6 @@ single JSONCache instance for all `*.json` files with `diag` set to `true`.
 
 ## To be implemented
 
-At present, the modified JSON file is written over the previous one, and it is
-vulnerable to a system crash occuring leaving a partially written JSON file. In
-this case, data loss will occur, since the old contents of the file will not be
-accessible. We have designed a protocol to address this: the modified file is
-written to a new name, e.g. `example.json.tmp` instead of `example.json`, then
-the original is deleted and the new file renamed. If `example.json` does not
-exist at system startup, but `example.json.tmp` does, it means that a crash
-occurred, leaving a _fully written_ replacement file, but after deleting the
-original. In this case, it is safe to complete the renaming at system startup.
-We plan to implement this urgently, in the next version of the `json_cache`.
-
 It is intended that we will shortly add a timer function (or possibly just a
 function that the user should call periodically) to flush objects from the
 cache after a stale time, on the assumption that the object might not be