445 lines
13 KiB
Crystal
445 lines
13 KiB
Crystal
require "file_utils"
|
||
|
||
# Basic indexes for 1-to-1 relations.
|
||
# Uncached version.
|
||
#
|
||
# ```
|
||
# cars_by_name = car_database.new_uncached_index "name", &.name
|
||
# ```
|
||
#
|
||
# This index provides a file-system representation, enabling the administrators to
|
||
# select a value based on its index. The following example presents an index named "id"
|
||
# with some data indexed by an UUID attribute.
|
||
#
|
||
# ```plain
|
||
# storage
|
||
# ├── data
|
||
# │ ├── 0000000000
|
||
# │ ├── 0000000001
|
||
# │ └── 0000000002
|
||
# ├── indices
|
||
# │ └── by_id <- this is an example of index named "id"
|
||
# │ ├── 6e109b82-25de-4250-9c67-e7e8415ad5a7 -> ../../data/0000000000
|
||
# │ ├── 2080131b-97d7-4300-afa9-55b93cdfd124 -> ../../data/0000000001
|
||
# │ └── 8b4e83e3-ef95-40dc-a6e5-e6e697ce6323 -> ../../data/0000000002
|
||
# ```
|
||
#
|
||
# NOTE: no cache, thus considered as *slow* for creation, deletion **and retrieval**.
|
||
# NOTE: see `IndexCached` for a cached version, faster for retrieval.
|
||
# NOTE: for fast operations without fs representation, see `IndexRAMOnly`.
|
||
class DODB::Trigger::Index(V) < DODB::Trigger(V)
|
||
# Name of the index, such as *id* or *color* for example.
|
||
# This is an arbitrary value, mostly to create the index directory.
|
||
#
|
||
# NOTE: used for internal operations.
|
||
property name : String
|
||
|
||
# Procedure to retrieve the index attribute from the value, used for **internal operations**.
|
||
property key_proc : Proc(V, String | NoIndex)
|
||
|
||
# Root database directory, used for **internal operations**.
|
||
getter storage_root : String
|
||
|
||
# Reference to the database instance, used for **internal operations**.
|
||
@storage : DODB::Storage(V)
|
||
|
||
# To create an index from a database, use `DODB::Storage#new_index` to create
|
||
# a cached index, `DODB::Storage#new_uncached_index` for an uncached index or
|
||
# `DODB::Storage#new_RAM_index` for a RAM-only index.
|
||
#
|
||
# WARNING: this is an internal operation, do not instanciate an index by hand.
|
||
def initialize(@storage : DODB::Storage(V), @storage_root : String, @name : String, @key_proc : Proc(V, String | NoIndex))
|
||
Dir.mkdir_p trigger_directory
|
||
end
|
||
|
||
def check!(key : String, value : V, old_value : V?)
|
||
index_key = key_proc.call value
|
||
|
||
return if index_key.is_a? NoIndex
|
||
|
||
symlink = file_path_index index_key.to_s
|
||
|
||
if ::File.symlink? symlink
|
||
# In case both old and new values are pointing to the same key,
|
||
# this is not considered a collision.
|
||
if old_value
|
||
old_key = key_proc.call old_value
|
||
return if index_key == old_key
|
||
end
|
||
|
||
raise IndexOverload.new "index '#{@name}' is overloaded for key '#{key}', file #{symlink} exists"
|
||
end
|
||
end
|
||
|
||
# :inherit:
|
||
def index(key : String, value : V)
|
||
index_key = key_proc.call value
|
||
return if index_key.is_a? NoIndex
|
||
|
||
symlink = file_path_index index_key
|
||
|
||
Dir.mkdir_p ::File.dirname symlink
|
||
|
||
::File.symlink get_data_symlink_index(key), symlink
|
||
end
|
||
|
||
# :inherit:
|
||
def deindex (key : String, value : V)
|
||
index_key = key_proc.call value
|
||
return if index_key.is_a? NoIndex
|
||
|
||
symlink = file_path_index index_key
|
||
|
||
begin
|
||
::File.delete symlink
|
||
rescue File::NotFoundError
|
||
end
|
||
end
|
||
|
||
# Gets the key (ex: 343) for an entry in the DB from an indexed value, used for **internal operations**.
|
||
#
|
||
# Reads the link in `db/indices/by_#{name}/<index>`.
|
||
#
|
||
# Useful for internal purposes, to retrieve a value use `#get`.
|
||
#
|
||
# ```
|
||
# internal_database_key_for_the_corvet = cars_by_name.get_key "Corvet"
|
||
# ```
|
||
#
|
||
# NOTE: used for internal operations.
|
||
def get_key(index : String) : Int32
|
||
get_key_on_fs index
|
||
end
|
||
|
||
# Gets data from an indexed value (throws an exception on a missing entry).
|
||
#
|
||
# ```
|
||
# corvet = cars_by_name.get "Corvet"
|
||
# ```
|
||
#
|
||
# WARNING: throws an exception if the value isn't found.
|
||
# NOTE: for a safe version, use `#get?`.
|
||
def get(index : String) : V
|
||
@storage[get_key index]
|
||
end
|
||
|
||
# Gets data from an indexed value without throwing an exception on a missing entry.
|
||
#
|
||
# ```
|
||
# corvet = cars_by_name.get? "Corvet"
|
||
# ```
|
||
#
|
||
# NOTE: safe version of `#get`, returns a *nil* value in case of a missing entry instead of an exception.
|
||
def get?(index : String) : V?
|
||
get index
|
||
rescue MissingEntry
|
||
nil
|
||
end
|
||
|
||
# Gets data from an indexed value (thread-safe via two file locks) and gives it to a provided block of code.
|
||
#
|
||
# WARNING: should be thread-safe only between other `#safe_get` and `#safe_get?` calls,
|
||
# index creations and deletions do not use the same locks!
|
||
# NOTE: on exception, releases all locks.
|
||
def safe_get(index : String) : Nil
|
||
@storage.request_lock @name, index
|
||
internal_key = get_key(index).to_s
|
||
@storage.request_lock internal_key
|
||
|
||
begin
|
||
yield get index
|
||
rescue e
|
||
# On exception, returns the exception after releasing locks.
|
||
@storage.release_lock internal_key
|
||
@storage.release_lock @name, index
|
||
raise e
|
||
end
|
||
|
||
@storage.release_lock internal_key
|
||
@storage.release_lock @name, index
|
||
end
|
||
|
||
# Same as `#safe_get` but doesn't throw an exception on a missing value
|
||
# (provided block of code receives a *nil* value).
|
||
#
|
||
# WARNING: should be thread-safe only between other `#safe_get` and `#safe_get?` calls,
|
||
# index creations and deletions do not use the same locks!
|
||
# NOTE: on exception, releases all locks.
|
||
def safe_get?(index : String, &block : Proc(V | Nil, Nil)) : Nil
|
||
safe_get index, &block
|
||
rescue MissingEntry
|
||
yield nil
|
||
end
|
||
|
||
# Reads the indexed symlink to find its related key, used for **internal operations**.
|
||
#
|
||
# For example, for a car indexed by its name:
|
||
#
|
||
# ```
|
||
# storage
|
||
# ├── data
|
||
# │ └── 0000000343
|
||
# └── indices
|
||
# └── by_name
|
||
# └── Corvet -> ../../data/0000000343
|
||
# ```
|
||
#
|
||
# `#get_key_on_fs` reads the *storage/indices/by_name/Corvet* symlink and gets
|
||
# the name of the data file ("000000343") and converts it in an integer,
|
||
# which is the key in the database.
|
||
#
|
||
# NOTE: used for internal operations.
|
||
def get_key_on_fs(index : String) : Int32
|
||
file_path = file_path_index index
|
||
raise MissingEntry.new(@name, index) unless ::File.symlink? file_path
|
||
::File.readlink(file_path).sub(/^.*\//, "").to_i
|
||
end
|
||
|
||
# Updates a value based on its indexed attribute (which must not have changed).
|
||
#
|
||
# ```
|
||
# # Update the car "corvet" in the database.
|
||
# car_by_name.update corvet
|
||
# ```
|
||
# WARNING: in case the indexed attribute has changed, use `#update(index, value)`.
|
||
def update(new_value : V)
|
||
index = key_proc.call new_value
|
||
|
||
raise Exception.new "new value is not indexable" if index.is_a? NoIndex
|
||
|
||
update index, new_value
|
||
end
|
||
|
||
# Updates a value based on its indexed attribute (which may have changed).
|
||
#
|
||
# ```
|
||
# # Update the car "corvet" in the database.
|
||
# car_by_name.update "Corvet", corvet
|
||
# ```
|
||
# NOTE: in case the indexed attribute hasn't changed, you may prefer `#update(value)`.
|
||
def update(index : String, new_value : V)
|
||
key = get_key index
|
||
|
||
@storage[key] = new_value
|
||
end
|
||
|
||
# Updates a value. Creates it if necessary.
|
||
#
|
||
# ```
|
||
# # Update or create the car "corvet" in the database.
|
||
# car_by_name.update_or_create corvet
|
||
# ```
|
||
# WARNING: use `#update_or_create(index, value)` if the indexed value may have changed.
|
||
def update_or_create(new_value : V)
|
||
update new_value
|
||
rescue MissingEntry
|
||
@storage << new_value
|
||
end
|
||
|
||
# Same as `#update_or_create(value)` but handles changed indexes.
|
||
#
|
||
# ```
|
||
# # Update or create the car named "Corvet" in the database.
|
||
# # Its name may have changed in the object "corvet".
|
||
# car_by_name.update_or_create "Corvet", corvet
|
||
# ```
|
||
# NOTE: safe version in case the index has changed.
|
||
def update_or_create(index : String, new_value : V)
|
||
update index, new_value
|
||
rescue MissingEntry
|
||
@storage << new_value
|
||
end
|
||
|
||
# Deletes a value based on its index.
|
||
#
|
||
# ```
|
||
# # Deletes the car named "Corvet".
|
||
# car_by_name.delete "Corvet"
|
||
# ```
|
||
# WARNING: may throw a MissingEntry exception.
|
||
def delete(index : String)
|
||
key = get_key index
|
||
|
||
@storage.delete key
|
||
end
|
||
|
||
# Deletes a value based on its index, but do ignores a MissingEntry error.
|
||
#
|
||
# ```
|
||
# # Deletes the car named "Corvet" (no MissingEntry exception if the car doesn't exist).
|
||
# car_by_name.delete? "Corvet"
|
||
# ```
|
||
def delete?(v : String)
|
||
delete v
|
||
rescue MissingEntry
|
||
nil
|
||
end
|
||
|
||
# :inherit:
|
||
def trigger_directory : String
|
||
"#{@storage_root}/indices/by_#{@name}"
|
||
end
|
||
|
||
# FIXME: Now that it’s being used outside of this class, name it properly.
|
||
def file_path_index(index_key : String)
|
||
"#{trigger_directory}/#{index_key}"
|
||
end
|
||
|
||
# Creates the relative path to the data from the indexing directory.
|
||
private def get_data_symlink_index(key : String)
|
||
"../../data/#{key}"
|
||
end
|
||
end
|
||
|
||
# Basic indexes for 1-to-1 relations.
|
||
# Cached version.
|
||
#
|
||
# ```
|
||
# cars_by_name = car_database.new_index "name", &.name
|
||
# ```
|
||
#
|
||
# The cache makes this index fast and since the index doesn't contain
|
||
# the full value but just an attribute and a key, memory usage is still reasonable.
|
||
#
|
||
# A few file-system operations are required on index creation and deletion,
|
||
# thus this version still is slow for both these operations.
|
||
#
|
||
# ```plain
|
||
# storage
|
||
# ├── data
|
||
# │ ├── 0000000000
|
||
# │ ├── 0000000001
|
||
# │ └── 0000000002
|
||
# ├── indices
|
||
# │ └── by_id <- this is an example of index named "id"
|
||
# │ ├── 6e109b82-25de-4250-9c67-e7e8415ad5a7 -> ../../data/0000000000
|
||
# │ ├── 2080131b-97d7-4300-afa9-55b93cdfd124 -> ../../data/0000000001
|
||
# │ └── 8b4e83e3-ef95-40dc-a6e5-e6e697ce6323 -> ../../data/0000000002
|
||
# ```
|
||
#
|
||
# NOTE: cached, reasonable amount of memory used since it's just an index.
|
||
# NOTE: fast for retrieval, slow for index creation and deletion (fs operations).
|
||
# NOTE: see `DODB::Trigger::Index` for an uncached version, even less memory-hungry.
|
||
# NOTE: for fast operations without fs representation, see `IndexRAMOnly`.
|
||
class DODB::Trigger::IndexCached(V) < DODB::Trigger::Index(V)
|
||
# This hash contains the relation between the index key and the data key, used for
|
||
# **internal operations**.
|
||
#
|
||
# WARNING: used for internal operations, do not change its content or access it directly.
|
||
property data = Hash(String, Int32).new
|
||
|
||
def check!(key : String, value : V, old_value : V?)
|
||
index_key = key_proc.call value
|
||
return if index_key.is_a? NoIndex
|
||
|
||
if data[index_key]?
|
||
# In case both old and new values are pointing to the same key,
|
||
# this is not considered a collision.
|
||
if old_value
|
||
old_key = key_proc.call old_value
|
||
return if index_key == old_key
|
||
end
|
||
|
||
raise IndexOverload.new "index '#{@name}' is overloaded for key '#{key}'"
|
||
end
|
||
end
|
||
|
||
def clear_cache!
|
||
data.clear
|
||
end
|
||
|
||
# Clears the cache and removes the `#trigger_directory`.
|
||
def nuke_trigger
|
||
super
|
||
clear_cache!
|
||
end
|
||
|
||
# Indexes the value on the file-system as `DODB::Trigger::Index#index` but also puts the index in a cache.
|
||
#
|
||
# NOTE: used for internal operations.
|
||
def index(key, value)
|
||
index_key = key_proc.call value
|
||
return if index_key.is_a? NoIndex
|
||
super(key, value)
|
||
|
||
@data[index_key] = key.to_i
|
||
end
|
||
|
||
# Removes the index of a value on the file-system as `DODB::Trigger::Index#deindex` but also from
|
||
# the cache, used for **internal operations**.
|
||
#
|
||
# NOTE: used for internal operations.
|
||
def deindex(key, value)
|
||
index_key = key_proc.call value
|
||
return if index_key.is_a? NoIndex
|
||
super(key, value)
|
||
|
||
@data.delete index_key
|
||
end
|
||
|
||
# Gets the key (ex: 343) for an entry in the DB.
|
||
# With caching, the key is probably stored in a hash, or we'll search in the FS.
|
||
#
|
||
# NOTE: used for internal operations.
|
||
def get_key(index : String) : Int32
|
||
if k = @data[index]?
|
||
k
|
||
elsif k = get_key_on_fs(index)
|
||
@data[index] = k
|
||
k
|
||
else
|
||
raise MissingEntry.new(@name, index)
|
||
end
|
||
end
|
||
end
|
||
|
||
# Basic indexes for 1-to-1 relations.
|
||
# RAM-only version, no file-system representation.
|
||
#
|
||
# ```
|
||
# cars_by_name = car_database.new_RAM_index "name", &.name
|
||
# ```
|
||
#
|
||
# Since there is no file-system operations, all the operations are fast.
|
||
# `DODB::Trigger::IndexRAMOnly` enables the flexibility of indexes without a file-system representation
|
||
# for absolute efficiency.
|
||
# Exactly as easy to use as the other index implementations.
|
||
#
|
||
# NOTE: reasonable amount of memory used since it's just an index.
|
||
# NOTE: fast for all operations, but no file-system representation.
|
||
class DODB::Trigger::IndexRAMOnly(V) < DODB::Trigger::IndexCached(V)
|
||
# Indexes a value in RAM, no file-system operation.
|
||
#
|
||
# NOTE: used for internal operations.
|
||
def index(key, value)
|
||
index_key = key_proc.call value
|
||
return if index_key.is_a? NoIndex
|
||
@data[index_key] = key.to_i
|
||
end
|
||
|
||
# Removes the index of a value in RAM, no file-system operation.
|
||
#
|
||
# NOTE: used for internal operations.
|
||
def deindex(key, value)
|
||
index_key = key_proc.call value
|
||
return if index_key.is_a? NoIndex
|
||
@data.delete index_key
|
||
end
|
||
|
||
# Gets the key (ex: 343) for an entry in the DB.
|
||
# With a RAM-only index, the key is necessarily stored in the hash.
|
||
#
|
||
# NOTE: used for internal operations.
|
||
def get_key(index : String) : Int32
|
||
if k = @data[index]?
|
||
k
|
||
else
|
||
raise MissingEntry.new(@name, index)
|
||
end
|
||
end
|
||
|
||
# Clears the index.
|
||
def nuke_trigger
|
||
clear_cache!
|
||
end
|
||
end
|