From c88333e19b459685f57ba3f246c3fdd684681542 Mon Sep 17 00:00:00 2001 From: Matthew Bloch Date: Wed, 5 Nov 2014 14:00:45 +0000 Subject: Split out -prune from -snapshot, tested the latter on various live setups. --- lib/byteback/backup_directory.rb | 175 ++++++++++++++++++++++++-------------- lib/byteback/disk_free.rb | 41 --------- lib/byteback/disk_free_history.rb | 110 ++++++++++++++++++++++++ 3 files changed, 221 insertions(+), 105 deletions(-) delete mode 100644 lib/byteback/disk_free.rb create mode 100755 lib/byteback/disk_free_history.rb (limited to 'lib/byteback') diff --git a/lib/byteback/backup_directory.rb b/lib/byteback/backup_directory.rb index f0ceabc..e5ab8c0 100644 --- a/lib/byteback/backup_directory.rb +++ b/lib/byteback/backup_directory.rb @@ -1,13 +1,113 @@ module Byteback + + # Represents a particular timestamped backup directory + class Snapshot + class << self + # What order to remove snapshots in to regain disk space? + # + # Order backups by their closeness to defined backup times, which are + # listed in a set order (i.e. today's backup is more important than yesterday's). + # + BACKUP_IMPORTANCE = [0, 1, 2, 3, 7, 14, 21, 28, 56, 112] + + def sort_by_importance(snapshots_unsorted, now=Time.now) + snapshots_sorted = [] + + # FIXME: takes about a minute to sort 900 items, + # seems like that ought to be quicker than O(n^2) + # + while !snapshots_unsorted.empty? + BACKUP_IMPORTANCE.each do |days| + target_time = now - (days*86400) + closest = snapshots_unsorted.inject(nil) do |best, snapshot| + if best.nil? || (snapshot.time-target_time).abs < (best.time-target_time).abs + snapshot + else + best + end + end + break unless closest + snapshots_sorted << snapshots_unsorted.delete(closest) + end + end + + snapshots_sorted + end + end + + attr_reader :backup_directory, :path + + def initialize(backup_directory, snapshot_path) + @backup_directory = backup_directory + @path = snapshot_path + time # throws ArgumentError if it can't parse + nil + end + + def time + Time.parse(path) + end + + def <=>(b) + time <=> b.time + end + + def create!(from) + system_no_error("btrfs subvolume snapshot #{from} #{path}") + end + + def delete! + system_no_error("btrfs subvolume delete #{path}") + end + + # Returns the size of the given snapshot (runs du, may be slow) + # + # Would much prefer to take advantage of this feature: + # http://dustymabe.com/2013/09/22/btrfs-how-big-are-my-snapshots/ + # but it's not currently in Debian/wheezy. + # + def du + `du -s -b #{path}`.to_i + end + + protected + + def system_no_error(*args) + args[-1] += " > /dev/null" unless @verbose + raise RuntimeError.new("Command failed: "+args.join(" ")) unless + system(*args) + end + end + # Represent a directory full of backups where "current" is a subvolume # which is snapshotted to frozen backup directories called e.g. # "yyyy-mm-ddThh:mm+zzzz". # class BackupDirectory + class << self + # Return all backup directories + # + def all + Dir.new(ENV['HOME']).entries.map do |entry| + next if entry[0] == '.' + name = File.expand_path(ENV['HOME'] + "/" + entry) + File.directory?(name + "/current") ? BackupDirectory.new(name) : nil + end. + compact + end + + # Returns every snapshot in every backup directory + # + def all_snapshots + all.map { |dir| dir.snapshots }.flatten + end + end + attr_reader :dir def initialize(dir) @dir = Dir.new(dir) + raise Errno::ENOENT unless File.directory?(dir) current end @@ -21,83 +121,30 @@ module Byteback # Return an array of Times representing the current list of # snapshots. # - def snapshot_times + def snapshots @dir.entries.map do |entry| + next if entry[0] == '.' || entry == 'current' + snapshot_path = File.expand_path(@dir.path + "/" + entry) + next unless File.directory?(snapshot_path) begin - Time.parse(entry) - rescue ArgumentError => error + Snapshot.new(self, snapshot_path) + rescue ArgumentError => ae + # directory name must represent a parseable Time nil end end. - compact. - sort - end - - # What order to remove snapshots in to regain disk space? - # - # Order backups by their closeness to defined backup times, which are - # listed in a set order (i.e. today's backup is more important than yesterday's). - # - BACKUP_IMPORTANCE = [0, 1, 2, 3, 7, 14, 21, 28, 56, 112] - def snapshot_times_by_importance - now = Time.now - snapshot_times_unsorted = snapshot_times - snapshot_times_sorted = [] - while !snapshot_times_unsorted.empty? - BACKUP_IMPORTANCE.each do |days| - target_time = now + (days*86400) - closest = snapshot_times_unsorted.inject(nil) do |best, time| - if best.nil? || (time-target_time).abs < (best-target_time).abs - time - else - best - end - end - break unless closest - snapshot_times_sorted << snapshot_times_unsorted.delete(closest) - end - end - snapshot_times_sorted - end - - # Returns the size of the given snapshot (runs du, may be slow) - # - # Would much prefer to take advantage of this feature: - # http://dustymabe.com/2013/09/22/btrfs-how-big-are-my-snapshots/ - # but it's not currently in Debian/wheezy. - # - def snapshot_size(time=snapshot_times.last) - `du -s -b #{snapshot_path(time)}`.to_i - end - - def average_snapshot_size(number=10) - snapshot_times.sort[0..number].inject(0) { |total, time| snapshot_size(time) } / number + compact end # Create a new snapshot of 'current' # - def new_snapshot! - system_no_error("btrfs subvolume snapshot -r #{current.path} #{snapshot_path}") - end - - def delete_snapshot!(time) - system_no_error("btrfs subvolume delete #{snapshot_path(time)}") + def new_snapshot!(time = Time.now) + snapshot_path = time.strftime("%Y-%m-%dT%H:%M%z") + Snapshot.new(self, snapshot_path).create!(current.path) end def current Dir.new("#{dir.path}/current") end - - def snapshot_path(time=Time.now) - "#{dir.path}/#{time.strftime("%Y-%m-%dT%H:%M%z")}" - end - - protected - - def system_no_error(*args) - args[-1] += " > /dev/null" unless @verbose - raise RuntimeError.new("Command failed: "+args.join(" ")) unless - system(*args) - end end end diff --git a/lib/byteback/disk_free.rb b/lib/byteback/disk_free.rb deleted file mode 100644 index 33952a3..0000000 --- a/lib/byteback/disk_free.rb +++ /dev/null @@ -1,41 +0,0 @@ - -module Byteback - # Icky way to find out free disc space on our mount - # - class DiskFree - def initialize(mount) - @mount = mount - end - - def total - all[2] - end - - def used - all[3] - end - - def available - all[4] - end - - def fraction_used - disk_device, disk_fs, disk_total, disk_used, disk_available, *rest = all - disk_used.to_f / disk_available - end - - protected - - def all - disk_device, disk_fs, disk_total, disk_used, disk_available, *rest = - df. - split("\n")[1]. - split(/\s+/). - map { |i| /^[0-9]+$/.match(i) ? i.to_i : i } - end - - def df - `/bin/df -T -P -B1 #{@mount}` - end - end -end diff --git a/lib/byteback/disk_free_history.rb b/lib/byteback/disk_free_history.rb new file mode 100755 index 0000000..ba66863 --- /dev/null +++ b/lib/byteback/disk_free_history.rb @@ -0,0 +1,110 @@ +#!/usr/bin/ruby + +require 'sys/filesystem' + +module Byteback + class DiskFreeReading < Struct.new(:fsstat, :time) + def initialize(fsstat,time=Time.now) + self.fsstat = fsstat + self.time = time + end + + # helper method to return %age of disc space free + # + def percent_free + fsstat.blocks_available * 100 / fsstat.blocks + end + end + + # A simple round-robin list to store a short history of a given mount + # point's disk space history. + # + class DiskFreeHistory + MINIMUM_INTERVAL = 5*60 # don't take readings more than 5 mins apart + MAXIMUM_AGE = 7*24*60*60 # delete readings after a week + + # Initialize a new list storing the disc space history for the given + # mount point. + # + def initialize(mountpoint, history_file=nil) + history_file = "#{mountpoint}/.disk_free_history" unless + history_file + @history_file = history_file + @mountpoint = mountpoint + load! + end + + # Take a new reading + # + def new_reading! + reading = DiskFreeReading.new(Sys::Filesystem.stat(@mountpoint)) + + # Don't record a new reading if it's exactly the same as last time, + # and less than the minimum interval. + # + return nil if @list.last && + @list.last.fsstat.blocks_available == reading.fsstat.blocks_available + Time.now - @list.last.time < MINIMUM_INTERVAL + + @list << reading + + save! + end + + def list + load! unless @list + @list + end + + def gradient(last_n_seconds, &value_from_reading) + value_from_reading ||= proc { |r| r.fsstat.blocks_available } + earliest = Time.now - last_n_seconds + + total = 0 + readings = 0 + later_reading = nil + + list.reverse.each do |reading| + if later_reading + difference = + value_from_reading.call(reading) - + value_from_reading.call(later_reading) + total += difference + p difference + end + break if reading.time < earliest + readings += 1 + later_reading = reading + end + + total / readings + end + + protected + + def load! + begin + File.open(@history_file) do |fh| + @list = Marshal.restore(fh.read(1000000)) + end + rescue Errno::ENOENT, TypeError => err + @list = [] + new_reading! + end + end + + def save! + list.shift while Time.now - list.first.time > MAXIMUM_AGE + + tmp = "@history_file.#{$$}.#{rand(9999999999)}" + begin + File.open(tmp, "w") do |fh| + fh.write(Marshal.dump(list)) + File.rename(tmp, @history_file) + end + ensure + File.unlink(tmp) if File.exists?(tmp) + end + end + end +end -- cgit v1.2.1