dodb.cr/src/dodb/trigger/index.cr

445 lines
13 KiB
Crystal
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 its 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