--- /dev/null
+= PRELIMINARY =
+
+This document is a description of a feature which is not yet implemented,
+added here to solicit feedback and to describe future plans. This document is
+subject to revision or withdrawal at any moment. Until this notice is
+removed, consider this entire document to be a figment of your imagination.
+
+= The Tahoe BackupDB =
+
+To speed up backup operations, Tahoe maintains a small database known as the
+"backupdb". This is used to avoid re-uploading files which have already been
+uploaded recently.
+
+This database lives in ~/.tahoe/private/backupdb.sqlite, and is a SQLite
+single-file database. It is used by the "tahoe backup" command, and by the
+"tahoe cp" command when the --use-backupdb option is included.
+
+The purpose of this database is specifically to manage the file-to-cap
+translation (the "upload" step). It does not address directory updates.
+
+The overall goal of optimizing backup is to reduce the work required when the
+source disk has not changed since the last backup. In the ideal case, running
+"tahoe backup" twice in a row, with no intervening changes to the disk, will
+not require any network traffic.
+
+This database is optional. If it is deleted, the worst effect is that a
+subsequent backup operation may use more effort (network bandwidth, CPU
+cycles, and disk IO) than it would have without the backupdb.
+
+== Schema ==
+
+The database contains the following tables:
+
+CREATE TABLE version
+(
+ version integer # contains one row, set to 0
+);
+
+CREATE TABLE last_upload
+(
+ path varchar(1024), # index, this is os.path.abspath(fn)
+ size integer, # os.stat(fn)[stat.ST_SIZE]
+ mtime number, # os.stat(fn)[stat.ST_MTIME]
+ fileid integer
+);
+
+CREATE TABLE caps
+(
+ fileid integer PRIMARY KEY AUTOINCREMENT,
+ filecap varchar(256), # URI:CHK:...
+ last_uploaded timestamp,
+ last_checked timestamp
+);
+
+CREATE TABLE keys_to_files
+(
+ readkey varchar(256) PRIMARY KEY, # index, AES key portion of filecap
+ fileid integer
+);
+
+Notes: if we extend the backupdb to assist with directory maintenance (see
+below), we may need paths in multiple places, so it would make sense to
+create a table for them, and change the last_upload table to refer to a
+pathid instead of an absolute path:
+
+CREATE TABLE paths
+(
+ path varchar(1024), # index
+ pathid integer PRIMARY KEY AUTOINCREMENT
+);
+
+== Operation ==
+
+The upload process starts with a pathname (like ~/.emacs) and wants to end up
+with a file-cap (like URI:CHK:...).
+
+The first step is to convert the path to an absolute form
+(/home/warner/emacs) and do a lookup in the last_upload table. If the path is
+not present in this table, the file must be uploaded. The upload process is:
+
+ 1. record the file's size and modification time
+ 2. upload the file into the grid, obtaining an immutable file read-cap
+ 3. add an entry to the 'caps' table, with the read-cap, and the current time
+ 4. extract the read-key from the read-cap, add an entry to 'keys_to_files'
+ 5. add an entry to 'last_upload'
+
+If the path *is* present in 'last_upload', the easy-to-compute identifying
+information is compared: file size and modification time. If these differ,
+the file must be uploaded. The row is removed from the last_upload table, and
+the upload process above is followed.
+
+If the path is present but the mtime differs, the file may have changed. If
+the size differs, then the file has certainly changed. The client will
+compute the CHK read-key for the file by hashing its contents, using exactly
+the same algorithm as the node does when it uploads a file (including
+~/.tahoe/private/convergence). It then checks the 'keys_to_files' table to
+see if this file has been uploaded before: perhaps the file was moved from
+elsewhere on the disk. If no match is found, the file must be uploaded, so
+the upload process above is follwed.
+
+If the read-key *is* found in the 'keys_to_files' table, then the file has
+been uploaded before, but we should consider performing a file check / verify
+operation to make sure we can skip a new upload. The fileid is used to
+retrieve the entry from the 'caps' table, and the last_checked timestamp is
+examined. If this timestamp is too old, a filecheck operation should be
+performed, and the file repaired if the results are not satisfactory. A
+"random early check" algorithm should be used, in which a check is performed
+with a probability that increases with the age of the previous results. E.g.
+files that were last checked within a month are not checked, files that were
+checked 5 weeks ago are re-checked with 25% probability, 6 weeks with 50%,
+more than 8 weeks are always checked. This reduces the "thundering herd" of
+filechecks-on-everything that would otherwise result when a backup operation
+is run one month after the original backup. The readkey can be submitted to
+the upload operation, to remove a duplicate hashing pass through the file and
+reduce the disk IO. In a future version of the storage server protocol, this
+could also improve the "streamingness" of the upload process.
+
+If the file's size and mtime match, the file is considered to be unmodified,
+and the last_checked timestamp from the 'caps' table is examined as above
+(possibly resulting in a filecheck or repair). The --no-timestamps option
+disables this check: this removes the danger of false-positives (i.e. not
+uploading a new file, because it appeared to be the same as a previously
+uploaded one), but increases the amount of disk IO that must be performed
+(every byte of every file must be hashed to compute the readkey).
+
+This algorithm is summarized in the following pseudocode:
+
+{{{
+ def backup(path):
+ abspath = os.path.abspath(path)
+ result = check_for_upload(abspath)
+ now = time.time()
+ if result == MUST_UPLOAD:
+ filecap = upload(abspath, key=result.readkey)
+ fileid = db("INSERT INTO caps (filecap, last_uploaded, last_checked)",
+ (filecap, now, now))
+ db("INSERT INTO keys_to_files", (result.readkey, filecap))
+ db("INSERT INTO last_upload", (abspath,current_size,current_mtime,fileid))
+ if result in (MOVED, ALREADY_UPLOADED):
+ age = now - result.last_checked
+ probability = (age - 1*MONTH) / 1*MONTH
+ probability = min(max(probability, 0.0), 1.0)
+ if random.random() < probability:
+ do_filecheck(result.filecap)
+ if result == MOVED:
+ db("INSERT INTO last_upload",
+ (abspath, current_size, current_mtime, result.fileid))
+
+
+ def check_for_upload(abspath):
+ row = db("SELECT (size,mtime,fileid) FROM last_upload WHERE path == %s"
+ % abspath)
+ if not row:
+ return check_moved(abspath)
+ current_size = os.stat(abspath)[stat.ST_SIZE]
+ current_mtime = os.stat(abspath)[stat.ST_MTIME]
+ (last_size,last_mtime,last_fileid) = row
+ if file_changed(current_size, last_size, current_mtime, last_mtime):
+ db("DELETE FROM last_upload WHERE fileid=%s" % fileid)
+ return check_moved(abspath)
+ (filecap, last_checked) = db("SELECT (filecap, last_checked) FROM caps" +
+ " WHERE fileid == %s" % last_fileid)
+ return ALREADY_UPLOADED(filecap=filecap, last_checked=last_checked)
+
+ def file_changed(current_size, last_size, current_mtime, last_mtime):
+ if last_size != current_size:
+ return True
+ if NO_TIMESTAMPS:
+ return True
+ if last_mtime != current_mtime:
+ return True
+ return False
+
+ def check_moved(abspath):
+ readkey = hash_with_convergence(abspath)
+ fileid = db("SELECT (fileid) FROM keys_to_files WHERE readkey == %s"%readkey)
+ if not fileid:
+ return MUST_UPLOAD(readkey=readkey)
+ (filecap, last_checked) = db("SELECT (filecap, last_checked) FROM caps" +
+ " WHERE fileid == %s" % fileid)
+ return MOVED(fileid=fileid, filecap=filecap, last_checked=last_checked)
+
+ def do_filecheck(filecap):
+ health = check(filecap)
+ if health < DESIRED:
+ repair(filecap)
+
+}}}