├── .gitignore
├── .travis.yml
├── LICENSE
├── README.md
├── docs
└── nuummite.png
├── shard.yml
├── spec
├── benchmark.cr
├── nuummite_spec.cr
└── spec_helper.cr
└── src
├── locking
└── locking.cr
└── nuummite.cr
/.gitignore:
--------------------------------------------------------------------------------
1 | /doc/
2 | /libs/
3 | /.crystal/
4 | /.shards/
5 |
6 |
7 | # Libraries don't need dependency lock
8 | # Dependencies will be locked in application that uses them
9 | /shard.lock
10 |
11 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: crystal
2 | crystal:
3 | - latest
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 CodeSteak
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # Nuummite [](https://travis-ci.org/CodeSteak/Nuummite)
4 |
5 | Nuummite is a tiny persistent embedded key-value store. All data is kept
6 | in RAM (in a Crystal Hash) and is also written to disk.
7 | So don't use Nuummite to handle big chunks of data.
8 | Keys and Values are always Strings.
9 | It just comes with the most basic operations.
10 |
11 |
12 | ## Installation
13 |
14 | Add this to your application's `shard.yml`:
15 |
16 | ```yaml
17 | dependencies:
18 | nuummite:
19 | github: codesteak/nuummite
20 | version: ~> 0.1.5
21 | ```
22 |
23 |
24 | ## Usage
25 |
26 | ```crystal
27 | require "nuummite"
28 | ```
29 |
30 | #### Open the database
31 | ```crystal
32 | db = Nuummite.new("path/to/folder", "optional-filename.db")
33 |
34 | # You can also have multiple:
35 | flowers = Nuummite.new("flowers")
36 | minerals = Nuummite.new("minerals")
37 | ```
38 |
39 | #### Put some values in
40 | ```crystal
41 | db["hello"] = "world"
42 | db["42"] = "Answer to the Ultimate Question of Life, The Universe, and Everything"
43 | db["whitespace"] = "Hey\n\t\t :D"
44 | ```
45 |
46 | #### Read values
47 | ```crystal
48 | db["hello"]? # => "world"
49 | db["ehhhh"]? # => nil
50 |
51 | #Note: db is locked while reading, so don't write to db!
52 | db.each do |key,value|
53 | # reads everything
54 | end
55 |
56 | db["crystals/ruby"] = "9.0"
57 | db["crystals/quartz"] = "~ 7.0"
58 | db["crystals/nuummite"] = "5.5 - 6.0"
59 |
60 | db.each("crystals/") do |key,value|
61 | # only crystals in here
62 | end
63 | ```
64 |
65 | #### Delete
66 | ```crystal
67 | db.delete "hello"
68 | ```
69 |
70 | #### Garbage collect
71 | Since values are saved to disk in a log style, file sizes grow,
72 | your key-value store needs to rewrite all data at some point:
73 | ```crystal
74 | db.garbage_collect
75 | ```
76 | By default it auto garbage collects after 10_000_000 writes.
77 | To modify this behavior you can:
78 | ```crystal
79 | # garbage collects after 1000 writes or deletes
80 | db.auto_garbage_collect_after_writes = 1000
81 |
82 | # does not auto garbage collect
83 | db.auto_garbage_collect_after_writes = nil
84 | ```
85 |
86 | #### Shutdown
87 | You can also shutdown Nuummite by
88 | ```crystal
89 | db.shutdown
90 | ```
91 |
92 | That's all you need to know :smile:
93 |
94 | ## Contributing
95 |
96 | 1. Fork it ( https://github.com/codesteak/nuummite/fork )
97 | 2. Create your feature branch (git checkout -b my-new-feature)
98 | 3. Commit your changes (git commit -am 'Add some feature')
99 | 4. Push to the branch (git push origin my-new-feature)
100 | 5. Create a new Pull Request
101 |
102 | ## Contributors
103 |
104 | - [CodeSteak](https://github.com/CodeSteak) - creator, maintainer
105 |
--------------------------------------------------------------------------------
/docs/nuummite.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodeSteak/Nuummite/fbbdb6f3e7817a38e218aa2954ceb6fe0f19013d/docs/nuummite.png
--------------------------------------------------------------------------------
/shard.yml:
--------------------------------------------------------------------------------
1 | name: nuummite
2 | version: 0.1.5
3 |
4 | authors:
5 | - CodeSteak
6 |
7 | license: MIT
8 |
--------------------------------------------------------------------------------
/spec/benchmark.cr:
--------------------------------------------------------------------------------
1 | require "benchmark"
2 | require "../src/nuummite"
3 |
4 | puts
5 | puts
6 | puts " user system total real"
7 | puts
8 |
9 | db = Nuummite.new("benchmark_db")
10 |
11 | db.auto_garbage_collect_after_writes = nil
12 | db.sync = false
13 |
14 | puts "1000_000 small writes"
15 | puts Benchmark.measure {
16 | 1000_000.times do |i|
17 | db["#{i}"] = "I <3 DATA"
18 | end
19 | }
20 | puts
21 |
22 | puts "1000_000 small deletes"
23 | puts Benchmark.measure {
24 | 1000_000.times do |i|
25 | db.delete "#{i}"
26 | end
27 | }
28 | puts
29 |
30 | db.shutdown
31 | sleep 0.01
32 |
33 | puts "reopen after 2000_000 operations"
34 | puts Benchmark.measure {
35 | db = Nuummite.new("benchmark_db")
36 | }
37 | puts
38 |
39 | 1000_000.times do |i|
40 | db["#{i}/lol"] = "I <3 DATA !!!!"
41 | end
42 |
43 | puts "garbage collect with 1000_000 entries"
44 | puts Benchmark.measure {
45 | db.garbage_collect
46 | }
47 | puts
48 |
49 | db.shutdown
50 | clean("benchmark_db")
51 |
52 | def clean(dir_name = "tmpdb")
53 | Dir.foreach(dir_name) do |filename|
54 | path = "#{dir_name}#{File::SEPARATOR}#{filename}"
55 | File.delete(path) if File.file?(path)
56 | end
57 | Dir.delete(dir_name)
58 | end
59 |
--------------------------------------------------------------------------------
/spec/nuummite_spec.cr:
--------------------------------------------------------------------------------
1 | require "./spec_helper"
2 |
3 | def with_db(name)
4 | db = Nuummite.new("tmpdb", name)
5 | yield db
6 | db.shutdown
7 | end
8 |
9 | describe Nuummite do
10 | it "make new db and save state, do operations" do
11 | with_db("one") do |db|
12 | db["a"] = "aaa"
13 | db["b"] = "bbb"
14 | db["c"] = "ccc"
15 | db["d"] = "nope"
16 | db["e"] = "eee"
17 |
18 | db.delete("e")
19 | db["d"] = "ddd"
20 | end
21 |
22 | with_db("one") do |db|
23 | db["a"]?.should eq("aaa")
24 | db["b"]?.should eq("bbb")
25 | db["c"]?.should eq("ccc")
26 | db["d"]?.should eq("ddd")
27 | db["e"]?.should eq(nil)
28 | end
29 | end
30 |
31 | it "open empty file" do
32 | #create empty file
33 | folder = "tmpdb";
34 | Dir.mkdir(folder) unless Dir.exists?(folder)
35 |
36 | path = "#{folder}#{File::SEPARATOR}empty"
37 |
38 | file = File.new(path, "a")
39 | file.close()
40 | # and reopen it
41 | with_db("empty") do |db|
42 | db["a"] = "aaa"
43 | db["a"]?.should eq("aaa")
44 | end
45 | end
46 |
47 | it "open empty alt file" do
48 | #create empty alt file
49 | folder = "tmpdb";
50 | Dir.mkdir(folder) unless Dir.exists?(folder)
51 |
52 | path = "#{folder}#{File::SEPARATOR}empty2.1"
53 |
54 | file = File.new(path, "a")
55 | file.close()
56 | # and reopen it
57 | with_db("empty2") do |db|
58 | db["a"] = "aaa"
59 | db["a"]?.should eq("aaa")
60 | end
61 | end
62 |
63 | it "make new db and save state, do operations and garbage_collect" do
64 | with_db("one") do |db|
65 | db["a"] = "a✌a"
66 | db["b"] = "bbb"
67 | db["c"] = "ccc"
68 | db["d"] = "nope"
69 | db["e"] = "eee"
70 |
71 | db.delete("e")
72 | db["d"] = "ddd"
73 |
74 | db.garbage_collect
75 | end
76 |
77 | with_db("one") do |db|
78 | db["a"]?.should eq("a✌a")
79 | db["b"]?.should eq("bbb")
80 | db["c"]?.should eq("ccc")
81 | db["d"]?.should eq("ddd")
82 | db["e"]?.should eq(nil)
83 | end
84 | end
85 |
86 | it "each" do
87 | with_db("each") do |db|
88 | db["a"] = ">D"
89 | db["crystals/ruby"] = ""
90 | db["crystals/quartz"] = ""
91 | db["crystals/nuummite"] = ""
92 |
93 | i = 0
94 | db.each do
95 | i += 1
96 | end
97 | i.should eq(4)
98 |
99 | i = 0
100 | db.each("crystals/") do
101 | i += 1
102 | end
103 | i.should eq(3)
104 |
105 | db["a"]?.should eq(">D")
106 | end
107 | end
108 |
109 | it "garbage collect db" do
110 | with_db("two") do |db|
111 | 1000.times do |i|
112 | db["key"] = "#{i}"
113 | end
114 | db.garbage_collect
115 | end
116 | file_size = File.size("tmpdb/two")
117 | (file_size < 100).should be_true
118 | end
119 |
120 | it "auto garbage collect db" do
121 | with_db("three") do |db|
122 | db.auto_garbage_collect_after_writes = 10000
123 | 10001.times do |i|
124 | db["#{i}"] = "data"*5
125 | db.delete "#{i}"
126 | end
127 | sleep 0.01
128 | file_size = File.size("tmpdb/three")
129 | (file_size < 100).should be_true
130 | end
131 | end
132 |
133 | clean()
134 | end
135 |
136 | def clean(dir_name = "tmpdb")
137 | Dir.new(dir_name).each do |filename|
138 | path = "#{dir_name}#{File::SEPARATOR}#{filename}"
139 | File.delete(path) if File.file?(path)
140 | end
141 | Dir.delete(dir_name)
142 | end
143 |
--------------------------------------------------------------------------------
/spec/spec_helper.cr:
--------------------------------------------------------------------------------
1 | require "spec"
2 | require "../src/nuummite"
3 | require "./benchmark"
4 |
--------------------------------------------------------------------------------
/src/locking/locking.cr:
--------------------------------------------------------------------------------
1 | # Allows basic locking.
2 | module Locking
3 | @running = true
4 | @channel_lock = Channel(Nil).new
5 | @channel_unlock = Channel(Nil).new
6 |
7 | private def lock
8 | @channel_lock.receive
9 | end
10 |
11 | private def unlock
12 | @channel_unlock.send nil
13 | end
14 |
15 | private def save
16 | lock
17 | yield
18 | ensure
19 | unlock
20 | end
21 |
22 | private def enable_locking
23 | spawn do
24 | manage_locks
25 | end
26 | end
27 |
28 | private def disable_locking
29 | save do
30 | @running = false
31 | end
32 | end
33 |
34 | private def manage_locks
35 | while @running
36 | @channel_lock.send nil
37 | @channel_unlock.receive
38 | end
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/src/nuummite.cr:
--------------------------------------------------------------------------------
1 | require "./locking/*"
2 |
3 | # Nuummite is a minimalistic persistent key-value store.
4 | class Nuummite
5 | include Locking
6 |
7 | VERSION = 1
8 |
9 | # Number of writes or deletes before automatic garbage collection happens.
10 | #
11 | # Set it to nil to disable automatic garbage collection.
12 | property auto_garbage_collect_after_writes : Int32? = 10_000_000
13 |
14 | # If true the logfile is flushed on every write.
15 | property sync : Bool
16 |
17 | @log : File
18 | @kv : Hash(String, String)
19 |
20 | enum Opcode : UInt8
21 | RENAME = 3
22 | REMOVE = 2
23 | WRITE = 1
24 | end
25 |
26 | def initialize(folder : String, @filename = "db.nuummite", @sync = true)
27 | @need_gc = false
28 | @log, @kv = open_folder(folder, @filename)
29 |
30 | enable_locking
31 | garbage_collect if @need_gc
32 | end
33 |
34 | private def open_folder(folder, filename) : {File, Hash(String, String)}
35 | Dir.mkdir(folder) unless Dir.exists?(folder)
36 |
37 | path = "#{folder}#{File::SEPARATOR}#{filename}"
38 | alt_path = "#{folder}#{File::SEPARATOR}#{filename}.1"
39 |
40 | new_file = false
41 |
42 | kv = if File.exists?(path) && File.size(path) > 0
43 | read_file_to_kv path
44 | elsif File.exists?(alt_path) && File.size(alt_path) > 0
45 | File.rename(alt_path, path)
46 | read_file_to_kv path
47 | else
48 | new_file = true
49 | Hash(String, String).new
50 | end
51 |
52 | file = File.new(path, "a")
53 | if new_file
54 | file.write_byte(VERSION.to_u8)
55 | file.flush
56 | end
57 | {file, kv}
58 | end
59 |
60 | @writes = 0
61 | private def check_autogc
62 | if autogc = @auto_garbage_collect_after_writes
63 | @writes += 1
64 | if @writes > autogc
65 | @writes = 0
66 | spawn do
67 | garbage_collect
68 | end
69 | end
70 | end
71 | end
72 |
73 | # Shuts down this instance of Nuummite.
74 | def shutdown
75 | disable_locking
76 | @log.flush
77 | @log.close
78 | end
79 |
80 | # Delets a key. Returns its value.
81 | def delete(key)
82 | save do
83 | log_remove(key)
84 | @kv.delete(key)
85 | end
86 | ensure
87 | check_autogc
88 | end
89 |
90 | # Set key to value.
91 | def []=(key, value)
92 | save do
93 | log_write(key, value)
94 | @kv[key] = value
95 | end
96 | ensure
97 | check_autogc
98 | end
99 |
100 | # Reads value to given key.
101 | def [](key)
102 | @kv[key]
103 | end
104 |
105 | # Reads value to given key. Returns `nil` if key is not avilable.
106 | def []?(key)
107 | @kv[key]?
108 | end
109 |
110 | # Yields every key-value pair where the key starts with `starts_with`.
111 | # ```
112 | # db["crystals/ruby"] = "9.0"
113 | # db["crystals/quartz"] = "~ 7.0"
114 | # db["crystals/nuummite"] = "5.5 - 6.0"
115 | #
116 | # db.each("crystals/") do |key, value|
117 | # # only crystals in here
118 | # end
119 | # ```
120 | # `starts_with` defaults to `""`. Then every key-value pair is yield.
121 | def each(starts_with : String = "")
122 | save do
123 | @kv.each do |key, value|
124 | if key.starts_with?(starts_with)
125 | yield key, value
126 | end
127 | end
128 | end
129 | end
130 |
131 | # Rewrites current state to logfile.
132 | def garbage_collect
133 | save do
134 | path = @log.path
135 | alt_path = "#{path}.1"
136 | File.delete(alt_path) if File.exists?(alt_path)
137 |
138 | @log.flush
139 | @log.close
140 |
141 | sync = @sync
142 |
143 | @log = File.new(alt_path, "w")
144 | @sync = false
145 |
146 | @log.write_byte(VERSION.to_u8)
147 | @kv.each do |key, value|
148 | log_write(key, value)
149 | end
150 | @log.flush
151 | @log.close
152 |
153 | @sync = sync
154 |
155 | File.delete(path)
156 | File.rename(alt_path, path)
157 |
158 | @log = File.new(path, "a")
159 | end
160 | end
161 |
162 | private def log_write(key, value)
163 | log Opcode::WRITE, key, value
164 | end
165 |
166 | private def log_remove(key)
167 | log Opcode::REMOVE, key
168 | end
169 |
170 | private def log_rename(key_old, key_new)
171 | log Opcode::RENAME, key_old, key_new
172 | end
173 |
174 | private def log(opcode, arg0, arg1 = nil)
175 | @log.write_byte(opcode.value.to_u8)
176 |
177 | write_string_arg(@log, arg0)
178 | write_string_arg(@log, arg1) if arg1
179 |
180 | @log.flush if @sync
181 | end
182 |
183 | private def write_string_arg(io, value)
184 | data = value.bytes
185 |
186 | io.write_bytes(data.size.to_i32, IO::ByteFormat::NetworkEndian)
187 | data.each do |b|
188 | io.write_byte(b)
189 | end
190 | end
191 |
192 | private def read_file_to_kv(path)
193 | kv = Hash(String, String).new
194 |
195 | file = File.new(path)
196 | version = file.read_byte.not_nil!
197 | raise Exception.new("Unsupported Version #{version}") if version != 1
198 |
199 | begin
200 | while opcode = file.read_byte
201 | case Opcode.new opcode
202 | when Opcode::WRITE
203 | key = read_string_arg file
204 | value = read_string_arg file
205 |
206 | kv[key] = value
207 | when Opcode::REMOVE
208 | key = read_string_arg file
209 |
210 | kv.delete key
211 | when Opcode::RENAME
212 | key_old = read_string_arg file
213 | key_new = read_string_arg file
214 |
215 | kv[key_new] = kv[key_old]
216 | kv.delete key_old
217 | else
218 | raise Exception.new("Invalid format: Opcode #{opcode}")
219 | end
220 | end
221 | rescue ex : IO::EOFError
222 | puts "Data is incomplete. \
223 | Please set sync to true to prevent this type of data corruption"
224 | puts "Continue..."
225 | @need_gc = true
226 | end
227 | file.close
228 | kv
229 | end
230 |
231 | private def read_string_arg(io)
232 | size = read_int(io)
233 | read_string(io, size)
234 | end
235 |
236 | private def read_int(io)
237 | value = 0
238 | 4.times { value <<= 8; value += io.read_byte.not_nil! }
239 | value
240 | end
241 |
242 | private def read_string(io, size)
243 | data = Slice(UInt8).new(size)
244 | io.read_fully(data)
245 | String.new(data)
246 | end
247 | end
248 |
--------------------------------------------------------------------------------