├── test ├── lib │ └── helper.rb └── test_pstore.rb ├── .gitignore ├── .github ├── dependabot.yml └── workflows │ ├── test.yml │ └── push_gem.yml ├── bin ├── setup └── console ├── Gemfile ├── Rakefile ├── BSDL ├── pstore.gemspec ├── COPYING ├── README.md └── lib └── pstore.rb /test/lib/helper.rb: -------------------------------------------------------------------------------- 1 | require "test/unit" 2 | require "core_assertions" 3 | 4 | Test::Unit::TestCase.include Test::Unit::CoreAssertions 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | Gemfile.lock 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'github-actions' 4 | directory: '/' 5 | schedule: 6 | interval: 'weekly' 7 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | group :development do 6 | gem "bundler" 7 | gem "rake" 8 | gem "test-unit" 9 | gem "test-unit-ruby-core" 10 | end 11 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.libs << "test/lib" 6 | t.ruby_opts << "-rhelper" 7 | t.test_files = FileList["test/**/test_*.rb"] 8 | end 9 | 10 | task :default => :test 11 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "pstore" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | ruby-versions: 7 | uses: ruby/actions/.github/workflows/ruby_versions.yml@master 8 | with: 9 | engine: cruby 10 | min_version: 2.4 11 | 12 | test: 13 | needs: ruby-versions 14 | name: build (${{ matrix.ruby }} / ${{ matrix.os }}) 15 | strategy: 16 | matrix: 17 | ruby: ${{ fromJson(needs.ruby-versions.outputs.versions) }} 18 | os: [ ubuntu-latest, macos-latest ] 19 | exclude: 20 | - { os: macos-latest, ruby: '2.4' } 21 | - { os: macos-latest, ruby: '2.5' } 22 | runs-on: ${{ matrix.os }} 23 | steps: 24 | - uses: actions/checkout@v6 25 | - name: Set up Ruby 26 | uses: ruby/setup-ruby@v1 27 | with: 28 | ruby-version: ${{ matrix.ruby }} 29 | - name: Install dependencies 30 | run: gem i test-unit test-unit-ruby-core 31 | - name: Run test 32 | run: rake test 33 | -------------------------------------------------------------------------------- /.github/workflows/push_gem.yml: -------------------------------------------------------------------------------- 1 | name: Publish gem to rubygems.org 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | push: 13 | if: github.repository == 'ruby/pstore' 14 | runs-on: ubuntu-latest 15 | 16 | environment: 17 | name: rubygems.org 18 | url: https://rubygems.org/gems/pstore 19 | 20 | permissions: 21 | contents: write 22 | id-token: write 23 | 24 | steps: 25 | - name: Harden Runner 26 | uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 27 | with: 28 | egress-policy: audit 29 | 30 | - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 31 | 32 | - name: Set up Ruby 33 | uses: ruby/setup-ruby@d5126b9b3579e429dd52e51e68624dda2e05be25 # v1.267.0 34 | with: 35 | bundler-cache: true 36 | ruby-version: ruby 37 | 38 | - name: Publish to RubyGems 39 | uses: rubygems/release-gem@1c162a739e8b4cb21a676e97b087e8268d8fc40b # v1.1.2 40 | 41 | - name: Create GitHub release 42 | run: | 43 | tag_name="$(git describe --tags --abbrev=0)" 44 | gh release create "${tag_name}" --verify-tag --generate-notes 45 | env: 46 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 47 | -------------------------------------------------------------------------------- /BSDL: -------------------------------------------------------------------------------- 1 | Copyright (C) 1993-2013 Yukihiro Matsumoto. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions 5 | are met: 6 | 1. Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | 2. Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND 13 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 14 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 15 | ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE 16 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 17 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS 18 | OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 19 | HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 20 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY 21 | OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF 22 | SUCH DAMAGE. 23 | -------------------------------------------------------------------------------- /pstore.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | name = File.basename(__FILE__, ".gemspec") 4 | version = ["lib", Array.new(name.count("-")+1, ".").join("/")].find do |dir| 5 | break File.foreach(File.join(__dir__, dir, "#{name.tr('-', '/')}.rb")) do |line| 6 | /^\s*VERSION\s*=\s*"(.*)"/ =~ line and break $1 7 | end rescue nil 8 | end 9 | 10 | Gem::Specification.new do |spec| 11 | spec.name = name 12 | spec.version = version 13 | spec.authors = ["Yukihiro Matsumoto"] 14 | spec.email = ["matz@ruby-lang.org"] 15 | 16 | spec.summary = %q{Transactional File Storage for Ruby Objects} 17 | spec.description = spec.summary 18 | spec.homepage = "https://github.com/ruby/pstore" 19 | spec.licenses = ["Ruby", "BSD-2-Clause"] 20 | 21 | spec.metadata["homepage_uri"] = spec.homepage 22 | spec.metadata["source_code_uri"] = "https://github.com/ruby/pstore" 23 | 24 | # Specify which files should be added to the gem when it is released. 25 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 26 | spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do 27 | `git ls-files -z 2>#{IO::NULL}`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 28 | end 29 | spec.bindir = "exe" 30 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 31 | spec.require_paths = ["lib"] 32 | end 33 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | Ruby is copyrighted free software by Yukihiro Matsumoto . 2 | You can redistribute it and/or modify it under either the terms of the 3 | 2-clause BSDL (see the file BSDL), or the conditions below: 4 | 5 | 1. You may make and give away verbatim copies of the source form of the 6 | software without restriction, provided that you duplicate all of the 7 | original copyright notices and associated disclaimers. 8 | 9 | 2. You may modify your copy of the software in any way, provided that 10 | you do at least ONE of the following: 11 | 12 | a. place your modifications in the Public Domain or otherwise 13 | make them Freely Available, such as by posting said 14 | modifications to Usenet or an equivalent medium, or by allowing 15 | the author to include your modifications in the software. 16 | 17 | b. use the modified software only within your corporation or 18 | organization. 19 | 20 | c. give non-standard binaries non-standard names, with 21 | instructions on where to get the original software distribution. 22 | 23 | d. make other distribution arrangements with the author. 24 | 25 | 3. You may distribute the software in object code or binary form, 26 | provided that you do at least ONE of the following: 27 | 28 | a. distribute the binaries and library files of the software, 29 | together with instructions (in the manual page or equivalent) 30 | on where to get the original distribution. 31 | 32 | b. accompany the distribution with the machine-readable source of 33 | the software. 34 | 35 | c. give non-standard binaries non-standard names, with 36 | instructions on where to get the original software distribution. 37 | 38 | d. make other distribution arrangements with the author. 39 | 40 | 4. You may modify and include the part of the software into any other 41 | software (possibly commercial). But some files in the distribution 42 | are not written by the author, so that they are not under these terms. 43 | 44 | For the list of those files and their copying conditions, see the 45 | file LEGAL. 46 | 47 | 5. The scripts and library files supplied as input to or produced as 48 | output from the software do not automatically fall under the 49 | copyright of the software, but belong to whomever generated them, 50 | and may be sold commercially, and may be aggregated with this 51 | software. 52 | 53 | 6. THIS SOFTWARE IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR 54 | IMPLIED WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED 55 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 56 | PURPOSE. 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pstore 2 | 3 | PStore implements a file based persistence mechanism based on a Hash. User 4 | code can store hierarchies of Ruby objects (values) into the data store file 5 | by name (keys). An object hierarchy may be just a single object. User code 6 | may later read values back from the data store or even update data, as needed. 7 | 8 | The transactional behavior ensures that any changes succeed or fail together. 9 | This can be used to ensure that the data store is not left in a transitory 10 | state, where some values were updated but others were not. 11 | 12 | Behind the scenes, Ruby objects are stored to the data store file with 13 | Marshal. That carries the usual limitations. Proc objects cannot be 14 | marshalled, for example. 15 | 16 | ## Installation 17 | 18 | Add this line to your application's Gemfile: 19 | 20 | ```ruby 21 | gem 'pstore' 22 | ``` 23 | 24 | And then execute: 25 | 26 | $ bundle 27 | 28 | Or install it yourself as: 29 | 30 | $ gem install pstore 31 | 32 | ## Usage 33 | 34 | ```ruby 35 | require "pstore" 36 | 37 | # a mock wiki object... 38 | class WikiPage 39 | def initialize( page_name, author, contents ) 40 | @page_name = page_name 41 | @revisions = Array.new 42 | 43 | add_revision(author, contents) 44 | end 45 | 46 | attr_reader :page_name 47 | 48 | def add_revision( author, contents ) 49 | @revisions << { :created => Time.now, 50 | :author => author, 51 | :contents => contents } 52 | end 53 | 54 | def wiki_page_references 55 | [@page_name] + @revisions.last[:contents].scan(/\b(?:[A-Z]+[a-z]+){2,}/) 56 | end 57 | 58 | # ... 59 | end 60 | 61 | # create a new page... 62 | home_page = WikiPage.new( "HomePage", "James Edward Gray II", 63 | "A page about the JoysOfDocumentation..." ) 64 | 65 | # then we want to update page data and the index together, or not at all... 66 | wiki = PStore.new("wiki_pages.pstore") 67 | wiki.transaction do # begin transaction; do all of this or none of it 68 | # store page... 69 | wiki[home_page.page_name] = home_page 70 | # ensure that an index has been created... 71 | wiki[:wiki_index] ||= Array.new 72 | # update wiki index... 73 | wiki[:wiki_index].push(*home_page.wiki_page_references) 74 | end # commit changes to wiki data store file 75 | 76 | ### Some time later... ### 77 | 78 | # read wiki data... 79 | wiki.transaction(true) do # begin read-only transaction, no changes allowed 80 | wiki.roots.each do |data_root_name| 81 | p data_root_name 82 | p wiki[data_root_name] 83 | end 84 | end 85 | ``` 86 | 87 | ## Development 88 | 89 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 90 | 91 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 92 | 93 | ## Contributing 94 | 95 | Bug reports and pull requests are welcome on GitHub at https://github.com/ruby/pstore. 96 | -------------------------------------------------------------------------------- /test/test_pstore.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'test/unit' 3 | require 'pstore' 4 | require 'tmpdir' 5 | 6 | class PStoreTest < Test::Unit::TestCase 7 | def setup 8 | @pstore_file = File.join(Dir.tmpdir, "pstore.tmp.#{Process.pid}") 9 | @pstore = PStore.new(@pstore_file) 10 | end 11 | 12 | def teardown 13 | File.unlink(@pstore_file) rescue nil 14 | end 15 | 16 | def test_opening_new_file_in_readonly_mode_should_result_in_empty_values 17 | @pstore.transaction(true) do 18 | assert_nil @pstore[:foo] 19 | assert_nil @pstore[:bar] 20 | end 21 | end 22 | 23 | def test_opening_new_file_in_readwrite_mode_should_result_in_empty_values 24 | @pstore.transaction do 25 | assert_nil @pstore[:foo] 26 | assert_nil @pstore[:bar] 27 | end 28 | end 29 | 30 | def test_data_should_be_loaded_correctly_when_in_readonly_mode 31 | @pstore.transaction do 32 | @pstore[:foo] = "bar" 33 | end 34 | @pstore.transaction(true) do 35 | assert_equal "bar", @pstore[:foo] 36 | end 37 | end 38 | 39 | def test_data_should_be_loaded_correctly_when_in_readwrite_mode 40 | @pstore.transaction do 41 | @pstore[:foo] = "bar" 42 | end 43 | @pstore.transaction do 44 | assert_equal "bar", @pstore[:foo] 45 | end 46 | end 47 | 48 | def test_changes_after_commit_are_discarded 49 | @pstore.transaction do 50 | @pstore[:foo] = "bar" 51 | @pstore.commit 52 | @pstore[:foo] = "baz" 53 | end 54 | @pstore.transaction(true) do 55 | assert_equal "bar", @pstore[:foo] 56 | end 57 | end 58 | 59 | def test_changes_are_not_written_on_abort 60 | @pstore.transaction do 61 | @pstore[:foo] = "bar" 62 | @pstore.abort 63 | end 64 | @pstore.transaction(true) do 65 | assert_nil @pstore[:foo] 66 | end 67 | end 68 | 69 | def test_writing_inside_readonly_transaction_raises_error 70 | assert_raise(PStore::Error) do 71 | @pstore.transaction(true) do 72 | @pstore[:foo] = "bar" 73 | end 74 | end 75 | end 76 | 77 | def test_thread_safe 78 | q1 = Thread::Queue.new 79 | assert_raise(PStore::Error) do 80 | th = Thread.new do 81 | @pstore.transaction do 82 | @pstore[:foo] = "bar" 83 | q1.push true 84 | sleep 85 | end 86 | end 87 | begin 88 | q1.pop 89 | @pstore.transaction {} 90 | ensure 91 | th.kill 92 | th.join 93 | end 94 | end 95 | q2 = Thread::Queue.new 96 | begin 97 | pstore = PStore.new(second_file, true) 98 | cur = Thread.current 99 | th = Thread.new do 100 | pstore.transaction do 101 | pstore[:foo] = "bar" 102 | q1.push true 103 | q2.pop 104 | # wait for cur to enter a transaction 105 | sleep 0.1 until cur.stop? 106 | end 107 | end 108 | begin 109 | q1.pop 110 | q2.push true 111 | assert_equal("bar", pstore.transaction { pstore[:foo] }) 112 | ensure 113 | th.join 114 | end 115 | end 116 | ensure 117 | File.unlink(second_file) rescue nil 118 | end 119 | 120 | def test_nested_transaction_raises_error 121 | assert_raise(PStore::Error) do 122 | @pstore.transaction { @pstore.transaction { } } 123 | end 124 | pstore = PStore.new(second_file, true) 125 | assert_raise(PStore::Error) do 126 | pstore.transaction { pstore.transaction { } } 127 | end 128 | ensure 129 | File.unlink(second_file) rescue nil 130 | end 131 | 132 | # Test that PStore's file operations do not blow up when default encodings are set 133 | def test_pstore_files_are_accessed_as_binary_files 134 | bug5311 = '[ruby-core:39503]' 135 | n = 128 136 | top_dir = File.expand_path('../lib', __dir__) 137 | assert_in_out_err(["-Eutf-8:utf-8", "-I#{top_dir}", "-rpstore", "-", @pstore_file], <<-SRC, [bug5311], [], bug5311, timeout: 30) 138 | @pstore = PStore.new(ARGV[0]) 139 | (1..#{n}).each do |i| 140 | @pstore.transaction {@pstore["Key\#{i}"] = "value \#{i}"} 141 | end 142 | @pstore.transaction {@pstore["Bug5311"] = '#{bug5311}'} 143 | puts @pstore.transaction {@pstore["Bug5311"]} 144 | SRC 145 | assert_equal(bug5311, @pstore.transaction {@pstore["Bug5311"]}, bug5311) 146 | end 147 | 148 | def test_key_p 149 | [:key?, :root?].each do |method| 150 | clear_store 151 | @pstore.transaction do 152 | @pstore[:foo] = 0 153 | assert_equal(true, @pstore.send(method, :foo)) 154 | assert_equal(false, @pstore.send(method, :bar)) 155 | end 156 | end 157 | end 158 | 159 | def test_keys 160 | [:keys, :roots].each do |method| 161 | clear_store 162 | @pstore.transaction do 163 | assert_equal([], @pstore.send(method)) 164 | end 165 | @pstore.transaction do 166 | @pstore[:foo] = 0 167 | assert_equal([:foo], @pstore.send(method)) 168 | end 169 | end 170 | end 171 | 172 | def clear_store 173 | @pstore.transaction do 174 | @pstore.keys.each do |key| 175 | @pstore.delete(key) 176 | end 177 | end 178 | end 179 | 180 | def second_file 181 | File.join(Dir.tmpdir, "pstore.tmp2.#{Process.pid}") 182 | end 183 | end 184 | -------------------------------------------------------------------------------- /lib/pstore.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # = PStore -- Transactional File Storage for Ruby Objects 3 | # 4 | # pstore.rb - 5 | # originally by matz 6 | # documentation by Kev Jackson and James Edward Gray II 7 | # improved by Hongli Lai 8 | # 9 | # See PStore for documentation. 10 | 11 | require "digest" 12 | 13 | # \PStore implements a file based persistence mechanism based on a Hash. 14 | # User code can store hierarchies of Ruby objects (values) 15 | # into the data store by name (keys). 16 | # An object hierarchy may be just a single object. 17 | # User code may later read values back from the data store 18 | # or even update data, as needed. 19 | # 20 | # The transactional behavior ensures that any changes succeed or fail together. 21 | # This can be used to ensure that the data store is not left in a transitory state, 22 | # where some values were updated but others were not. 23 | # 24 | # Behind the scenes, Ruby objects are stored to the data store file with Marshal. 25 | # That carries the usual limitations. Proc objects cannot be marshalled, 26 | # for example. 27 | # 28 | # There are three important concepts here (details at the links): 29 | # 30 | # - {Store}[rdoc-ref:PStore@The+Store]: a store is an instance of \PStore. 31 | # - {Entries}[rdoc-ref:PStore@Entries]: the store is hash-like; 32 | # each entry is the key for a stored object. 33 | # - {Transactions}[rdoc-ref:PStore@Transactions]: each transaction is a collection 34 | # of prospective changes to the store; 35 | # a transaction is defined in the block given with a call 36 | # to PStore#transaction. 37 | # 38 | # == About the Examples 39 | # 40 | # Examples on this page need a store that has known properties. 41 | # They can get a new (and populated) store by calling thus: 42 | # 43 | # example_store do |store| 44 | # # Example code using store goes here. 45 | # end 46 | # 47 | # All we really need to know about +example_store+ 48 | # is that it yields a fresh store with a known population of entries; 49 | # its implementation: 50 | # 51 | # require 'pstore' 52 | # require 'tempfile' 53 | # # Yield a pristine store for use in examples. 54 | # def example_store 55 | # # Create the store in a temporary file. 56 | # Tempfile.create do |file| 57 | # store = PStore.new(file) 58 | # # Populate the store. 59 | # store.transaction do 60 | # store[:foo] = 0 61 | # store[:bar] = 1 62 | # store[:baz] = 2 63 | # end 64 | # yield store 65 | # end 66 | # end 67 | # 68 | # == The Store 69 | # 70 | # The contents of the store are maintained in a file whose path is specified 71 | # when the store is created (see PStore.new). 72 | # The objects are stored and retrieved using 73 | # module Marshal, which means that certain objects cannot be added to the store; 74 | # see {Marshal::dump}[https://docs.ruby-lang.org/en/master/Marshal.html#method-c-dump]. 75 | # 76 | # == Entries 77 | # 78 | # A store may have any number of entries. 79 | # Each entry has a key and a value, just as in a hash: 80 | # 81 | # - Key: as in a hash, the key can be (almost) any object; 82 | # see {Hash Keys}[https://docs.ruby-lang.org/en/master/Hash.html#class-Hash-label-Hash+Keys]. 83 | # You may find it convenient to keep it simple by using only 84 | # symbols or strings as keys. 85 | # - Value: the value may be any object that can be marshalled by \Marshal 86 | # (see {Marshal::dump}[https://docs.ruby-lang.org/en/master/Marshal.html#method-c-dump]) 87 | # and in fact may be a collection 88 | # (e.g., an array, a hash, a set, a range, etc). 89 | # That collection may in turn contain nested objects, 90 | # including collections, to any depth; 91 | # those objects must also be \Marshal-able. 92 | # See {Hierarchical Values}[rdoc-ref:PStore@Hierarchical+Values]. 93 | # 94 | # == Transactions 95 | # 96 | # === The Transaction Block 97 | # 98 | # The block given with a call to method #transaction# 99 | # contains a _transaction_, 100 | # which consists of calls to \PStore methods that 101 | # read from or write to the store 102 | # (that is, all \PStore methods except #transaction itself, 103 | # #path, and Pstore.new): 104 | # 105 | # example_store do |store| 106 | # store.transaction do 107 | # store.keys # => [:foo, :bar, :baz] 108 | # store[:bat] = 3 109 | # store.keys # => [:foo, :bar, :baz, :bat] 110 | # end 111 | # end 112 | # 113 | # Execution of the transaction is deferred until the block exits, 114 | # and is executed _atomically_ (all-or-nothing): 115 | # either all transaction calls are executed, or none are. 116 | # This maintains the integrity of the store. 117 | # 118 | # Other code in the block (including even calls to #path and PStore.new) 119 | # is executed immediately, not deferred. 120 | # 121 | # The transaction block: 122 | # 123 | # - May not contain a nested call to #transaction. 124 | # - Is the only context where methods that read from or write to 125 | # the store are allowed. 126 | # 127 | # As seen above, changes in a transaction are made automatically 128 | # when the block exits. 129 | # The block may be exited early by calling method #commit or #abort. 130 | # 131 | # - Method #commit triggers the update to the store and exits the block: 132 | # 133 | # example_store do |store| 134 | # store.transaction do 135 | # store.keys # => [:foo, :bar, :baz] 136 | # store[:bat] = 3 137 | # store.commit 138 | # fail 'Cannot get here' 139 | # end 140 | # store.transaction do 141 | # # Update was completed. 142 | # store.keys # => [:foo, :bar, :baz, :bat] 143 | # end 144 | # end 145 | # 146 | # - Method #abort discards the update to the store and exits the block: 147 | # 148 | # example_store do |store| 149 | # store.transaction do 150 | # store.keys # => [:foo, :bar, :baz] 151 | # store[:bat] = 3 152 | # store.abort 153 | # fail 'Cannot get here' 154 | # end 155 | # store.transaction do 156 | # # Update was not completed. 157 | # store.keys # => [:foo, :bar, :baz] 158 | # end 159 | # end 160 | # 161 | # === Read-Only Transactions 162 | # 163 | # By default, a transaction allows both reading from and writing to 164 | # the store: 165 | # 166 | # store.transaction do 167 | # # Read-write transaction. 168 | # # Any code except a call to #transaction is allowed here. 169 | # end 170 | # 171 | # If argument +read_only+ is passed as +true+, 172 | # only reading is allowed: 173 | # 174 | # store.transaction(true) do 175 | # # Read-only transaction: 176 | # # Calls to #transaction, #[]=, and #delete are not allowed here. 177 | # end 178 | # 179 | # == Hierarchical Values 180 | # 181 | # The value for an entry may be a simple object (as seen above). 182 | # It may also be a hierarchy of objects nested to any depth: 183 | # 184 | # deep_store = PStore.new('deep.store') 185 | # deep_store.transaction do 186 | # array_of_hashes = [{}, {}, {}] 187 | # deep_store[:array_of_hashes] = array_of_hashes 188 | # deep_store[:array_of_hashes] # => [{}, {}, {}] 189 | # hash_of_arrays = {foo: [], bar: [], baz: []} 190 | # deep_store[:hash_of_arrays] = hash_of_arrays 191 | # deep_store[:hash_of_arrays] # => {:foo=>[], :bar=>[], :baz=>[]} 192 | # deep_store[:hash_of_arrays][:foo].push(:bat) 193 | # deep_store[:hash_of_arrays] # => {:foo=>[:bat], :bar=>[], :baz=>[]} 194 | # end 195 | # 196 | # And recall that you can use 197 | # {dig methods}[https://docs.ruby-lang.org/en/master/dig_methods_rdoc.html] 198 | # in a returned hierarchy of objects. 199 | # 200 | # == Working with the Store 201 | # 202 | # === Creating a Store 203 | # 204 | # Use method PStore.new to create a store. 205 | # The new store creates or opens its containing file: 206 | # 207 | # store = PStore.new('t.store') 208 | # 209 | # === Modifying the Store 210 | # 211 | # Use method #[]= to update or create an entry: 212 | # 213 | # example_store do |store| 214 | # store.transaction do 215 | # store[:foo] = 1 # Update. 216 | # store[:bam] = 1 # Create. 217 | # end 218 | # end 219 | # 220 | # Use method #delete to remove an entry: 221 | # 222 | # example_store do |store| 223 | # store.transaction do 224 | # store.delete(:foo) 225 | # store[:foo] # => nil 226 | # end 227 | # end 228 | # 229 | # === Retrieving Values 230 | # 231 | # Use method #fetch (allows default) or #[] (defaults to +nil+) 232 | # to retrieve an entry: 233 | # 234 | # example_store do |store| 235 | # store.transaction do 236 | # store[:foo] # => 0 237 | # store[:nope] # => nil 238 | # store.fetch(:baz) # => 2 239 | # store.fetch(:nope, nil) # => nil 240 | # store.fetch(:nope) # Raises exception. 241 | # end 242 | # end 243 | # 244 | # === Querying the Store 245 | # 246 | # Use method #key? to determine whether a given key exists: 247 | # 248 | # example_store do |store| 249 | # store.transaction do 250 | # store.key?(:foo) # => true 251 | # end 252 | # end 253 | # 254 | # Use method #keys to retrieve keys: 255 | # 256 | # example_store do |store| 257 | # store.transaction do 258 | # store.keys # => [:foo, :bar, :baz] 259 | # end 260 | # end 261 | # 262 | # Use method #path to retrieve the path to the store's underlying file; 263 | # this method may be called from outside a transaction block: 264 | # 265 | # store = PStore.new('t.store') 266 | # store.path # => "t.store" 267 | # 268 | # == Transaction Safety 269 | # 270 | # For transaction safety, see: 271 | # 272 | # - Optional argument +thread_safe+ at method PStore.new. 273 | # - Attribute #ultra_safe. 274 | # 275 | # Needless to say, if you're storing valuable data with \PStore, then you should 276 | # backup the \PStore file from time to time. 277 | # 278 | # == An Example Store 279 | # 280 | # require "pstore" 281 | # 282 | # # A mock wiki object. 283 | # class WikiPage 284 | # 285 | # attr_reader :page_name 286 | # 287 | # def initialize(page_name, author, contents) 288 | # @page_name = page_name 289 | # @revisions = Array.new 290 | # add_revision(author, contents) 291 | # end 292 | # 293 | # def add_revision(author, contents) 294 | # @revisions << {created: Time.now, 295 | # author: author, 296 | # contents: contents} 297 | # end 298 | # 299 | # def wiki_page_references 300 | # [@page_name] + @revisions.last[:contents].scan(/\b(?:[A-Z]+[a-z]+){2,}/) 301 | # end 302 | # 303 | # end 304 | # 305 | # # Create a new wiki page. 306 | # home_page = WikiPage.new("HomePage", "James Edward Gray II", 307 | # "A page about the JoysOfDocumentation..." ) 308 | # 309 | # wiki = PStore.new("wiki_pages.pstore") 310 | # # Update page data and the index together, or not at all. 311 | # wiki.transaction do 312 | # # Store page. 313 | # wiki[home_page.page_name] = home_page 314 | # # Create page index. 315 | # wiki[:wiki_index] ||= Array.new 316 | # # Update wiki index. 317 | # wiki[:wiki_index].push(*home_page.wiki_page_references) 318 | # end 319 | # 320 | # # Read wiki data, setting argument read_only to true. 321 | # wiki.transaction(true) do 322 | # wiki.keys.each do |key| 323 | # puts key 324 | # puts wiki[key] 325 | # end 326 | # end 327 | # 328 | class PStore 329 | VERSION = "0.2.0" 330 | 331 | RDWR_ACCESS = {mode: IO::RDWR | IO::CREAT | IO::BINARY, encoding: Encoding::ASCII_8BIT}.freeze 332 | RD_ACCESS = {mode: IO::RDONLY | IO::BINARY, encoding: Encoding::ASCII_8BIT}.freeze 333 | WR_ACCESS = {mode: IO::WRONLY | IO::CREAT | IO::TRUNC | IO::BINARY, encoding: Encoding::ASCII_8BIT}.freeze 334 | 335 | # The error type thrown by all PStore methods. 336 | class Error < StandardError 337 | end 338 | 339 | # Whether \PStore should do its best to prevent file corruptions, 340 | # even when an unlikely error (such as memory-error or filesystem error) occurs: 341 | # 342 | # - +true+: changes are posted by creating a temporary file, 343 | # writing the updated data to it, then renaming the file to the given #path. 344 | # File integrity is maintained. 345 | # Note: has effect only if the filesystem has atomic file rename 346 | # (as do POSIX platforms Linux, MacOS, FreeBSD and others). 347 | # 348 | # - +false+ (the default): changes are posted by rewinding the open file 349 | # and writing the updated data. 350 | # File integrity is maintained if the filesystem raises 351 | # no unexpected I/O error; 352 | # if such an error occurs during a write to the store, 353 | # the file may become corrupted. 354 | # 355 | attr_accessor :ultra_safe 356 | 357 | # Returns a new \PStore object. 358 | # 359 | # Argument +file+ is the path to the file in which objects are to be stored; 360 | # if the file exists, it should be one that was written by \PStore. 361 | # 362 | # path = 't.store' 363 | # store = PStore.new(path) 364 | # 365 | # A \PStore object is 366 | # {reentrant}[https://en.wikipedia.org/wiki/Reentrancy_(computing)]. 367 | # If argument +thread_safe+ is given as +true+, 368 | # the object is also thread-safe (at the cost of a small performance penalty): 369 | # 370 | # store = PStore.new(path, true) 371 | # 372 | def initialize(file, thread_safe = false) 373 | dir = File::dirname(file) 374 | unless File::directory? dir 375 | raise PStore::Error, format("directory %s does not exist", dir) 376 | end 377 | if File::exist? file and not File::readable? file 378 | raise PStore::Error, format("file %s not readable", file) 379 | end 380 | @filename = file 381 | @abort = false 382 | @ultra_safe = false 383 | @thread_safe = thread_safe 384 | @lock = Thread::Mutex.new 385 | end 386 | 387 | # Raises PStore::Error if the calling code is not in a PStore#transaction. 388 | def in_transaction 389 | raise PStore::Error, "not in transaction" unless @lock.locked? 390 | end 391 | # 392 | # Raises PStore::Error if the calling code is not in a PStore#transaction or 393 | # if the code is in a read-only PStore#transaction. 394 | # 395 | def in_transaction_wr 396 | in_transaction 397 | raise PStore::Error, "in read-only transaction" if @rdonly 398 | end 399 | private :in_transaction, :in_transaction_wr 400 | 401 | # Returns the value for the given +key+ if the key exists. 402 | # +nil+ otherwise; 403 | # if not +nil+, the returned value is an object or a hierarchy of objects: 404 | # 405 | # example_store do |store| 406 | # store.transaction do 407 | # store[:foo] # => 0 408 | # store[:nope] # => nil 409 | # end 410 | # end 411 | # 412 | # Returns +nil+ if there is no such key. 413 | # 414 | # See also {Hierarchical Values}[rdoc-ref:PStore@Hierarchical+Values]. 415 | # 416 | # Raises an exception if called outside a transaction block. 417 | def [](key) 418 | in_transaction 419 | @table[key] 420 | end 421 | 422 | # Like #[], except that it accepts a default value for the store. 423 | # If the +key+ does not exist: 424 | # 425 | # - Raises an exception if +default+ is +PStore::Error+. 426 | # - Returns the value of +default+ otherwise: 427 | # 428 | # example_store do |store| 429 | # store.transaction do 430 | # store.fetch(:nope, nil) # => nil 431 | # store.fetch(:nope) # Raises an exception. 432 | # end 433 | # end 434 | # 435 | # Raises an exception if called outside a transaction block. 436 | def fetch(key, default=PStore::Error) 437 | in_transaction 438 | unless @table.key? key 439 | if default == PStore::Error 440 | raise PStore::Error, format("undefined key '%s'", key) 441 | else 442 | return default 443 | end 444 | end 445 | @table[key] 446 | end 447 | 448 | # Creates or replaces the value for the given +key+: 449 | # 450 | # example_store do |store| 451 | # temp.transaction do 452 | # temp[:bat] = 3 453 | # end 454 | # end 455 | # 456 | # See also {Hierarchical Values}[rdoc-ref:PStore@Hierarchical+Values]. 457 | # 458 | # Raises an exception if called outside a transaction block. 459 | def []=(key, value) 460 | in_transaction_wr 461 | @table[key] = value 462 | end 463 | 464 | # Removes and returns the value at +key+ if it exists: 465 | # 466 | # example_store do |store| 467 | # store.transaction do 468 | # store[:bat] = 3 469 | # store.delete(:bat) 470 | # end 471 | # end 472 | # 473 | # Returns +nil+ if there is no such key. 474 | # 475 | # Raises an exception if called outside a transaction block. 476 | def delete(key) 477 | in_transaction_wr 478 | @table.delete key 479 | end 480 | 481 | # Returns an array of the existing keys: 482 | # 483 | # example_store do |store| 484 | # store.transaction do 485 | # store.keys # => [:foo, :bar, :baz] 486 | # end 487 | # end 488 | # 489 | # Raises an exception if called outside a transaction block. 490 | def keys 491 | in_transaction 492 | @table.keys 493 | end 494 | alias roots keys 495 | 496 | # Returns +true+ if +key+ exists, +false+ otherwise: 497 | # 498 | # example_store do |store| 499 | # store.transaction do 500 | # store.key?(:foo) # => true 501 | # end 502 | # end 503 | # 504 | # Raises an exception if called outside a transaction block. 505 | def key?(key) 506 | in_transaction 507 | @table.key? key 508 | end 509 | alias root? key? 510 | 511 | # Returns the string file path used to create the store: 512 | # 513 | # store.path # => "flat.store" 514 | # 515 | def path 516 | @filename 517 | end 518 | 519 | # Exits the current transaction block, committing any changes 520 | # specified in the 521 | # {transaction block}[rdoc-ref:PStore@The+Transaction+Block]. 522 | # 523 | # Raises an exception if called outside a transaction block. 524 | def commit 525 | in_transaction 526 | @abort = false 527 | throw :pstore_abort_transaction 528 | end 529 | 530 | # Exits the current transaction block, discarding any changes 531 | # specified in the 532 | # {transaction block}[rdoc-ref:PStore@The+Transaction+Block]. 533 | # 534 | # Raises an exception if called outside a transaction block. 535 | def abort 536 | in_transaction 537 | @abort = true 538 | throw :pstore_abort_transaction 539 | end 540 | 541 | # Opens a transaction block for the store. 542 | # See {Transactions}[rdoc-ref:PStore@Transactions]. 543 | # 544 | # With argument +read_only+ as +false+, the block may both read from 545 | # and write to the store. 546 | # 547 | # With argument +read_only+ as +true+, the block may not include calls 548 | # to #transaction, #[]=, or #delete. 549 | # 550 | # Raises an exception if called within a transaction block. 551 | def transaction(read_only = false) # :yields: pstore 552 | value = nil 553 | if !@thread_safe 554 | raise PStore::Error, "nested transaction" unless @lock.try_lock 555 | else 556 | begin 557 | @lock.lock 558 | rescue ThreadError 559 | raise PStore::Error, "nested transaction" 560 | end 561 | end 562 | begin 563 | @rdonly = read_only 564 | @abort = false 565 | file = open_and_lock_file(@filename, read_only) 566 | if file 567 | begin 568 | @table, checksum, original_data_size = load_data(file, read_only) 569 | 570 | catch(:pstore_abort_transaction) do 571 | value = yield(self) 572 | end 573 | 574 | if !@abort && !read_only 575 | save_data(checksum, original_data_size, file) 576 | end 577 | ensure 578 | file.close 579 | end 580 | else 581 | # This can only occur if read_only == true. 582 | @table = {} 583 | catch(:pstore_abort_transaction) do 584 | value = yield(self) 585 | end 586 | end 587 | ensure 588 | @lock.unlock 589 | end 590 | value 591 | end 592 | 593 | private 594 | # Constant for relieving Ruby's garbage collector. 595 | CHECKSUM_ALGO = %w[SHA512 SHA384 SHA256 SHA1 RMD160 MD5].each do |algo| 596 | begin 597 | break Digest(algo) 598 | rescue LoadError 599 | end 600 | end 601 | EMPTY_STRING = "" 602 | EMPTY_MARSHAL_DATA = Marshal.dump({}) 603 | EMPTY_MARSHAL_CHECKSUM = CHECKSUM_ALGO.digest(EMPTY_MARSHAL_DATA) 604 | 605 | EMPTY_MARSHAL_DATA.freeze 606 | EMPTY_MARSHAL_CHECKSUM.freeze 607 | 608 | # 609 | # Open the specified filename (either in read-only mode or in 610 | # read-write mode) and lock it for reading or writing. 611 | # 612 | # The opened File object will be returned. If _read_only_ is true, 613 | # and the file does not exist, then nil will be returned. 614 | # 615 | # All exceptions are propagated. 616 | # 617 | def open_and_lock_file(filename, read_only) 618 | if read_only 619 | begin 620 | file = File.new(filename, **RD_ACCESS) 621 | begin 622 | file.flock(File::LOCK_SH) 623 | return file 624 | rescue 625 | file.close 626 | raise 627 | end 628 | rescue Errno::ENOENT 629 | return nil 630 | end 631 | else 632 | file = File.new(filename, **RDWR_ACCESS) 633 | file.flock(File::LOCK_EX) 634 | return file 635 | end 636 | end 637 | 638 | # Load the given PStore file. 639 | # If +read_only+ is true, the unmarshalled Hash will be returned. 640 | # If +read_only+ is false, a 3-tuple will be returned: the unmarshalled 641 | # Hash, a checksum of the data, and the size of the data. 642 | def load_data(file, read_only) 643 | if read_only 644 | begin 645 | table = load(file) 646 | raise Error, "PStore file seems to be corrupted." unless table.is_a?(Hash) 647 | rescue EOFError 648 | # This seems to be a newly-created file. 649 | table = {} 650 | end 651 | table 652 | else 653 | data = file.read 654 | if data.empty? 655 | # This seems to be a newly-created file. 656 | table = {} 657 | checksum = empty_marshal_checksum 658 | size = empty_marshal_data.bytesize 659 | else 660 | table = load(data) 661 | checksum = CHECKSUM_ALGO.digest(data) 662 | size = data.bytesize 663 | raise Error, "PStore file seems to be corrupted." unless table.is_a?(Hash) 664 | end 665 | data.replace(EMPTY_STRING) 666 | [table, checksum, size] 667 | end 668 | end 669 | 670 | def on_windows? 671 | is_windows = RUBY_PLATFORM =~ /mswin|mingw|bccwin|wince/ 672 | 673 | on_windows_proc = Proc.new do 674 | is_windows 675 | end 676 | Ractor.make_shareable(on_windows_proc) if defined?(Ractor) 677 | self.class.__send__(:define_method, :on_windows?, &on_windows_proc) 678 | is_windows 679 | end 680 | 681 | def save_data(original_checksum, original_file_size, file) 682 | new_data = dump(@table) 683 | 684 | if new_data.bytesize != original_file_size || CHECKSUM_ALGO.digest(new_data) != original_checksum 685 | if @ultra_safe && !on_windows? 686 | # Windows doesn't support atomic file renames. 687 | save_data_with_atomic_file_rename_strategy(new_data, file) 688 | else 689 | save_data_with_fast_strategy(new_data, file) 690 | end 691 | end 692 | 693 | new_data.replace(EMPTY_STRING) 694 | end 695 | 696 | def save_data_with_atomic_file_rename_strategy(data, file) 697 | temp_filename = "#{@filename}.tmp.#{Process.pid}.#{rand 1000000}" 698 | temp_file = File.new(temp_filename, **WR_ACCESS) 699 | begin 700 | temp_file.flock(File::LOCK_EX) 701 | temp_file.write(data) 702 | temp_file.flush 703 | File.rename(temp_filename, @filename) 704 | rescue 705 | File.unlink(temp_file) rescue nil 706 | raise 707 | ensure 708 | temp_file.close 709 | end 710 | end 711 | 712 | def save_data_with_fast_strategy(data, file) 713 | file.rewind 714 | file.write(data) 715 | file.truncate(data.bytesize) 716 | end 717 | 718 | 719 | # This method is just a wrapped around Marshal.dump 720 | # to allow subclass overriding used in YAML::Store. 721 | def dump(table) # :nodoc: 722 | Marshal::dump(table) 723 | end 724 | 725 | # This method is just a wrapped around Marshal.load. 726 | # to allow subclass overriding used in YAML::Store. 727 | def load(content) # :nodoc: 728 | Marshal::load(content) 729 | end 730 | 731 | def empty_marshal_data 732 | EMPTY_MARSHAL_DATA 733 | end 734 | def empty_marshal_checksum 735 | EMPTY_MARSHAL_CHECKSUM 736 | end 737 | end 738 | --------------------------------------------------------------------------------