qwe 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md ADDED
@@ -0,0 +1,134 @@
1
+ # Qwe - pure ruby framework for stuff
2
+
3
+ The stuff is mostly complex ruby objects living in RAM for a long time, accessed over DRb. Qwe provides database-like interface for managing that with a few extras, while attempting to impose as little bounds
4
+ as possible on the way you write code.
5
+
6
+ [Full Documentation](DOCS.md)
7
+
8
+ ## Features
9
+
10
+ ### Zero-config startup
11
+
12
+ ```
13
+ bundle exec qwe
14
+ ```
15
+ And you are ready to go.
16
+ [Puma plugin](DOCS.md#Puma-plugin) is also available.
17
+
18
+ ### Object persistence
19
+
20
+ ```ruby
21
+ id = Qwe::DB.create(Hash)
22
+ ```
23
+ The hash is now accessible from any ruby process with `Qwe::DB[]`,
24
+ considering you know that id.
25
+
26
+ ```ruby
27
+ Qwe::DB[id][:qwe] = 123
28
+ ```
29
+
30
+ After a period of inactivity, or manually if configured,
31
+ hash will get dumped on disk with `Marshal` and RAM freed.
32
+ Once you call `Qwe::DB[id]` again, object gets un-marshalled and lives in RAM the same way.
33
+
34
+ ### Attributes API
35
+
36
+ ```ruby
37
+ class Person
38
+ include Qwe::Mixins::Thing
39
+
40
+ attribute :age, min: 0, init: 18, convert: :to_i
41
+ end
42
+ ```
43
+ is equivalent to
44
+ ```ruby
45
+ class Person
46
+ def initialize
47
+ @age = 18
48
+ end
49
+
50
+ attr_reader :age
51
+
52
+ def age=(val)
53
+ val = val.to_i
54
+ val = 0 if val < 0
55
+ @age = val
56
+ end
57
+ end
58
+ ```
59
+
60
+ Despite the fact that `attribute` method accepts many arguments and can be extended,
61
+ performance of both variants is the same.
62
+
63
+ ### Transactions
64
+
65
+ Every attribute change can be tracked, so you can scroll back and forth your object state
66
+ throughout it's lifecycle. Changes are stored as ruby code in plaintext file.
67
+
68
+ requirements.rb
69
+ ```ruby
70
+ class Person
71
+ include Qwe::Mixins::Root
72
+
73
+ attribute :age, init: 0, convert: :to_i
74
+ end
75
+ ```
76
+ Server startup
77
+ ```
78
+ qwe -r requirements.rb
79
+ ```
80
+ ruby console
81
+ ```ruby
82
+ id = Qwe::DB.create(:Person)
83
+ Qwe::DB[id].age = 1
84
+ Qwe::DB[id].age += 2
85
+ Qwe::DB[id].age += 3
86
+ puts Qwe::DB[id].record.commits
87
+ ```
88
+ Outputs:
89
+ ```ruby
90
+ self.instance_variable_set(:@age, 1)
91
+ self.instance_variable_set(:@age, 3)
92
+ self.instance_variable_set(:@age, 6)
93
+ ```
94
+
95
+ While commit "logs" are not handcrafted ruby code, they are still readable.
96
+ Most standard types are supported and subject to extension.
97
+
98
+ # Requirements
99
+
100
+ ruby 3+ is required, but since long-running workers greatly benefit from YJIT compiler, and compiler is greatly improved in subsequent releases, it is better to use latest ruby whenever possible.
101
+
102
+ # Notes
103
+
104
+ ## Is it production ready?
105
+
106
+ It is extracted from production and backported there, but your case may be different. Feedback is welcome.
107
+
108
+ ## Does it scale?
109
+
110
+ It depends, but mostly yes.
111
+
112
+ On MRI one object lives in one thread. Qwe can work with any amount of worker threads,
113
+ but it's for increasing amount of simultaneously served objects.
114
+ Other ruby implementations are not tested.
115
+
116
+ The primary limitation and at the same time strength is the fact that "root" objects
117
+ (the ones created with Qwe::DB.create) are fully loaded into RAM upon access.
118
+ It makes working with them faster after a short startup delay, but does not allow to effectively perform SQL-like
119
+ requests across multiple root objects.
120
+
121
+ If root object are independent from each other, system scales easily
122
+ by running independent Qwe servers.
123
+
124
+ ## Note on security
125
+
126
+ `Qwe::DB[]` provides access to object from another thread with **DRb**, ruby gem from standard library.
127
+ DRb does not provide any access control, and opens separate port for each object it serves.
128
+ It is safe to run Qwe in docker container or other isolated network, and unsafe to run on virtual or physical device with all ports open.
129
+
130
+ See [DRb docs](https://docs.ruby-lang.org/en/3.3/DRb.html#module-DRb-label-Security) for a more detailed explanation.
131
+
132
+ ## License
133
+
134
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+ require "etc"
6
+
7
+ task :server do
8
+ pid = spawn("#{__dir__}/exe/qwe", "-p", "3229", "-d", Etc.systmpdir + "/qwe_db", "-r", "#{__dir__}/test/requirements.rb", "-s", "30", "--gc", "5", "-w", "#{__dir__}/lib")
9
+ Process.wait(pid)
10
+ end
11
+
12
+ Rake::TestTask.new(:test) do |t|
13
+ t.libs << "test"
14
+ t.libs << "lib"
15
+ t.test_files = FileList["test/**/test_*.rb"]
16
+ end
17
+
18
+ require "rubocop/rake_task"
19
+
20
+ RuboCop::RakeTask.new
21
+
22
+ task default: %i[test rubocop]
data/exe/qwe ADDED
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+
6
+ require "qwe"
7
+ require "optparse"
8
+
9
+ args = {}
10
+ opt_parser = OptionParser.new do |parser|
11
+ parser.banner = "Usage: qwe [options] - start server\n"
12
+
13
+ parser.on("-dDIR", "--dir=DIR", "Directory with persistent data") do |a|
14
+ args[:dir] = a
15
+ end
16
+
17
+ parser.on("-pPORT", "--port=PORT", "Server port") do |a|
18
+ args[:port] = a
19
+ end
20
+
21
+ parser.on("-wDIR", "--watch=DIR", "Restart server on changes in that directory. Can be passed multiple times") do |a|
22
+ args[:watch] ||= []
23
+ args[:watch].push a
24
+ end
25
+
26
+ parser.on("-rFILE", "--require=FILE", "--require_file=FILE", "This file will be required in worker threads") do |a|
27
+ args[:require_file] = File.expand_path(a)
28
+ end
29
+
30
+ parser.on("-iID", "--interactive=ID", "Launch interactive console bound to record #ID") do |a|
31
+ args[:interactive] = a.to_i
32
+ end
33
+
34
+ parser.on("-cCLASS", "--create=CLASS", "Connect to running server, create new record of class CLASS, and print its id") do |a|
35
+ args[:create] = a
36
+ end
37
+
38
+ parser.on("-tTHREADS", "--threads=THREADS", "Worker threads count. Defaults to the number of CPU cores") do |a|
39
+ args[:threads] = a.to_i
40
+ end
41
+
42
+ parser.on("--gc=SECONDS", "--gc_interval=SECONDS", "Call garbage collector (GC.start) every x seconds. Off by default") do |a|
43
+ args[:gc_interval] = a.to_i
44
+ end
45
+
46
+ parser.on("-sSECONDS", "--detach=SECONDS", "--detach_timeout=SECONDS", "Detach record after x seconds since creation or loading. 0 - disable, default 300.") do |a|
47
+ args[:detach_timeout] = a.to_i
48
+ end
49
+
50
+ parser.on("--no-jit", "--no_jit", "Disable JIT. It's on by default") do |a|
51
+ args[:no_jit] = true
52
+ end
53
+
54
+ parser.on("-h", "--help", "Print this help") do
55
+ puts parser
56
+ exit
57
+ end
58
+ end
59
+ opt_parser.parse!
60
+
61
+ def spawn_server(args)
62
+ spawn(__FILE__, *((args.to_a.filter { |a| a[0] != :watch }).map { |a| "--#{a[0]}=#{a[1]}" }))
63
+ end
64
+
65
+ if args[:watch]
66
+ begin
67
+ require "listen"
68
+ rescue LoadError
69
+ puts "Can't load 'listen' gem"
70
+ exit 1
71
+ end
72
+ pid = spawn_server(args)
73
+ Process.detach(pid)
74
+
75
+ puts "Watching changes in #{args[:watch]}..."
76
+ Listen.to(*args[:watch]) do
77
+ Process.kill("INT", pid)
78
+ pid = spawn_server(args)
79
+ Process.detach(pid)
80
+ puts "\n\n"
81
+ end.start
82
+
83
+ trap :INT do
84
+ Process.kill("INT", pid)
85
+ Process.exit
86
+ end
87
+ sleep
88
+ elsif args[:interactive]
89
+ require "irb"
90
+
91
+ Qwe::DB.connect("druby://localhost:#{args[:port]}") if args[:port]
92
+
93
+ if args[:interactive] > 0
94
+ obj = Qwe::DB[args[:interactive]]
95
+ puts "\e[0;32mQwe interactive prompt\e[0m, record \e[1;32m##{args[:interactive]}\e[0m, class \e[1;36m#{obj.record.obj_eval("self.class.to_s")}\e[0m"
96
+ else
97
+ obj = Qwe::DB.server.pick_worker
98
+ puts "\e[0;32mQwe interactive prompt\e[0m, worker \e[1;36m#{obj.uri}\e[0m, require #{obj.requirements}"
99
+
100
+ require obj.requirements
101
+ end
102
+
103
+ (obj.instance_exec { binding }).instance_exec do
104
+ irb(show_code: false)
105
+ rescue
106
+ # Older irb versions always print source code around call point
107
+ IRB.setup(source_location[0], argv: [])
108
+ workspace = IRB::WorkSpace.new(self)
109
+ binding_irb = IRB::Irb.new(workspace)
110
+ binding_irb.context.irb_path = File.expand_path(source_location[0])
111
+ binding_irb.run(IRB.conf)
112
+ end
113
+ elsif args[:create]
114
+ Qwe::DB.connect("druby://localhost:#{args[:port]}") if args[:port]
115
+ puts Qwe::DB.create(args[:create].to_sym)
116
+ else
117
+ Qwe::DB.serve(**args)
118
+ end
data/lib/guerilla.rb ADDED
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ class BasicObject
4
+ def self.to_rb
5
+ name
6
+ end
7
+ end
8
+
9
+ class String
10
+ def to_rb
11
+ "'" + gsub("'", "\\\\'") + "'"
12
+ end
13
+ end
14
+
15
+ class Symbol
16
+ def to_rb
17
+ ":\"#{self}\""
18
+ end
19
+ end
20
+
21
+ class Hash
22
+ def to_rb
23
+ "{" + (to_a.map { |v| "#{v[0].to_rb} => #{v[1].to_rb}" }).join(", ") + "}"
24
+ end
25
+ end
26
+
27
+ class Array
28
+ def to_rb
29
+ "[" + (map { |v| v.to_rb }).join(", ") + "]"
30
+ end
31
+ end
32
+
33
+ class Time
34
+ def to_rb
35
+ "Time.at(#{to_r}r)"
36
+ end
37
+ end
38
+
39
+ class Float
40
+ def to_rb
41
+ if nan?
42
+ "Float::NAN"
43
+ elsif infinite?
44
+ "Float::INFINITY"
45
+ else
46
+ to_s
47
+ end
48
+ end
49
+ end
50
+
51
+ class Integer
52
+ alias_method :to_rb, :to_s
53
+ end
54
+
55
+ class Rational
56
+ def to_rb
57
+ to_s + "r"
58
+ end
59
+ end
60
+
61
+ class Vector
62
+ def to_rb
63
+ "Vector#{to_a.to_rb}"
64
+ end
65
+ end
66
+
67
+ class TrueClass
68
+ def to_rb
69
+ "true"
70
+ end
71
+ end
72
+
73
+ class FalseClass
74
+ def to_rb
75
+ "false"
76
+ end
77
+ end
78
+
79
+ class NilClass
80
+ def to_rb
81
+ "nil"
82
+ end
83
+ end
@@ -0,0 +1,80 @@
1
+ require "puma/plugin"
2
+
3
+ Puma::Plugin.create do
4
+ attr_reader :puma_pid, :qwe_pid, :log_writer
5
+
6
+ def start(launcher)
7
+ @log_writer = launcher.log_writer
8
+ @puma_pid = $$
9
+
10
+ in_background do
11
+ monitor_qwe
12
+ end
13
+
14
+ launcher.events.on_booted do
15
+ @qwe_pid = fork do
16
+ Thread.new { monitor_puma }
17
+ conf = Rails.root.join("config", "qwe.yml")
18
+ if File.exist?(conf)
19
+ log "Loading config #{conf}"
20
+ Qwe::DB.serve(**YAML.parse_file(conf).to_ruby(symbolize_names: true))
21
+ else
22
+ Qwe::DB.serve
23
+ end
24
+ end
25
+ end
26
+
27
+ launcher.events.on_stopped { stop_qwe }
28
+ launcher.events.on_restart { stop_qwe }
29
+ end
30
+
31
+ private
32
+
33
+ def stop_qwe
34
+ Process.waitpid(qwe_pid, Process::WNOHANG)
35
+ log "Stopping Qwe..."
36
+ Process.kill(:INT, qwe_pid) if qwe_pid
37
+ Process.wait(qwe_pid)
38
+ rescue Errno::ECHILD, Errno::ESRCH
39
+ end
40
+
41
+ def monitor_puma
42
+ monitor(:puma_dead?, "Detected Puma has gone away, stopping Qwe...")
43
+ end
44
+
45
+ def monitor_qwe
46
+ monitor(:qwe_dead?, "Detected Qwe has gone away, stopping Puma...")
47
+ end
48
+
49
+ def monitor(process_dead, message)
50
+ loop do
51
+ if send(process_dead)
52
+ log message
53
+ Process.kill(:INT, $$)
54
+ break
55
+ end
56
+ sleep 2
57
+ end
58
+ end
59
+
60
+ def qwe_dead?
61
+ if qwe_started?
62
+ Process.waitpid(qwe_pid, Process::WNOHANG)
63
+ end
64
+ false
65
+ rescue Errno::ECHILD, Errno::ESRCH
66
+ true
67
+ end
68
+
69
+ def qwe_started?
70
+ qwe_pid.present?
71
+ end
72
+
73
+ def puma_dead?
74
+ Process.ppid != puma_pid
75
+ end
76
+
77
+ def log(...)
78
+ log_writer.log(...)
79
+ end
80
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Qwe
4
+ class Attribute
5
+ @@possible_params = {}
6
+
7
+ [:name, :reader, :writer].each do |k|
8
+ @@possible_params[k] = true
9
+ attr_accessor k
10
+ end
11
+
12
+ attr_accessor :klass, :params
13
+
14
+ def initialize(klass, name, *booleans, writer: nil, reader: nil, **params)
15
+ @klass = klass
16
+ @name = name
17
+ @params = params
18
+ raise "Parameter 'name' is reserved" if @params[:name]
19
+ @params[:name] = name
20
+ booleans.each { |b| @params[b] = true }
21
+
22
+ @params.each_key do |p|
23
+ raise "Unknown parameter #{p}" unless @@possible_params.include?(p)
24
+ end
25
+
26
+ @reader = if reader
27
+ if reader.is_a?(Proc)
28
+ reader
29
+ else
30
+ raise "Reader parameter should be a proc, #{reader.class} given"
31
+ end
32
+ else
33
+ Qwe::Function.new(klass, name)
34
+ end
35
+
36
+ @writer = Qwe::Function.new(klass, :"#{name}=")
37
+ if writer
38
+ if writer.is_a?(Proc)
39
+ klass.define_method(:"#{name}_writer", &writer)
40
+ @writer.stage("value = #{name}_writer(value)")
41
+ else
42
+ raise "writer parameter should be a Proc, #{writer.class} given"
43
+ end
44
+ end
45
+ end
46
+
47
+ @@extensions = []
48
+ def self.add(p = nil, &block)
49
+ if p
50
+ @@possible_params[p] = true
51
+ if block
52
+ @@extensions.push(proc { instance_exec(&block) if self[p] })
53
+ end
54
+ elsif block
55
+ @@extensions.push(block)
56
+ else
57
+ raise ArgumentError.new("Attribute.add - param or block required")
58
+ end
59
+ end
60
+
61
+ def compile
62
+ writer.arg(:value)
63
+
64
+ @@extensions.each do |block|
65
+ instance_exec(&block)
66
+ end
67
+
68
+ if reader.is_a?(Function)
69
+ reader.stage "@#{name}"
70
+ reader.compile
71
+ else
72
+ klass.define_method(name, &reader)
73
+ end
74
+
75
+ writer.stage("@#{name} = value")
76
+ writer.stage("root&.record&.commit(\"\#{to_rb}.instance_variable_set(:@#{name}, \#{value.to_rb})\\n\")")
77
+ writer.stage("value")
78
+ writer.compile
79
+ end
80
+
81
+ def method_missing(symbol, *args)
82
+ if @@possible_params.has_key?(symbol)
83
+ @params[symbol]
84
+ else
85
+ super
86
+ end
87
+ end
88
+
89
+ def respond_to_missing?(symbol, *args)
90
+ @@possible_params[symbol] || super
91
+ end
92
+
93
+ def [](key)
94
+ @params[key]
95
+ end
96
+
97
+ def inspect
98
+ "Attribute #{klass}.#{params}"
99
+ end
100
+
101
+ def init_or_default_stage(p)
102
+ if self[p].is_a?(Class)
103
+ "#{self[p]}.new"
104
+ else
105
+ klass.class_variable_set(:"@@#{name}_#{p}", self[p])
106
+ if self[p].is_a?(Proc)
107
+ "instance_exec(&@@#{name}_#{p})"
108
+ else
109
+ "@@#{name}_#{p}.clone"
110
+ end
111
+ end
112
+ end
113
+
114
+ add(:init) do
115
+ klass.init.stage "self.#{name} = " + init_or_default_stage(:init)
116
+ end
117
+
118
+ add(:default) do
119
+ reader.stage "self.#{name} = " + init_or_default_stage(:default) + " unless @#{name}"
120
+ end
121
+
122
+ add(:convert) do
123
+ writer.stage "value = value.#{convert}"
124
+ end
125
+
126
+ add(:min) do
127
+ klass.class_variable_set(:"@@#{name}_min", convert && min.send(convert) || min)
128
+ writer.stage "value = @@#{name}_min.clone if value < @@#{name}_min"
129
+ end
130
+
131
+ add(:max) do
132
+ klass.class_variable_set(:"@@#{name}_max", convert && max.send(convert) || max)
133
+ writer.stage "value = @@#{name}_max.clone if value > @@#{name}_max"
134
+ end
135
+
136
+ add(:array) do
137
+ writer.stage "value = Qwe::Attribute::ArrayProxy.from_array(self, :#{name}, value)"
138
+ klass.init.stage("self.#{name} = []") unless init
139
+ end
140
+
141
+ add(:enum) do
142
+ raise "#{klass}.#{name} - enum #{enum} doesn't respond to include?" unless enum.respond_to?(:include?)
143
+ klass.class_variable_set(:"@@#{name}_enum", enum)
144
+ writer.stage "raise \"Enum - unknown value \#{self}.#{name} = \#{value}\" unless @@#{name}_enum.include?(value)"
145
+ end
146
+
147
+ class ArrayProxy < Array
148
+ attr_accessor :__qwe_thing
149
+ attr_accessor :__qwe_name
150
+
151
+ include DRb::DRbUndumped
152
+
153
+ undef _dump
154
+
155
+ def __qwe_commit(m, *a, **k)
156
+ __qwe_thing&.root&.record&.commit("#{__qwe_thing.to_rb}.#{__qwe_name}.method(:#{m}).super_method.call(*#{a.to_rb}, **#{k.to_rb})\n")
157
+ end
158
+
159
+ TRANSACTION_METHODS = [:<<, :[]=, :clear, :compact!, :concat, :delete_at, :flatten!, :insert, :pop, :push, :replace, :reverse!, :rotate!, :shift, :shuffle!, :slice!, :unshift]
160
+ REPLACE_METHODS = [:delete, :delete_if, :fill, :filter!, :select!, :keep_if, :map!, :reject!, :sort!, :sort_by!, :uniq!]
161
+
162
+ TRANSACTION_METHODS.each do |m|
163
+ define_method(m) do |*a, **k|
164
+ __qwe_commit(m, *a, **k)
165
+ super(*a, **k)
166
+ end
167
+ end
168
+
169
+ REPLACE_METHODS.each do |m|
170
+ define_method(m) do |*a, **k, &block|
171
+ r = super(*a, **k, &block)
172
+ if block
173
+ __qwe_thing&.root&.record&.commit "#{__qwe_thing.to_rb}.instance_variable_set(:@#{__qwe_name}, #{to_rb})\n"
174
+ else
175
+ __qwe_commit(m, *a, **k)
176
+ end
177
+ r
178
+ end
179
+ end
180
+
181
+ def self.from_array(thing, name, arr = [])
182
+ a = new(arr)
183
+ a.__qwe_thing = thing
184
+ a.__qwe_name = name
185
+ a
186
+ end
187
+
188
+ def to_rb
189
+ "Qwe::Attribute::ArrayProxy.from_array(#{__qwe_thing.to_rb}, :#{__qwe_name}, #{super})"
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zstds"
4
+ require "fileutils"
5
+
6
+ module Qwe::DB
7
+ class CommitsFile
8
+ include DRb::DRbUndumped
9
+
10
+ attr_reader :path, :archive_path, :file
11
+
12
+ def initialize(dir)
13
+ @path = File.join(dir, "commits")
14
+ @archive_path = File.join(dir, "commits.zst")
15
+ unless File.exist?(@archive_path)
16
+ @file = File.open(@path, "a+")
17
+ end
18
+ end
19
+
20
+ def write(str)
21
+ unless file
22
+ log "Write #{str} to archived commits file #{path}, unpacking"
23
+ ZSTDS::File.decompress archive_path, path
24
+ FileUtils.rm_f(archive_path)
25
+ @file = File.open(@path, "a+")
26
+ end
27
+ file.write(str)
28
+ rescue => e
29
+ log "Couldn't write #{str} to #{path}: #{e.full_message}"
30
+ 0
31
+ end
32
+
33
+ def read(line = nil)
34
+ if file
35
+ file.flush
36
+ file.rewind
37
+ if line
38
+ lines = file.readlines
39
+ lines[0, line].join("\n")
40
+ else
41
+ file.read
42
+ end
43
+ else
44
+ cs = ZSTDS::Stream::Reader.open(archive_path) do |r|
45
+ r.set_encoding("UTF-8")
46
+ r.read
47
+ end
48
+ if line
49
+ cs.lines[0, line].join("")
50
+ else
51
+ cs
52
+ end
53
+ end
54
+ end
55
+
56
+ def archive!
57
+ file&.close
58
+ ZSTDS::File.compress path, archive_path
59
+ FileUtils.rm_f(path)
60
+ @file = nil
61
+ end
62
+
63
+ def flush
64
+ file&.flush
65
+ end
66
+
67
+ def close
68
+ file&.close
69
+ end
70
+ end
71
+ end