litestack 0.4.1 → 0.4.2
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.
- checksums.yaml +4 -4
- data/.standard.yml +3 -0
- data/BENCHMARKS.md +23 -7
- data/CHANGELOG.md +11 -0
- data/Gemfile +1 -7
- data/Gemfile.lock +92 -0
- data/README.md +120 -6
- data/ROADMAP.md +45 -0
- data/Rakefile +3 -1
- data/WHYLITESTACK.md +1 -1
- data/assets/litecache_metrics.png +0 -0
- data/assets/litedb_metrics.png +0 -0
- data/assets/litemetric_logo_teal.png +0 -0
- data/assets/litesearch_logo_teal.png +0 -0
- data/bench/bench.rb +17 -10
- data/bench/bench_cache_rails.rb +10 -13
- data/bench/bench_cache_raw.rb +17 -22
- data/bench/bench_jobs_rails.rb +18 -12
- data/bench/bench_jobs_raw.rb +17 -10
- data/bench/bench_queue.rb +4 -6
- data/bench/rails_job.rb +5 -7
- data/bench/skjob.rb +4 -4
- data/bench/uljob.rb +6 -6
- data/lib/action_cable/subscription_adapter/litecable.rb +5 -8
- data/lib/active_job/queue_adapters/litejob_adapter.rb +6 -8
- data/lib/active_record/connection_adapters/litedb_adapter.rb +65 -75
- data/lib/active_support/cache/litecache.rb +38 -41
- data/lib/generators/litestack/install/install_generator.rb +3 -3
- data/lib/generators/litestack/install/templates/database.yml +7 -1
- data/lib/litestack/liteboard/liteboard.rb +269 -149
- data/lib/litestack/litecable.rb +41 -37
- data/lib/litestack/litecable.sql.yml +22 -11
- data/lib/litestack/litecache.rb +79 -88
- data/lib/litestack/litecache.sql.yml +81 -22
- data/lib/litestack/litecache.yml +1 -1
- data/lib/litestack/litedb.rb +35 -40
- data/lib/litestack/litejob.rb +30 -29
- data/lib/litestack/litejobqueue.rb +63 -65
- data/lib/litestack/litemetric.rb +80 -92
- data/lib/litestack/litemetric.sql.yml +244 -234
- data/lib/litestack/litemetric_collector.sql.yml +38 -41
- data/lib/litestack/litequeue.rb +39 -41
- data/lib/litestack/litequeue.sql.yml +39 -31
- data/lib/litestack/litescheduler.rb +15 -15
- data/lib/litestack/litesearch/index.rb +93 -63
- data/lib/litestack/litesearch/model.rb +66 -65
- data/lib/litestack/litesearch/schema.rb +53 -56
- data/lib/litestack/litesearch/schema_adapters/backed_adapter.rb +46 -50
- data/lib/litestack/litesearch/schema_adapters/basic_adapter.rb +44 -35
- data/lib/litestack/litesearch/schema_adapters/contentless_adapter.rb +3 -6
- data/lib/litestack/litesearch/schema_adapters/standalone_adapter.rb +7 -9
- data/lib/litestack/litesearch/schema_adapters.rb +4 -9
- data/lib/litestack/litesearch.rb +6 -9
- data/lib/litestack/litesupport.rb +76 -86
- data/lib/litestack/railtie.rb +1 -1
- data/lib/litestack/version.rb +2 -2
- data/lib/litestack.rb +6 -4
- data/lib/railties/rails/commands/dbconsole.rb +11 -15
- data/lib/sequel/adapters/litedb.rb +16 -21
- data/lib/sequel/adapters/shared/litedb.rb +168 -168
- data/scripts/build_metrics.rb +91 -0
- data/scripts/test_cable.rb +30 -0
- data/scripts/test_job_retry.rb +33 -0
- data/scripts/test_metrics.rb +60 -0
- data/template.rb +2 -2
- metadata +101 -6
@@ -1,56 +1,53 @@
|
|
1
1
|
schema:
|
2
2
|
1:
|
3
|
-
|
4
3
|
create_events: >
|
5
4
|
CREATE TABLE IF NOT EXISTS local_events(
|
6
5
|
topic TEXT NOT NULL,
|
7
|
-
name TEXT DEFAULT('___') NOT NULL ON CONFLICT REPLACE,
|
8
|
-
key TEXT DEFAULT('___') NOT NULL ON CONFLICT REPLACE,
|
9
|
-
count INTEGER DEFAULT(0) NOT NULL ON CONFLICT REPLACE,
|
10
|
-
value REAL,
|
11
|
-
minimum REAL,
|
12
|
-
maximum REAL,
|
13
|
-
created_at INTEGER DEFAULT((unixepoch()/300*300)) NOT NULL ON CONFLICT REPLACE,
|
14
|
-
resolution TEXT DEFAULT('minute') NOT NULL,
|
6
|
+
name TEXT DEFAULT('___') NOT NULL ON CONFLICT REPLACE,
|
7
|
+
key TEXT DEFAULT('___') NOT NULL ON CONFLICT REPLACE,
|
8
|
+
count INTEGER DEFAULT(0) NOT NULL ON CONFLICT REPLACE,
|
9
|
+
value REAL,
|
10
|
+
minimum REAL,
|
11
|
+
maximum REAL,
|
12
|
+
created_at INTEGER DEFAULT((unixepoch()/300*300)) NOT NULL ON CONFLICT REPLACE,
|
13
|
+
resolution TEXT DEFAULT('minute') NOT NULL,
|
15
14
|
PRIMARY KEY(resolution, created_at, topic, name, key)
|
16
|
-
) STRICT;
|
15
|
+
) STRICT;
|
17
16
|
|
18
17
|
stmts:
|
19
|
-
|
20
18
|
capture_event: >
|
21
|
-
INSERT INTO local_events(topic, name, key, created_at, count, value, minimum, maximum)
|
22
|
-
VALUES
|
23
|
-
(?1, ?2, ?3, ?4, ?5, ?6, ?6, ?6),
|
19
|
+
INSERT INTO local_events(topic, name, key, created_at, count, value, minimum, maximum)
|
20
|
+
VALUES
|
21
|
+
(?1, ?2, ?3, ?4, ?5, ?6, ?6, ?6),
|
24
22
|
(?1, ?2, '___', ?4, ?5, ?6, ?6, ?6),
|
25
|
-
(?1, '___', '___', ?4, ?5, ?6, ?6, ?6)
|
26
|
-
ON CONFLICT DO UPDATE
|
27
|
-
SET
|
28
|
-
count = count + EXCLUDED.count,
|
29
|
-
value = value + EXCLUDED.value,
|
30
|
-
minimum = min(minimum, EXCLUDED.minimum),
|
31
|
-
maximum = max(maximum, EXCLUDED.maximum)
|
32
|
-
|
33
|
-
migrate_events:
|
34
|
-
INSERT INTO m.events(topic, name, key, created_at, count, value, minimum, maximum)
|
35
|
-
SELECT topic, name, key, created_at, count, value, minimum, maximum
|
36
|
-
FROM local_events
|
37
|
-
ORDER BY resolution, created_at ASC, topic, name, key
|
23
|
+
(?1, '___', '___', ?4, ?5, ?6, ?6, ?6)
|
24
|
+
ON CONFLICT DO UPDATE
|
25
|
+
SET
|
26
|
+
count = count + EXCLUDED.count,
|
27
|
+
value = value + EXCLUDED.value,
|
28
|
+
minimum = min(minimum, EXCLUDED.minimum),
|
29
|
+
maximum = max(maximum, EXCLUDED.maximum);
|
30
|
+
|
31
|
+
migrate_events: >
|
32
|
+
INSERT INTO m.events(topic, name, key, created_at, count, value, minimum, maximum)
|
33
|
+
SELECT topic, name, key, created_at, count, value, minimum, maximum
|
34
|
+
FROM local_events
|
35
|
+
ORDER BY resolution, created_at ASC, topic, name, key
|
38
36
|
LIMIT ?
|
39
|
-
ON CONFLICT DO UPDATE
|
40
|
-
SET
|
41
|
-
count = count + EXCLUDED.count,
|
42
|
-
value = value + EXCLUDED.value,
|
43
|
-
minimum = min(minimum, EXCLUDED.minimum),
|
44
|
-
maximum = max(maximum, EXCLUDED.maximum)
|
37
|
+
ON CONFLICT DO UPDATE
|
38
|
+
SET
|
39
|
+
count = count + EXCLUDED.count,
|
40
|
+
value = value + EXCLUDED.value,
|
41
|
+
minimum = min(minimum, EXCLUDED.minimum),
|
42
|
+
maximum = max(maximum, EXCLUDED.maximum);
|
45
43
|
|
46
|
-
delete_migrated_events:
|
44
|
+
delete_migrated_events: >
|
47
45
|
DELETE FROM local_events WHERE rowid IN (
|
48
|
-
SELECT rowid
|
49
|
-
FROM local_events
|
50
|
-
ORDER BY resolution, created_at ASC, topic, name, key
|
46
|
+
SELECT rowid
|
47
|
+
FROM local_events
|
48
|
+
ORDER BY resolution, created_at ASC, topic, name, key
|
51
49
|
LIMIT ?
|
52
|
-
)
|
53
|
-
|
54
|
-
event_count:
|
50
|
+
);
|
51
|
+
|
52
|
+
event_count: >
|
55
53
|
SELECT count(*) FROM local_events;
|
56
|
-
|
data/lib/litestack/litequeue.rb
CHANGED
@@ -1,27 +1,26 @@
|
|
1
1
|
# frozen_stringe_literal: true
|
2
2
|
|
3
3
|
# all components should require the support module
|
4
|
-
require_relative
|
4
|
+
require_relative "litesupport"
|
5
5
|
|
6
|
-
#require 'securerandom'
|
6
|
+
# require 'securerandom'
|
7
7
|
|
8
8
|
##
|
9
|
-
#Litequeue is a simple queueing system for Ruby applications that allows you to push and pop values from a queue. It provides a straightforward API for creating and managing named queues, and for adding and removing values from those queues. Additionally, it offers options for scheduling pops at a certain time in the future, which can be useful for delaying processing until a later time.
|
9
|
+
# Litequeue is a simple queueing system for Ruby applications that allows you to push and pop values from a queue. It provides a straightforward API for creating and managing named queues, and for adding and removing values from those queues. Additionally, it offers options for scheduling pops at a certain time in the future, which can be useful for delaying processing until a later time.
|
10
10
|
#
|
11
|
-
#Litequeue is built on top of SQLite, which makes it very fast and efficient, even when handling large volumes of data. This lightweight and easy-to-use queueing system serves as a good foundation for building more advanced job processing frameworks that require basic queuing capabilities.
|
11
|
+
# Litequeue is built on top of SQLite, which makes it very fast and efficient, even when handling large volumes of data. This lightweight and easy-to-use queueing system serves as a good foundation for building more advanced job processing frameworks that require basic queuing capabilities.
|
12
12
|
#
|
13
|
-
|
14
|
-
class Litequeue
|
15
13
|
|
14
|
+
class Litequeue
|
16
15
|
# the default options for the queue
|
17
|
-
# can be overriden by passing new options in a hash
|
16
|
+
# can be overriden by passing new options in a hash
|
18
17
|
# to Litequeue.new
|
19
18
|
# path: "./queue.db"
|
20
19
|
# mmap_size: 128 * 1024 * 1024 -> 128MB to be held in memory
|
21
20
|
# sync: 1 -> sync only when checkpointing
|
22
|
-
|
21
|
+
|
23
22
|
include Litesupport::Liteconnection
|
24
|
-
|
23
|
+
|
25
24
|
DEFAULT_OPTIONS = {
|
26
25
|
path: Litesupport.root.join("queue.sqlite3"),
|
27
26
|
mmap_size: 32 * 1024,
|
@@ -35,70 +34,70 @@ class Litequeue
|
|
35
34
|
# queue.pop # => nil
|
36
35
|
# sleep 2
|
37
36
|
# queue.pop # => "somevalue"
|
38
|
-
|
37
|
+
|
39
38
|
def initialize(options = {})
|
40
39
|
init(options)
|
41
40
|
end
|
42
|
-
|
41
|
+
|
43
42
|
# push an item to the queue, optionally specifying the queue name (defaults to default) and after how many seconds it should be ready to pop (defaults to zero)
|
44
43
|
# a unique job id is returned from this method, can be used later to delete it before it fires. You can push string, integer, float, true, false or nil values
|
45
44
|
#
|
46
|
-
def push(value, delay=0, queue=
|
45
|
+
def push(value, delay = 0, queue = "default")
|
47
46
|
# @todo - check if queue is busy, back off if it is
|
48
47
|
# also bring back the synchronize block, to prevent
|
49
48
|
# a race condition if a thread hits the busy handler
|
50
49
|
# before the current thread proceeds after a backoff
|
51
|
-
#id = SecureRandom.uuid # this is somehow expensive, can we improve?
|
50
|
+
# id = SecureRandom.uuid # this is somehow expensive, can we improve?
|
52
51
|
run_stmt(:push, queue, delay, value)[0]
|
53
52
|
end
|
54
|
-
|
55
|
-
def repush(id, value, delay=0, queue=
|
53
|
+
|
54
|
+
def repush(id, value, delay = 0, queue = "default")
|
56
55
|
run_stmt(:repush, id, queue, delay, value)[0]
|
57
56
|
end
|
58
|
-
|
59
|
-
alias_method
|
57
|
+
|
58
|
+
alias_method :<<, :push
|
60
59
|
alias_method :"<<<", :repush
|
61
|
-
|
60
|
+
|
62
61
|
# pop an item from the queue, optionally with a specific queue name (default queue name is 'default')
|
63
|
-
def pop(queue=
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
62
|
+
def pop(queue = "default", limit = 1)
|
63
|
+
res = run_stmt(:pop, queue, limit)
|
64
|
+
return res[0] if res.length == 1
|
65
|
+
return nil if res.empty?
|
66
|
+
res
|
68
67
|
end
|
69
|
-
|
68
|
+
|
70
69
|
# delete an item from the queue
|
71
70
|
# queue = Litequeue.new
|
72
71
|
# id = queue.push("somevalue")
|
73
72
|
# queue.delete(id) # => "somevalue"
|
74
73
|
# queue.pop # => nil
|
75
74
|
def delete(id)
|
76
|
-
|
75
|
+
run_stmt(:delete, id)[0]
|
77
76
|
end
|
78
|
-
|
77
|
+
|
79
78
|
# deletes all the entries in all queues, or if a queue name is given, deletes all entries in that specific queue
|
80
|
-
def clear(queue=nil)
|
79
|
+
def clear(queue = nil)
|
81
80
|
run_sql("DELETE FROM queue WHERE iif(?1 IS NOT NULL, name = ?1, TRUE)", queue)
|
82
81
|
end
|
83
82
|
|
84
83
|
# returns a count of entries in all queues, or if a queue name is given, reutrns the count of entries in that queue
|
85
|
-
def count(queue=nil)
|
84
|
+
def count(queue = nil)
|
86
85
|
run_sql("SELECT count(*) FROM queue WHERE iif(?1 IS NOT NULL, name = ?1, TRUE)", queue)[0][0]
|
87
86
|
end
|
88
|
-
|
87
|
+
|
89
88
|
# return the size of the queue file on disk
|
90
|
-
#def size
|
91
|
-
# run_sql("SELECT size.page_size * count.page_count FROM pragma_page_size() AS size, pragma_page_count() AS count")[0][0]
|
92
|
-
#end
|
93
|
-
|
89
|
+
# def size
|
90
|
+
# run_sql("SELECT size.page_size * count.page_count FROM pragma_page_size() AS size, pragma_page_count() AS count")[0][0]
|
91
|
+
# end
|
92
|
+
|
94
93
|
def queues_info
|
95
94
|
run_stmt(:info)
|
96
95
|
end
|
97
|
-
|
96
|
+
|
98
97
|
def snapshot
|
99
98
|
queues = {}
|
100
99
|
queues_info.each do |qc|
|
101
|
-
#queues[qc[0]] = {count: qc[1], time_in_queue: {avg: qc[2], min: qc[3], max: qc[4]}}
|
100
|
+
# queues[qc[0]] = {count: qc[1], time_in_queue: {avg: qc[2], min: qc[3], max: qc[4]}}
|
102
101
|
queues[qc[0]] = qc[1]
|
103
102
|
end
|
104
103
|
{
|
@@ -109,15 +108,15 @@ class Litequeue
|
|
109
108
|
size: size,
|
110
109
|
jobs: count
|
111
110
|
},
|
112
|
-
queues: queues
|
111
|
+
queues: queues
|
113
112
|
}
|
114
113
|
end
|
115
114
|
|
116
|
-
private
|
117
|
-
|
115
|
+
private
|
116
|
+
|
118
117
|
def create_connection
|
119
118
|
super("#{__dir__}/litequeue.sql.yml") do |conn|
|
120
|
-
conn.wal_autocheckpoint = 10000
|
119
|
+
conn.wal_autocheckpoint = 10000
|
121
120
|
# check if there is an old database and convert entries to the new format
|
122
121
|
if conn.get_first_value("select count(*) from sqlite_master where name = '_ul_queue_'") == 1
|
123
122
|
conn.transaction(:immediate) do
|
@@ -127,5 +126,4 @@ class Litequeue
|
|
127
126
|
end
|
128
127
|
end
|
129
128
|
end
|
130
|
-
|
131
|
-
end
|
129
|
+
end
|
@@ -1,45 +1,53 @@
|
|
1
1
|
schema:
|
2
|
-
1:
|
3
|
-
create_table_queue: >
|
2
|
+
1:
|
3
|
+
create_table_queue: >
|
4
4
|
CREATE TABLE IF NOT EXISTS queue(
|
5
|
-
id TEXT PRIMARY KEY DEFAULT(hex(randomblob(32))) NOT NULL ON CONFLICT REPLACE,
|
6
|
-
name TEXT DEFAULT('default') NOT NULL ON CONFLICT REPLACE,
|
5
|
+
id TEXT PRIMARY KEY DEFAULT(hex(randomblob(32))) NOT NULL ON CONFLICT REPLACE,
|
6
|
+
name TEXT DEFAULT('default') NOT NULL ON CONFLICT REPLACE,
|
7
7
|
fire_at INTEGER DEFAULT(unixepoch()) NOT NULL ON CONFLICT REPLACE,
|
8
8
|
value TEXT,
|
9
9
|
created_at INTEGER DEFAULT(unixepoch()) NOT NULL ON CONFLICT REPLACE
|
10
|
-
) WITHOUT ROWID
|
11
|
-
|
10
|
+
) WITHOUT ROWID;
|
11
|
+
|
12
12
|
create_index_queue_by_name: >
|
13
|
-
CREATE INDEX IF NOT EXISTS idx_queue_by_name ON queue(name, fire_at ASC)
|
13
|
+
CREATE INDEX IF NOT EXISTS idx_queue_by_name ON queue(name, fire_at ASC);
|
14
14
|
|
15
15
|
stmts:
|
16
16
|
|
17
|
-
push:
|
18
|
-
|
19
|
-
|
17
|
+
push: >
|
18
|
+
INSERT INTO queue(id, name, fire_at, value)
|
19
|
+
VALUES (hex(randomblob(32)), $1, (unixepoch() + $2), $3)
|
20
|
+
RETURNING id, name;
|
21
|
+
|
22
|
+
repush: >
|
23
|
+
INSERT INTO queue(id, name, fire_at, value)
|
24
|
+
VALUES (?, ?, (unixepoch() + ?), ?)
|
25
|
+
RETURNING name;
|
20
26
|
|
21
27
|
pop: >
|
22
|
-
DELETE FROM queue
|
23
|
-
WHERE (name, fire_at, id)
|
28
|
+
DELETE FROM queue
|
29
|
+
WHERE (name, fire_at, id)
|
24
30
|
IN (
|
25
|
-
|
26
|
-
|
27
|
-
AND fire_at <= (unixepoch())
|
28
|
-
|
29
|
-
|
30
|
-
)
|
31
|
-
RETURNING id, value
|
32
|
-
|
33
|
-
delete:
|
34
|
-
|
31
|
+
SELECT name, fire_at, id FROM queue
|
32
|
+
WHERE name = ifnull($1, 'default')
|
33
|
+
AND fire_at <= (unixepoch())
|
34
|
+
ORDER BY fire_at ASC
|
35
|
+
LIMIT ifnull($2, 1)
|
36
|
+
)
|
37
|
+
RETURNING id, value;
|
38
|
+
|
39
|
+
delete: >
|
40
|
+
DELETE FROM queue
|
41
|
+
WHERE id = $1
|
42
|
+
RETURNING value;
|
43
|
+
|
35
44
|
info: >
|
36
|
-
SELECT
|
37
|
-
name,
|
38
|
-
count(*) AS count,
|
39
|
-
avg(unixepoch() - created_at) AS avg,
|
40
|
-
min(unixepoch() - created_at) AS min,
|
45
|
+
SELECT
|
46
|
+
name,
|
47
|
+
count(*) AS count,
|
48
|
+
avg(unixepoch() - created_at) AS avg,
|
49
|
+
min(unixepoch() - created_at) AS min,
|
41
50
|
max(unixepoch() - created_at) AS max
|
42
|
-
FROM queue
|
43
|
-
GROUP BY name
|
44
|
-
ORDER BY count DESC
|
45
|
-
|
51
|
+
FROM queue
|
52
|
+
GROUP BY name
|
53
|
+
ORDER BY count DESC;
|
@@ -10,24 +10,24 @@ module Litescheduler
|
|
10
10
|
elsif defined? Polyphony
|
11
11
|
:polyphony
|
12
12
|
elsif defined? Iodine
|
13
|
-
:iodine
|
13
|
+
:iodine
|
14
14
|
else
|
15
15
|
:threaded
|
16
16
|
end
|
17
17
|
end
|
18
|
-
|
18
|
+
|
19
19
|
# spawn a new execution context
|
20
20
|
def self.spawn(&block)
|
21
21
|
if backend == :fiber
|
22
22
|
Fiber.schedule(&block)
|
23
23
|
elsif backend == :polyphony
|
24
24
|
spin(&block)
|
25
|
-
elsif backend == :threaded
|
25
|
+
elsif (backend == :threaded) || (backend == :iodine)
|
26
26
|
Thread.new(&block)
|
27
27
|
end
|
28
28
|
# we should never reach here
|
29
29
|
end
|
30
|
-
|
30
|
+
|
31
31
|
def self.storage
|
32
32
|
if backend == :fiber || backend == :poylphony
|
33
33
|
Fiber.current.storage
|
@@ -35,7 +35,7 @@ module Litescheduler
|
|
35
35
|
Thread.current
|
36
36
|
end
|
37
37
|
end
|
38
|
-
|
38
|
+
|
39
39
|
def self.current
|
40
40
|
if backend == :fiber || backend == :poylphony
|
41
41
|
Fiber.current
|
@@ -43,7 +43,7 @@ module Litescheduler
|
|
43
43
|
Thread.current
|
44
44
|
end
|
45
45
|
end
|
46
|
-
|
46
|
+
|
47
47
|
# switch the execution context to allow others to run
|
48
48
|
def self.switch
|
49
49
|
if backend == :fiber
|
@@ -54,31 +54,31 @@ module Litescheduler
|
|
54
54
|
Thread.current.switch_fiber
|
55
55
|
true
|
56
56
|
else
|
57
|
-
#Thread.pass
|
57
|
+
# Thread.pass
|
58
58
|
false
|
59
|
-
end
|
59
|
+
end
|
60
60
|
end
|
61
|
-
|
61
|
+
|
62
62
|
# bold assumption, we will only synchronize threaded code!
|
63
63
|
# If some code explicitly wants to synchronize a fiber
|
64
64
|
# they must send (true) as a parameter to this method
|
65
65
|
# else it is a no-op for fibers
|
66
66
|
def self.synchronize(fiber_sync = false, &block)
|
67
|
-
if backend == :fiber
|
67
|
+
if (backend == :fiber) || (backend == :polyphony)
|
68
68
|
yield # do nothing, just run the block as is
|
69
69
|
else
|
70
|
-
|
70
|
+
mutex.synchronize(&block)
|
71
71
|
end
|
72
72
|
end
|
73
|
-
|
73
|
+
|
74
74
|
def self.max_contexts
|
75
75
|
return 50 if backend == :fiber || backend == :polyphony
|
76
|
-
5
|
76
|
+
5
|
77
77
|
end
|
78
|
-
|
78
|
+
|
79
79
|
# mutex initialization
|
80
80
|
def self.mutex
|
81
81
|
# a single mutex per process (is that ok?)
|
82
82
|
@@mutex ||= Mutex.new
|
83
83
|
end
|
84
|
-
end
|
84
|
+
end
|