require 'ftools' require 'fsdb/compat' if RUBY_VERSION.to_f < 1.7 require 'fsdb/mutex' require 'fsdb/modex' require 'fsdb/file-lock' require 'fsdb/formats'

module FSDB include Formats

class Database

include Formats

class CacheEntry #:nodoc:

  attr_reader :version
  attr_accessor :file_handle

  def initialize
    @mutex      = Mutex.new
    @modex      = Modex.new
    @users      = 0

    stale!
  end

  # Yields to block that loads the object, if needed.
  # Called between #start_using and #stop_using.
  def object(mtime)
    @mutex.synchronize do
      if @object and mtime == @mtime
        if @check_time - mtime < 1
          # If we last checked the file during the same second as mtime, the
          # file might have been touched after we checked it, so we may have
          # to load it again. (Assume resolution of file mtime <= 1.)
          @check_time = Time.now
          yield @version
        end
      else
        @check_time = Time.now
        yield nil
      end

      @object
    end
  end

  def just_gimme_the_damn_object!
    @object
  end

  def stale!
    @check_time = nil
    @mtime      = nil
    @version    = nil
    @object     = nil
  end

  def update(new_mtime, version, object)
    # only called in @mutex or object_exclusive context, so no need to lock
    @check_time = new_mtime
    @mtime      = new_mtime
    @version    = version
    @object     = object
  end

  def start_using; Thread.exclusive {@users += 1}; end
  def stop_using;  Thread.exclusive {@users -= 1}; end

  # called in context of lock on db's @@cache_mutex, which is also
  # required for start_using.
  def unused?;    @users == 0; end

  # Protects object during #browse, #edit, and so on. Should be locked
  # as long as the object is being used. It's ok to lock the @mutex
  # within the context of a lock on @modex.
  def sync_object_shared do_when_first, do_when_last
    @modex.synchronize(Modex::SH, do_when_first, do_when_last, self) { yield }
  end

  def sync_object_exclusive
    @modex.synchronize(Modex::EX) { yield }
  end

end

META_PREFIX       = '..fsdb.meta.'

@@cache = {}                   # maps <file id> to <CacheEntry>
@@cache_mutex = Mutex.new      # protects access to @@cache hash

# The root directory of the db, to which paths are relative.
attr_reader :dir

# Create a new database object that accesses +dir+. Makes sure that the
# directory exists on disk, but doesn't create or open any other files.
def initialize dir
  @dir = File.expand_path(dir)
  File.makedirs(@dir)
end

# Create a new database object that accesses +path+ relative to the database
# directory. A process can have any number of dbs accessing overlapping dirs.
# The cost of creating an additional db is very low; its state is just the
# dir. Caching is done in structures owned by the Database class itself.
def subdb path
  self.class.new(File.join(@dir, path))
end

def inspect; "#<#{self.class}:#{dir}>"; end

# Convert a relative path (relative to the db dir) to an absolute path.
def absolute(path)
  File.join(@dir, path)
end

# Convert an absolute path to a unique key for the cache, raising
# MissingFileError if the file does not exist.
def get_file_id(abs_path)
  File.stat(abs_path).ino
rescue Errno::ENOENT
  raise MissingFileError, "Cannot find file at #{abs_path}"
rescue Errno::EINTR
  retry
end

class CreateFileError < StandardError; end

# Convert an absolute path to a unique key for the cache, creating the file
# if it does not exist. Raises CreateFileError if it can't be created.
def make_file_id(abs_path)
  File.makedirs(File.dirname(abs_path))
  File.stat(abs_path).ino
rescue Errno::ENOENT
  begin
    File.open(abs_path, "w") { |f| f.stat.ino }
  rescue StandardError => e
    raise CreateFileError, "Cannot create file at #{abs_path}:\n#{e}"
  end
end

# For housekeeping, so that stale entries don't result in unused, but
# uncollectable, CacheEntry objects.
def clear_entry(file_id)
  if file_id
    @@cache_mutex.synchronize do
      cache_entry = @@cache[file_id]
      @@cache.delete(file_id) if cache_entry and cache_entry.unused?
    end
  end
end

# Can be called occasionally to reduce memory footprint, esp. if cached
# objects are large and infrequently used.
def clear_cache
  @@cache_mutex.synchronize do
    @@cache.delete_if do |file_id, cache_entry|
      cache_entry.unused?
    end
  end
end

private

# Bring the object in from f, if necessary, and put it into the cache_entry.
# Directories are not cached, since they can be changed by insert/delete.
def cache_object(f, cache_entry)
  mtime = f.mtime
  cache_entry.object(mtime) do |cache_version|
    file_version = get_version_of(f)
    if file_version != cache_version or file_version == :directory
      cache_entry.update(mtime, file_version, load(f))
    end
  end
end

# used in context of read or write lock on f
def get_version_of(f)
  path = f.path
  if path.sub!(/(?=[^\/]+$)/, META_PREFIX)
    File.open(path, "rb") do |meta|
      meta.sysread(4).unpack("N")[0]
    end
  else
    :directory
  end
rescue
  :never_written
end

# used in context of write lock on f. Returns new version.
def inc_version_of(f, cache_entry)
  path = f.path
  if path.sub!(/(?=[^\/]+$)/, META_PREFIX)
    version = cache_entry.version
    case version
    when Fixnum
      version = (version + 1) & 0x3FFFFFFF # keep it a Fixnum
    else # :never_written
      version = 0
    end

    File.open(path, "wb") do |meta|
      meta.syswrite([version].pack("N"))
    end

    version
  else
    :directory
  end
end

# used in context of write lock on f
def del_version_of(f)
  path = f.path
  if path.sub!(/(?=[^\/]+$)/, META_PREFIX)
    File.delete(path) rescue nil
  end
end

def use_cache_entry(file_id)
  cache_entry = nil
  @@cache_mutex.synchronize do
    cache_entry = @@cache[file_id] ||= CacheEntry.new
    cache_entry.start_using
  end
  yield cache_entry
ensure
  cache_entry.stop_using if cache_entry
end

# Lock path for shared (read) use. Other threads will wait to modify it.
def object_shared(file_id, do_when_first, do_when_last)
  use_cache_entry(file_id) do |cache_entry|
    cache_entry.sync_object_shared(do_when_first, do_when_last) do
      yield cache_entry
    end
  end
end

# Lock path for exclusive (write) use. Other threads will wait to access it.
def object_exclusive(file_id)
  use_cache_entry(file_id) do |cache_entry|
    cache_entry.sync_object_exclusive do
      yield cache_entry
    end
  end
end

# Opens path for reading ("r") with a shared lock for
# the duration of the block.
def open_read_lock(path)
  File.open(path, "r") do |f|
    f.lock_shared
    identify_file_type(f, path)
    yield f
  end
rescue Errno::ENOENT
  raise MissingFileError
end

# Opens path for writing and reading ("r+") with an exclusive lock for
# the duration of the block.
def open_write_lock(path)
  is_dir = path =~ DIR_PAT
  if is_dir
    open_read_lock(path) { |f| yield f }
  else
    File.open(path, "r+") do |f|
      f.lock_exclusive
      identify_file_type(f, path)
      yield f
    end
  end
rescue Errno::ENOENT
  raise MissingFileError
end

public

# Raised when open_read_lock or open_write_lock cannot find the file.
class MissingFileError < Errno::ENOENT; end

DIR_PAT = /\/$/

# Raised in a transaction that takes a block (#browse, #edit, #replace,
# or #delete) to roll back the state of the object.
class AbortedTransaction < StandardError; end

# Abort the current transaction (#browse, #edit, #replace, or #delete, roll
# back the state of the object, and return nil from the transaction.
#
# In the #browse case, the only effect is to end the transaction.
#
# Note that any exception that breaks out of the transaction will
# also abort the transaction, and be re-raised.
def abort;      raise AbortedTransaction; end
def self.abort; raise AbortedTransaction; end

# Raised, by default, when #browse or #edit can't find the object.
class MissingObjectError < StandardError; end

# Called when #browse doesn't find anything at the path.
# The original caller's block is available to be yielded to.
def default_browse(path); object_missing(path) {|x| yield x}; end

# Called when #edit doesn't find anything at the path.
# The original caller's block is available to be yielded to.
def default_edit(path);   object_missing(path) {|x| yield x}; end

# The default behavior of both #default_edit and #default_browse. Raises
# MissingObjectError by default, but it can yield to the original block.
def object_missing(path)
  raise MissingObjectError, "No object at #{path}"
end

# Called when #fetch doesn't find anything at the path.
# Default definition just returns nil.
def default_fetch(path); nil; end

#-- Transactions --

# Note: "path" argument is always relative to database dir.
# See fsdb/util.rb for path validation and normalization.

# Browse the object. Yields the object to the caller's block, and returns
# the value of the block.
# Changes to the object are not persistent, but should be avoided (they
# *will* be seen by other threads, but only in the current process, and
# only until the cache is cleared).
def browse(path = "/")                # :yields: object
  abs_path = absolute(path)
  file_id = get_file_id(abs_path)

  ## put these outside method, and pass in params?
  do_when_first = proc do |cache_entry|
    raise if cache_entry.file_handle

    begin
      f = File.open(abs_path, "r")
    rescue Errno::ENOENT
      raise MissingFileError
    rescue Errno::EINTR
      retry
    end

    cache_entry.file_handle = f
    f.lock_shared
    identify_file_type(f, path) ## could avoid if cache_object says so
    object = cache_object(f, cache_entry)
  end

  do_when_last = proc do |cache_entry|
    # last one out closes the file
    f = cache_entry.file_handle
    if f
      f.close
      cache_entry.file_handle = nil
    end
  end

  object_shared(file_id, do_when_first, do_when_last) do |cache_entry|
    object = cache_entry.just_gimme_the_damn_object!
    yield object
  end
rescue MissingFileError
  clear_entry(file_id)
  default_browse(path) {|x| yield x}
rescue AbortedTransaction
end

# Edit the object in place. Changes to the yielded object made within
# the caller's block become persistent. Returns the value of the block.
# Note that assigning to the block argument variable does not change
# the state of the object. Use destructive methods on the object.
def edit(path = "/")
  abs_path = absolute(path)
  file_id = get_file_id(abs_path)
  object_exclusive file_id do |cache_entry|
    open_write_lock(abs_path) do |f|
      object = cache_object(f, cache_entry)
      result = yield object
      dump(object, f)
      cache_entry.update(f.mtime, inc_version_of(f, cache_entry), object)
      result
    end
  end
rescue MissingFileError
  clear_entry(file_id)
  default_edit(path) {|x| yield x}
rescue AbortedTransaction
  clear_entry(file_id) # The cached object may have edits which are not valid.
  nil
rescue Exception
  clear_entry(file_id)
  raise
end

# Replace the yielded object (or nil) with the return value of the block.
# Returns the object that was replaced.
# Use replace instead of edit when accessing db over a drb connection.
# Use replace instead of insert if the path needs to be protected while
# the object is prepared for insertion.
# Note that (unlike #edit) destructive methods on the object do not
# persistently change the state of the object, unless the object is
# the return value of the block.
def replace(path)
  abs_path = absolute(path)
  file_id = make_file_id(abs_path)
  object_exclusive file_id do |cache_entry|
    open_write_lock(abs_path) do |f|
      old_object = f.stat.zero? ? nil : cache_object(f, cache_entry)
      object = yield old_object
      dump(object, f)
      cache_entry.update(f.mtime, inc_version_of(f, cache_entry), object)
      old_object
    end
  end
rescue AbortedTransaction
  clear_entry(file_id) # The cached object may have edits which are not valid.
  nil
rescue Exception
  clear_entry(file_id)
  raise
end

# Insert the object, replacing anything at the path. Returns the object.
# (The object remains a <i>local copy</i>, distinct from the one which will be
# given out when accessing the path through database transactions.)
def insert(path, object)
  abs_path = absolute(path)
  file_id = make_file_id(abs_path)
  object_exclusive file_id do |cache_entry|
    open_write_lock(abs_path) do |f|
      dump(object, f)
      cache_entry.update(f.mtime, inc_version_of(f, cache_entry), object)
      object
    end
  end
ensure
  clear_entry(file_id) # no one else can get this copy of object
end
alias []= insert

# Delete the object from the db. If a block is given, yields the object (or
# nil if none) before deleting it from the db (but before releasing the lock
# on the path), and returns the value of the block. Otherwise, just returns
# the object (or nil).
def delete(path)                  # :yields: object
  abs_path = absolute(path)
  file_id = get_file_id(abs_path)
  object_exclusive file_id do |cache_entry|
    open_write_lock(abs_path) do |f|
      object = cache_object(f, cache_entry)
      result = block_given? ? (yield object) : object
      File.delete(abs_path) rescue Dir.delete(abs_path)
      cache_entry.stale!
      del_version_of(f)
      result
    end
  end
rescue MissingFileError
  nil
rescue AbortedTransaction
ensure
  clear_entry(file_id)
end

# Fetch a *copy* of the object at the path for private use by the current
# thread/process. (The copy is a *deep* copy.)
def fetch(path = "/")
  abs_path = absolute(path)
  file_id = get_file_id(abs_path)
  object_exclusive file_id do |cache_entry|
    open_read_lock(abs_path) do |f|
      object = cache_object(f, cache_entry)
      cache_entry.stale!
      object
    end
  end
rescue MissingFileError
  clear_entry(file_id)
  default_fetch(path)
end
alias [] fetch

#-- IO methods --

# Returns object read from f (must be open for reading).
def load(f)
  f.format.load(f)
end

# Writes object to f (must be open for writing).
def dump(object, f)
  f.format.dump(object, f)
end

FORMATS = [TEXT_FORMAT, MARSHAL_FORMAT].freeze

def formats
  @formats || FORMATS
end

def formats=(fmts)
  @formats = fmts
end

def identify_file_type(f, path)
  format = find_format(path)
  f.binmode if format.binary?
  f.format = format
end

def find_format(path)
  DIR_FORMAT === path ? DIR_FORMAT : formats.find {|fmt| fmt === path}
end

end

end # module FSDB