Backing Up to Versioned Filenames

Problem

You want to copy a file to a numbered backup before overwriting the original file. More generally: rather than overwriting an existing file, you want to use a new file whose name is based on the original filename.

Solution

Use String#succ to generate versioned suffixes for a filename until you find one that doesn't already exist:

class File def File.versioned_filename(base, first_suffix='.0') suffix = nil filename = base while File.exists?(filename) suffix = (suffix ? suffix.succ : first_suffix) filename = base + suffix end return filename end end 5.times do |i| name = File.versioned_filename('filename.txt') open(name, 'w') { |f| f << "Contents for run #{i}" } puts "Created #{name}" end # Created filename.txt # Created filename.txt.0 # Created filename.txt.1 # Created filename.txt.2 # Created filename.txt.3

If you want to copy or move the original file to the versioned filename as a prelude to writing to the original file, include the ftools library to add the class methods File. copy and File.move. Then call versioned_filename and use File.copy or File.move to put the old file in its new place:

require 'ftools' class File def File.to_backup(filename, move=false) new_filename = nil if File.exists? filename new_filename = File. versioned_filename(filename) File.send(move ? :move : :copy, filename, new_filename) end return new_filename end end

Let's back up filename.txt a couple of times. Recall from earlier that the files filename.txt.[0-3] already exist.

File.to_backup('filename.txt') # => "filename.txt.4" File.to_backup('filename.txt') # => "filename.txt.5"

Now let's do a destructive backup:

File.to_backup('filename.txt', true) # => "filename.txt.6" File.exists? 'filename.txt' # => false

You can't back up what doesn't exist:

File.to_backup('filename.txt') # => nil

 

Discussion

If you anticipate more than 10 versions of a file, you should add additional zeroes to the initial suffix. Otherwise, filename.txt.10 will sort before filename.txt.2 in a directory listing. A commonly used suffix is ".000".

200.times do |i| name = File.versioned_filename('many_versions.txt', '.000') open(name, 'w') { |f| f << "Contents for run #{i}" } puts "Created #{name}" end # Created many_versions.txt # Created many_versions.txt.000 # Created many_versions.txt.001 # … # Created many_versions.txt.197 # Created many_versions.txt.198

The result of versioned_filename won't be trustworthy if other threads or processes on your machine might be trying to write the same file. If this is a concern for you, you shouldn't be satisfied with a negative result from File.exists?. In the time it takes to open that file, some other process or thread might open it before you. Once you find a file that doesn't exist, you must get an exclusive lock on the file before you can be totally certain it's okay to use.

Here's how such an implementation might look on a Unix system. The versioned_filename methods return the name of a file, but this implementation needs to return the actual file, opened and locked. This is the only way to avoid a race condition between the time the method returns a filename, and the time you open and lock the file.

class File def File. versioned_file( base, first_suffix='.0', access_mode='w') suffix = file = locked = nil filename = base begin suffix = (suffix ? suffix.succ : first_suffix) filename = base + suffix unless File.exists? filename file = open(filename, access_mode) locked = file.flock(File::LOCK_EX | File::LOCK_NB) file.close unless locked end end until locked return file end end File.versioned_file('contested_file') # => # File.versioned_file('contested_file') # => # File.versioned_file('contested_file') # => #

The construct begin…end until locked creates a loop that runs at least once, and continues to run until the variable locked becomes true, indicating that a file has been opened and successfully locked.

See Also

Категории