From 87fc2bee691ec33a08298d4156b27a55a5af5af9 Mon Sep 17 00:00:00 2001 From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com> Date: Mon, 3 Aug 2026 06:27:17 -0700 Subject: [PATCH] feat: extend cinderstore --- .gitattributes | 6 ++ .github/dependabot.yml | 7 ++ .github/workflows/ci.yml | 8 +- README.md | 104 ++++++++++++++++------ examples/snapshot_demo.cr | 35 ++++++++ shard.lock | 2 +- shard.yml | 2 +- spec/snapshot_spec.cr | 171 +++++++++++++++++++++++++++++++++++++ src/cinderstore/db.cr | 171 ++++++++++++++++++++++++++++++++++--- src/cinderstore/demo.cr | 20 ++++- src/cinderstore/version.cr | 2 +- 11 files changed, 487 insertions(+), 41 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/dependabot.yml create mode 100644 examples/snapshot_demo.cr create mode 100644 spec/snapshot_spec.cr diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fc55814 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Keep every text file on LF line endings. +# +# The formatter check `crystal tool format --check` treats CRLF files as +# changed. Without this file, a Windows checkout fails the check. The rule +# below forces LF on every platform. +* text=auto eol=lf diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ff1ffd1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0fdcddd..0fd6a6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Crystal - uses: crystal-lang/setup-crystal@v2 + uses: crystal-lang/install-crystal@v1 with: crystal: 1.21.0 @@ -34,5 +34,11 @@ jobs: - name: Run the test suite run: crystal spec + - name: Run the demo + run: crystal run examples/demo.cr + + - name: Run the snapshot example + run: crystal run examples/snapshot_demo.cr + - name: Build the binary run: shards build --production diff --git a/README.md b/README.md index 2c2aff3..226a556 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # Cinderstore -[![CI](https://github.com///actions/workflows/ci.yml/badge.svg)](https://github.com///actions/workflows/ci.yml) - +[![CI](https://github.com/DanielCuevas1208/cinderstore/actions/workflows/ci.yml/badge.svg)](https://github.com/DanielCuevas1208/cinderstore/actions/workflows/ci.yml) Cinderstore is an embeddable key and value store. It is built on a log structured merge tree (LSM tree). It is written in Crystal and uses only the @@ -9,9 +8,10 @@ Crystal standard library. The store keeps a write ahead log for durability. It flushes memory to sorted files. It merges those files during compaction. It recovers all data after a -restart. It serves `get`, `put`, and `delete` over a local socket. +restart. It serves `get`, `put`, and `delete` over a local socket. A snapshot +returns the same view, even when writes continue. -This is release 0.1.0. It is the first coherent release. +This is release 0.2.0. It adds snapshot iterators and consistent reads. ## Features @@ -21,6 +21,7 @@ This is release 0.1.0. It is the first coherent release. - Block cache for fast repeated reads - Background flush and compaction - Range scans with an iterator API +- Consistent snapshots that ignore later writes - Crash recovery from the write ahead log - Local TCP server with a line protocol - Zero runtime dependencies @@ -51,11 +52,11 @@ bin/cinderstore demo ## Demo output The demo loads a product catalog from `fixtures/catalog.csv`. It writes, -scans, flushes, compacts, deletes, and reopens a database. The output is +scans, flushes, compacts, and snapshots a database. The output is deterministic. ```text -== Cinderstore 0.1.0 demo == +== Cinderstore 0.2.0 demo == Loaded 24 products from ...\fixtures\catalog.csv Database directory: ...\cinderstore-demo @@ -85,11 +86,19 @@ Database directory: ...\cinderstore-demo 6. Verify deletes and updates after compaction get SKU-0003 => nil - get SKU-0001 => {"name":"Forge Anvil 45kg","price":175.00,"stock":14} + get SKU-0001 => "{\"name\":\"Forge Anvil 45kg\",\"price\":175.00,\"stock\":14}" scan count => 20 -7. Reopen the database and verify recovery - rows after restart: 20 +7. Snapshot gives a consistent point-in-time view + snapshot rows: 20, snapshot sequence: 30 + live rows after writes, flush, and compact: 17 + snapshot rows: 20 + snapshot get SKU-0005 => "{\"name\":\"Tongs Long Reach\",\"price\":29.75,\"stock\":30}" + live get SKU-0005 => nil + snapshot closed; its tables are now freed + +8. Reopen the database and verify recovery + rows after restart: 17 Demo complete. ``` @@ -130,6 +139,34 @@ db.flush # Move the memtable into a table. db.compact # Merge tables and drop deleted keys. ``` +## Snapshots + +Create a snapshot to read a consistent view. + +```crystal +view = db.snapshot +``` + +A snapshot captures the store at one moment. Later writes, flushes, and +compactions never change it. Read it like the database. + +```crystal +view.get("forge-hammer") # => "steel" +view.scan("a", "z") # => the pairs at capture time +view.each("a", "z") do |key, value| + puts "#{key} => #{value}" +end +``` + +A snapshot pins its table files. Close it to release them. + +```crystal +view.close +``` + +You can keep a snapshot open while the database changes. This is useful for +reporting, exports, or checks that must not change mid-run. + ## Command line tool The tool uses a database directory. The default directory is @@ -241,6 +278,12 @@ merge yields the newest entry for each key. The bloom filter lets a reader skip a table that cannot contain the key. The block cache holds decoded blocks so repeated reads avoid disk. +### Snapshots + +A snapshot copies the active memory table and opens its own readers. It pins +the table files, so compaction defers their deletion until the snapshot +closes. Reads use the captured state only. + ### Recovery On open, the database replays the write ahead log into the memory table. @@ -278,24 +321,26 @@ db = Cinderstore::DB.new("data", config) ## Project layout ```text -src/cinderstore.cr Library entry point -src/cinderstore/ Core components -src/cli.cr Command line tool -examples/demo.cr Library walkthrough -examples/server_demo.cr Wire protocol walkthrough -fixtures/catalog.csv Sample product catalog -spec/ Test suite +src/cinderstore.cr Library entry point +src/cinderstore/ Core components +src/cli.cr Command line tool +examples/demo.cr Library walkthrough +examples/snapshot_demo.cr Snapshot walkthrough +examples/server_demo.cr Wire protocol walkthrough +fixtures/catalog.csv Sample product catalog +spec/ Test suite ``` ## Test status -The suite runs with `crystal spec`. It has 82 examples. All pass on Windows +The suite runs with `crystal spec`. It has 93 examples. All pass on Windows and Linux. It covers the skip list, the memory table, the write ahead log, the bloom filter, and the block cache. It covers the tables, the iterators, -and the database. It covers compaction, durability, and the server protocol. +and the database. It covers compaction, durability, snapshots, and the +server protocol. The CI workflow runs on GitHub Actions for Windows and Ubuntu. It checks -formatting, runs the suite, and builds the binary. +formatting, runs the suite, runs both examples, and builds the binary. ## Limitations @@ -304,15 +349,26 @@ formatting, runs the suite, and builds the binary. - Keys are limited to 4 KB. - The server protocol is unencrypted. Use it on localhost only. - Compaction is a full merge. It is correct and simple, not incremental. +- A snapshot copies the active memory table. A large table costs memory. +- A snapshot pins its table files. Close it before you delete the directory. +- Close snapshots before you reopen the same directory in a new instance. - No multi-threaded runtime is required. The server uses fibers. ## Roadmap -- Release 0.2: incremental compaction by level -- Release 0.3: snapshot iterators and consistent reads -- Release 0.4: optional checksum-free fast mode -- Release 0.5: batch writes and group commit -- Release 0.6: secondary indexes +Release 0.2.0 is the current release. It delivers snapshot iterators and +consistent reads. + +Completed: + +- Snapshot iterators and consistent reads + +Planned: + +- Incremental compaction by level +- Optional checksum-free fast mode +- Batch writes and group commit +- Secondary indexes ## License diff --git a/examples/snapshot_demo.cr b/examples/snapshot_demo.cr new file mode 100644 index 0000000..d5be20b --- /dev/null +++ b/examples/snapshot_demo.cr @@ -0,0 +1,35 @@ +# Shows how a snapshot keeps a consistent view across writes. +# +# Usage: crystal run examples/snapshot_demo.cr +require "file_utils" +require "../src/cinderstore" + +path = File.join(Dir.tempdir, "cinderstore-snapshot-demo") +FileUtils.rm_rf(path) if File.exists?(path) +config = Cinderstore::DB::Config.new +config.sync_writes = false +config.compact_on_flush = false +db = Cinderstore::DB.new(path, config) + +db.put("forge-hammer", "steel") +db.put("forge-tongs", "iron") +db.flush + +view = db.snapshot +puts "snapshot sequence: #{view.seq}" +puts "snapshot rows: #{view.scan.size}" + +db.delete("forge-tongs") +db.put("anvil", "45kg") +db.flush +db.compact + +puts "live rows after writes: #{db.scan.size}" +puts "snapshot rows after writes: #{view.scan.size}" +puts "snapshot get forge-tongs: #{view.get("forge-tongs").inspect}" +puts "live get forge-tongs: #{db.get("forge-tongs").inspect}" + +view.close +db.close +FileUtils.rm_rf(path) +puts "snapshot demo complete" diff --git a/shard.lock b/shard.lock index 5593cd8..1726e2d 100644 --- a/shard.lock +++ b/shard.lock @@ -1 +1 @@ -version: 0.1.0 +version: 0.2.0 diff --git a/shard.yml b/shard.yml index c8e269b..4fc81c1 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: cinderstore -version: 0.1.0 +version: 0.2.0 description: An embeddable key/value store built on a log structured merge tree. crystal: ">= 1.10.0" license: Apache-2.0 diff --git a/spec/snapshot_spec.cr b/spec/snapshot_spec.cr new file mode 100644 index 0000000..8884976 --- /dev/null +++ b/spec/snapshot_spec.cr @@ -0,0 +1,171 @@ +require "./spec_helper" + +describe Cinderstore::DB::Snapshot do + it "captures the state at creation" do + config = Cinderstore::SpecHelpers.fast_config + Cinderstore::SpecHelpers.with_db("snapshot-basic", config) do |db, _path| + db.put("a", "1") + db.put("b", "2") + view = db.snapshot + db.put("c", "3") + db.delete("a") + + view.get("a").should eq("1") + view.get("b").should eq("2") + view.get("c").should be_nil + view.scan.map(&.[0]).should eq(%w[a b]) + + db.get("a").should be_nil + db.get("c").should eq("3") + view.close + end + end + + it "reports the captured sequence number" do + config = Cinderstore::SpecHelpers.fast_config + Cinderstore::SpecHelpers.with_db("snapshot-seq", config) do |db, _path| + db.put("a", "1") + db.put("b", "2") + view = db.snapshot + view.seq.should eq(2) + db.put("c", "3") + view.seq.should eq(2) + view.close + end + end + + it "matches the live store when nothing changes" do + config = Cinderstore::SpecHelpers.fast_config + Cinderstore::SpecHelpers.with_db("snapshot-match", config) do |db, _path| + %w[alpha beta gamma].each_with_index { |key, i| db.put(key, i.to_s) } + view = db.snapshot + view.scan.should eq(db.scan) + view.get("beta").should eq(db.get("beta")) + view.close + end + end + + it "scans a range and respects the limit" do + config = Cinderstore::SpecHelpers.fast_config + Cinderstore::SpecHelpers.with_db("snapshot-range", config) do |db, _path| + 10.times { |i| db.put("k%02d" % i, "v") } + view = db.snapshot + view.scan("k02", "k06").map(&.[0]).should eq(%w[k02 k03 k04 k05]) + view.scan("", nil, 3).size.should eq(3) + view.close + end + end + + it "iterates with a block" do + config = Cinderstore::SpecHelpers.fast_config + Cinderstore::SpecHelpers.with_db("snapshot-each", config) do |db, _path| + %w[a b c d].each { |key| db.put(key, key.upcase) } + view = db.snapshot + keys = [] of String + view.each("b", "d") { |key, _value| keys << key } + keys.should eq(%w[b c]) + view.close + end + end + + it "ignores later flushes and compactions" do + config = Cinderstore::SpecHelpers.fast_config + Cinderstore::SpecHelpers.with_db("snapshot-flush-compact", config) do |db, _path| + 100.times { |i| db.put("k%03d" % i, "old#{i}") } + db.flush + view = db.snapshot + + db.put("k000", "new") + db.delete("k001") + db.flush + db.compact + + view.scan.size.should eq(100) + view.get("k000").should eq("old0") + view.get("k001").should eq("old1") + + db.scan.size.should eq(99) + db.get("k000").should eq("new") + db.get("k001").should be_nil + view.close + end + end + + it "defers table deletion while a snapshot is open" do + config = Cinderstore::SpecHelpers.fast_config + Cinderstore::SpecHelpers.with_db("snapshot-pin", config) do |db, path| + 200.times { |i| db.put("k%03d" % i, "v#{i}") } + db.flush + db.put("k500", "v") + db.flush + db.stats.tables.should eq(2) + + view = db.snapshot + db.compact + db.stats.tables.should eq(1) + + on_disk = Dir.children(path).count { |name| name.ends_with?(".sst") } + on_disk.should be > db.stats.tables + + view.get("k123").should eq("v123") + view.scan.size.should eq(201) + + view.close + remaining = Dir.children(path).count { |name| name.ends_with?(".sst") } + remaining.should eq(db.stats.tables) + end + end + + it "keeps snapshots independent of each other" do + config = Cinderstore::SpecHelpers.fast_config + Cinderstore::SpecHelpers.with_db("snapshot-independent", config) do |db, _path| + db.put("a", "1") + first = db.snapshot + db.put("b", "2") + second = db.snapshot + db.put("c", "3") + + first.scan.map(&.[0]).should eq(%w[a]) + second.scan.map(&.[0]).should eq(%w[a b]) + + second.close + first.get("a").should eq("1") + first.scan.map(&.[0]).should eq(%w[a]) + first.close + end + end + + it "remains readable after the database closes" do + config = Cinderstore::SpecHelpers.fast_config + Cinderstore::SpecHelpers.with_db_path("snapshot-after-close") do |path| + db = Cinderstore::DB.new(path, config) + db.put("a", "1") + view = db.snapshot + db.close + + view.get("a").should eq("1") + view.scan.map(&.[0]).should eq(%w[a]) + view.close + end + end + + it "raises on reads after close" do + config = Cinderstore::SpecHelpers.fast_config + Cinderstore::SpecHelpers.with_db("snapshot-closed", config) do |db, _path| + db.put("a", "1") + view = db.snapshot + view.close + expect_raises(Cinderstore::ClosedError) { view.get("a") } + expect_raises(Cinderstore::ClosedError) { view.scan } + end + end + + it "rejects empty keys" do + config = Cinderstore::SpecHelpers.fast_config + Cinderstore::SpecHelpers.with_db("snapshot-invalid", config) do |db, _path| + view = db.snapshot + expect_raises(Cinderstore::InvalidKeyError) { view.get("") } + view.close + end + end +end diff --git a/src/cinderstore/db.cr b/src/cinderstore/db.cr index 9ca6401..9638f93 100644 --- a/src/cinderstore/db.cr +++ b/src/cinderstore/db.cr @@ -6,7 +6,8 @@ module Cinderstore # Writes go to a memtable and a write ahead log. When the memtable grows # past its limit we flush it to a sorted table. Background compaction # merges tables and drops stale data. Reads merge the memtable and all - # tables, which makes every view consistent with the write order. + # tables, which makes every view consistent with the write order. A + # snapshot returns the same view no matter what writes happen later. class DB MANIFEST_NAME = "MANIFEST" WAL_SUFFIX = ".wal" @@ -82,6 +83,86 @@ module Cinderstore end end + # A consistent point-in-time view of the database. + # + # A snapshot captures the write sequence, the memtable contents, and + # the table set when it is created. Later writes, flushes, and + # compactions never change what a snapshot returns. The snapshot pins + # the table files it reads, so compaction defers deletion until the + # snapshot closes. + class Snapshot + getter seq : Int64 + + @db : DB + @mem : MemTable + @frozen : MemTable? + @readers : Array(SstableReader) + @ids : Array(Int64) + @closed = false + + def initialize(@db : DB, @mem : MemTable, @frozen : MemTable?, + @readers : Array(SstableReader), @ids : Array(Int64), + @seq : Int64) + end + + # Returns the live value for `key` at capture time, or nil. + def get(key : String) : String? + @db.validate_key(key) + @db.@lock.synchronize do + check_open + if entry = @frozen.try { |mem| mem.get_entry(key) } + return entry.alive ? entry.value : nil + end + if entry = @mem.get_entry(key) + return entry.alive ? entry.value : nil + end + iter = LiveIter.new(@db.merge_sources(@mem, @frozen, @readers, key, true)) + if entry = iter.next? + return entry.value if entry.key == key + end + nil + end + end + + # Returns live key/value pairs in key order. The range is + # [start_key, finish_key). + def scan(start_key : String = "", finish_key : String? = nil, + limit : Int32 = -1) : Array(Tuple(String, String)) + @db.@lock.synchronize do + check_open + result = [] of Tuple(String, String) + iter = LiveIter.new(@db.merge_sources(@mem, @frozen, @readers, start_key, false)) + while entry = iter.next? + break if finish_key && entry.key >= finish_key + result << {entry.key, entry.value} + break if limit >= 0 && result.size >= limit + end + result + end + end + + # Yields live key/value pairs in key order. The range is + # [start_key, finish_key). + def each(start_key : String = "", finish_key : String? = nil, + &block : String, String ->) : Nil + scan(start_key, finish_key).each { |key, value| yield key, value } + end + + # Releases the pinned table files. A closed snapshot cannot be read. + def close : Nil + @db.@lock.synchronize do + return if @closed + @closed = true + @readers.each(&.close) + @db.unpin_tables(@ids) + end + end + + private def check_open : Nil + raise ClosedError.new("snapshot is closed") if @closed + end + end + # A live reference to one sorted table file. class TableRef getter id : Int64 @@ -129,6 +210,8 @@ module Cinderstore @compacting = false @pending_table_id : Int64 = 0_i64 @manifest : Manifest? = nil + @pinned : Hash(Int64, Int32) + @pending_deletes : Hash(Int64, String) def initialize(path : String, config : Config = Config.new) @path = path @@ -136,6 +219,8 @@ module Cinderstore @mem = MemTable.new @levels = [[] of TableRef] @block_cache = BlockCache.new(config.cache_blocks) + @pinned = {} of Int64 => Int32 + @pending_deletes = {} of Int64 => String open end @@ -219,6 +304,27 @@ module Cinderstore end end + # Captures a consistent point-in-time view of the store. + # + # The snapshot owns its readers and pins the table files, so a later + # flush or compaction never changes what it returns. Close the snapshot + # to release the pinned files. + def snapshot : Snapshot + @lock.synchronize do + check_open + mem_copy = MemTable.new + @mem.each_entry { |entry| mem_copy.put(entry.key, entry.value, entry.seq) } + ids = [] of Int64 + readers = [] of SstableReader + @levels.flatten.each do |ref| + ids << ref.id + readers << SstableReader.new(ref.path, ref.id, @block_cache) + end + pin_tables(ids) + Snapshot.new(self, mem_copy, @frozen, readers, ids, @seq) + end + end + # Returns a snapshot of database counters. def stats : Stats @lock.synchronize do @@ -339,16 +445,23 @@ module Cinderstore end private def make_merge_iter(start : String, bloom_guard : Bool) : MergeIter + readers = @levels.flatten.map { |ref| ref.reader(@block_cache) } + merge_sources(@mem, @frozen, readers, start, bloom_guard) + end + + # Builds a merge iterator over the given memtable, frozen table, and + # table readers. Snapshot reads reuse this so they see exactly the + # state the snapshot captured. + protected def merge_sources(mem : MemTable, frozen : MemTable?, + readers : Array(SstableReader), start : String, + bloom_guard : Bool) : MergeIter sources = [] of Store::Iter - if frozen = @frozen + if frozen sources << MemIter.new(frozen, start) end - sources << MemIter.new(@mem, start) - @levels.flatten.each do |ref| - reader = ref.reader(@block_cache) - if bloom_guard && !reader.bloom_may_contain?(start) - next - end + sources << MemIter.new(mem, start) + readers.each do |reader| + next if bloom_guard && !reader.bloom_may_contain?(start) sources << TableIter.new(reader, start) end MergeIter.new(sources) @@ -460,8 +573,7 @@ module Cinderstore @levels = [[] of TableRef, outputs.map { |m| TableRef.new(m.id, m.first, m.last, m.count, table_path(m.id)) }] save_manifest tables.not_nil!.each do |ref| - path = ref.path - File.delete(path) if File.exists?(path) + delete_table_file(ref.id, ref.path) end end end @@ -534,7 +646,7 @@ module Cinderstore File.join(@path, "#{Util.file_stem(id)}#{SST_SUFFIX}") end - private def validate_key(key : String) : Nil + protected def validate_key(key : String) : Nil raise InvalidKeyError.new("key must not be empty") if key.empty? raise InvalidKeyError.new("key exceeds #{MAX_KEY_BYTES} bytes") if key.bytesize > MAX_KEY_BYTES end @@ -548,5 +660,42 @@ module Cinderstore rescue File::NotFoundError 0_i64 end + + # ------------------------------------------------------------------ + # Snapshot pinning + # ------------------------------------------------------------------ + + # Increments the pin count for each table id. A pinned table file + # cannot be deleted by compaction. + private def pin_tables(ids : Array(Int64)) : Nil + ids.each do |id| + @pinned[id] = (@pinned[id]? || 0) + 1 + end + end + + # Decrements the pin count for each table id. When the last pin for a + # table is released, any deferred delete for it runs now. + protected def unpin_tables(ids : Array(Int64)) : Nil + ids.each do |id| + count = (@pinned[id]? || 0) - 1 + if count <= 0 + @pinned.delete(id) + if path = @pending_deletes.delete(id) + File.delete(path) rescue nil + end + else + @pinned[id] = count + end + end + end + + # Deletes a table file, or defers the delete while a snapshot pins it. + private def delete_table_file(id : Int64, path : String) : Nil + if (@pinned[id]? || 0) > 0 + @pending_deletes[id] = path + else + File.delete(path) if File.exists?(path) + end + end end end diff --git a/src/cinderstore/demo.cr b/src/cinderstore/demo.cr index fd224fc..25b1226 100644 --- a/src/cinderstore/demo.cr +++ b/src/cinderstore/demo.cr @@ -4,7 +4,8 @@ module Cinderstore # A self-contained walkthrough of the store. # # The demo loads a small product catalog, writes it, scans a range, - # flushes, compacts, and verifies recovery. Its output is deterministic. + # flushes, compacts, takes a snapshot, and verifies recovery. Its + # output is deterministic. class Demo def self.run(db_path : String? = nil, fixture : String? = nil) : Int32 new(db_path, fixture).run @@ -74,9 +75,24 @@ module Cinderstore puts " scan count => #{db.scan.size}" puts "" + puts "7. Snapshot gives a consistent point-in-time view" + snap = db.snapshot + puts " snapshot rows: #{snap.scan.size}, snapshot sequence: #{snap.seq}" + %w[SKU-0005 SKU-0015 SKU-0020].each { |sku| db.delete(sku) } + db.put("SKU-0001", %({"name":"Forge Anvil 45kg","price":190.00,"stock":13})) + db.flush + db.compact + puts " live rows after writes, flush, and compact: #{db.scan.size}" + puts " snapshot rows: #{snap.scan.size}" + puts " snapshot get SKU-0005 => #{snap.get("SKU-0005").inspect}" + puts " live get SKU-0005 => #{db.get("SKU-0005").inspect}" + snap.close + puts " snapshot closed; its tables are now freed" + puts "" + db.close - puts "7. Reopen the database and verify recovery" + puts "8. Reopen the database and verify recovery" reopened = DB.new(path, config) count = reopened.scan.size puts " rows after restart: #{count}" diff --git a/src/cinderstore/version.cr b/src/cinderstore/version.cr index 16eaad2..f883acc 100644 --- a/src/cinderstore/version.cr +++ b/src/cinderstore/version.cr @@ -1,3 +1,3 @@ module Cinderstore - VERSION = "0.1.0" + VERSION = "0.2.0" end