redis-locker 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +6 -0
- data/Gemfile +12 -0
- data/README.md +53 -0
- data/Rakefile +28 -0
- data/lib/redis-locker.rb +173 -0
- data/redis-locker.gemspec +29 -0
- data/spec/config/redis.yml.example +2 -0
- data/spec/lib/redis_locker_spec.rb +156 -0
- data/spec/spec_helper.rb +16 -0
- metadata +104 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,53 @@
|
|
1
|
+
# redis-locker
|
2
|
+
|
3
|
+
A super-FAST and super-ROBUST LOCKING mechanism.
|
4
|
+
Builds queue of concurrent code blocks using Redis.
|
5
|
+
|
6
|
+
If Redis fails at some point (cleared, filled with wrong data, stuck) it will be trying to stay alive anyway. This is supported by additional key-control.
|
7
|
+
|
8
|
+
## Installing
|
9
|
+
```
|
10
|
+
$ gem install redis-locker
|
11
|
+
```
|
12
|
+
|
13
|
+
Or put in your gemfile for latest version:
|
14
|
+
```ruby
|
15
|
+
gem 'redis-locker', git: 'git://github.com/einzige/redis-locker.git'
|
16
|
+
```
|
17
|
+
|
18
|
+
## Using
|
19
|
+
```ruby
|
20
|
+
# Throws an error if transaction will not be finished in 10 seconds
|
21
|
+
RedisLocker.new('payment_transaction').run!(10.seconds) do
|
22
|
+
# Any concurrent code.
|
23
|
+
end
|
24
|
+
|
25
|
+
# Throws an error if transaction will not be finished in 10 seconds
|
26
|
+
# Clears all stale tasks which were not performed within 10 seconds
|
27
|
+
RedisLocker.new('payment_transaction', 10.seconds).run! do
|
28
|
+
# Any concurrent code.
|
29
|
+
end
|
30
|
+
|
31
|
+
# Does not throw any error, but clears all stale tasks which were not performed within 10 seconds
|
32
|
+
RedisLocker.new('payment_transaction', 10.seconds).run do
|
33
|
+
# Any concurrent code.
|
34
|
+
end
|
35
|
+
```
|
36
|
+
|
37
|
+
## Running specs
|
38
|
+
- Clone the repo
|
39
|
+
- run `bundle exec rake spec`
|
40
|
+
|
41
|
+
## Contributing to redis-locker
|
42
|
+
|
43
|
+
- Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet
|
44
|
+
- Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it
|
45
|
+
- Fork the project
|
46
|
+
- Start a feature/bugfix branch
|
47
|
+
- Commit and push until you are happy with your contribution
|
48
|
+
- Make sure to add tests for it. This is important so I don't break it in a future version unintentionally.
|
49
|
+
- Please try not to mess with the Rakefile, version, or history. If you want to have your own version, or is otherwise necessary, that is fine, but please isolate to its own commit so I can cherry-pick around it.
|
50
|
+
|
51
|
+
## Copyright
|
52
|
+
|
53
|
+
Copyright (c) 2013 Sergei Zinin. No LICENSE for details :)
|
data/Rakefile
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
#!/usr/bin/env rake
|
2
|
+
|
3
|
+
ENV['BUNDLE_GEMFILE'] = 'Gemfile'
|
4
|
+
gem_root = File.expand_path(File.dirname(__FILE__))
|
5
|
+
|
6
|
+
require 'rubygems'
|
7
|
+
require 'bundler'
|
8
|
+
require 'rake'
|
9
|
+
require 'rake/testtask'
|
10
|
+
require 'rspec'
|
11
|
+
require 'rspec/core/rake_task'
|
12
|
+
|
13
|
+
task default: :spec
|
14
|
+
|
15
|
+
desc "Run the test suite"
|
16
|
+
task spec: ['spec:setup', 'spec:lib']
|
17
|
+
|
18
|
+
namespace :spec do
|
19
|
+
desc "Setup the test environment"
|
20
|
+
task :setup do
|
21
|
+
system "cd #{gem_root} && bundle install && mkdir db"
|
22
|
+
end
|
23
|
+
|
24
|
+
desc "Test the RedisLocker lib"
|
25
|
+
RSpec::Core::RakeTask.new(:lib) do |task|
|
26
|
+
task.pattern = File.join(gem_root, '/spec/lib/**/*_spec.rb')
|
27
|
+
end
|
28
|
+
end
|
data/lib/redis-locker.rb
ADDED
@@ -0,0 +1,173 @@
|
|
1
|
+
require 'logger'
|
2
|
+
require 'redis'
|
3
|
+
require 'timeout'
|
4
|
+
|
5
|
+
|
6
|
+
class RedisLocker
|
7
|
+
attr_reader :key, :running, :timestamp, :timestamp_key, :time_limit
|
8
|
+
|
9
|
+
# @param [String] key
|
10
|
+
# @param [Integer] time_limit Number of seconds when locker will be expired
|
11
|
+
def initialize(key, time_limit = 5)
|
12
|
+
@key = key
|
13
|
+
@time_limit = time_limit
|
14
|
+
@running = false
|
15
|
+
end
|
16
|
+
|
17
|
+
# @return [true, false]
|
18
|
+
def current?
|
19
|
+
concurrent_timestamp == timestamp
|
20
|
+
end
|
21
|
+
|
22
|
+
# Puts running block information in Redis
|
23
|
+
# This information will be used to place running block in a specific position of its queue
|
24
|
+
def enter_queue
|
25
|
+
logger.info("Entering #@key")
|
26
|
+
raise 'This block is already in the queue' if running?
|
27
|
+
|
28
|
+
@running = true
|
29
|
+
self.timestamp = generate_timestamp.to_s
|
30
|
+
|
31
|
+
redis.set timestamp_key, '1'
|
32
|
+
redis.expire timestamp_key, time_limit
|
33
|
+
redis.rpush key, timestamp
|
34
|
+
end
|
35
|
+
|
36
|
+
# Clears all data from queue related to this block
|
37
|
+
def exit_queue
|
38
|
+
logger.info("Leaving #@key")
|
39
|
+
redis.del timestamp_key
|
40
|
+
redis.lrem key, 1, timestamp
|
41
|
+
@running = false
|
42
|
+
end
|
43
|
+
|
44
|
+
# Returns true if block is ready to run
|
45
|
+
# @return [true, false]
|
46
|
+
def get_ready
|
47
|
+
if ready?
|
48
|
+
concurrent_timestamp.nil? ? start_queue : make_current
|
49
|
+
true
|
50
|
+
else
|
51
|
+
current?
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def ready?
|
56
|
+
concurrent_timestamp.nil? || current? ||
|
57
|
+
(generate_timestamp - concurrent_timestamp.to_f >= time_limit) ||
|
58
|
+
redis.get(generate_timestamp_key(concurrent_timestamp)).nil?
|
59
|
+
end
|
60
|
+
|
61
|
+
def redis
|
62
|
+
self.class.redis
|
63
|
+
end
|
64
|
+
|
65
|
+
# Waits for the queue and evaluates the block
|
66
|
+
def run(&block)
|
67
|
+
logger.info("Running queue #@key")
|
68
|
+
|
69
|
+
enter_queue
|
70
|
+
wait
|
71
|
+
begin
|
72
|
+
block.call
|
73
|
+
ensure
|
74
|
+
exit_queue
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
# @param [Integer] time_limit Number of seconds after we throw a Timeout::Error
|
79
|
+
# @param [true, false] clear_queue_on_timeout
|
80
|
+
# @raise [Timeout::Error]
|
81
|
+
def run!(time_limit = @time_limit, clear_queue_on_timeout = false, &block)
|
82
|
+
Timeout::timeout(time_limit) { run(&block) }
|
83
|
+
rescue Timeout::Error => error
|
84
|
+
logger.error("Failed by timeout #{time_limit}s on #@key")
|
85
|
+
|
86
|
+
if clear_queue_on_timeout
|
87
|
+
logger.info("Clearing queue #@key")
|
88
|
+
clear_queue
|
89
|
+
end
|
90
|
+
|
91
|
+
raise error
|
92
|
+
end
|
93
|
+
|
94
|
+
def running?
|
95
|
+
@running
|
96
|
+
end
|
97
|
+
|
98
|
+
def self.logger
|
99
|
+
@logger ||= Logger.new(STDOUT)
|
100
|
+
end
|
101
|
+
|
102
|
+
def self.logger=(logger)
|
103
|
+
@logger = logger
|
104
|
+
end
|
105
|
+
|
106
|
+
def self.redis
|
107
|
+
@redis
|
108
|
+
end
|
109
|
+
|
110
|
+
def self.redis=(adapter)
|
111
|
+
@redis = adapter
|
112
|
+
end
|
113
|
+
|
114
|
+
protected
|
115
|
+
|
116
|
+
# @return [Float]
|
117
|
+
def generate_timestamp
|
118
|
+
Time.now.to_f
|
119
|
+
end
|
120
|
+
|
121
|
+
private
|
122
|
+
|
123
|
+
def clear_queue
|
124
|
+
redis.del key
|
125
|
+
end
|
126
|
+
|
127
|
+
# @return [String]
|
128
|
+
def concurrent_timestamp
|
129
|
+
@concurrent_timestamp ||= fetch_concurrent_timestamp
|
130
|
+
end
|
131
|
+
|
132
|
+
# Fetches next concurrent thread ID from the queue
|
133
|
+
def fetch_concurrent_timestamp
|
134
|
+
redis.lindex(key, 0)
|
135
|
+
end
|
136
|
+
|
137
|
+
# @param [String, Float] timestamp
|
138
|
+
def generate_timestamp_key(timestamp = @timestamp)
|
139
|
+
"Locker::__key_#{timestamp}"
|
140
|
+
end
|
141
|
+
|
142
|
+
# @return [Logger]
|
143
|
+
def logger
|
144
|
+
self.class.logger
|
145
|
+
end
|
146
|
+
|
147
|
+
# Replaces concurrent timestamp
|
148
|
+
def make_current
|
149
|
+
redis.lrem key, 0, timestamp
|
150
|
+
redis.lpop key
|
151
|
+
redis.lpush key, timestamp
|
152
|
+
end
|
153
|
+
alias_method :replace_concurrent_timestamp, :make_current
|
154
|
+
|
155
|
+
# Builds queue starting from self
|
156
|
+
def start_queue
|
157
|
+
redis.lpush key, timestamp
|
158
|
+
end
|
159
|
+
|
160
|
+
# @param [Float] value
|
161
|
+
def timestamp=(value)
|
162
|
+
@timestamp = value
|
163
|
+
@timestamp_key = generate_timestamp_key(@timestamp)
|
164
|
+
@timestamp
|
165
|
+
end
|
166
|
+
|
167
|
+
# Locking itself
|
168
|
+
def wait
|
169
|
+
begin
|
170
|
+
@concurrent_timestamp = fetch_concurrent_timestamp
|
171
|
+
end until get_ready
|
172
|
+
end
|
173
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
|
3
|
+
Gem::Specification.new do |s|
|
4
|
+
s.name = %q{redis-locker}
|
5
|
+
s.version = "0.0.1"
|
6
|
+
|
7
|
+
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
8
|
+
s.authors = ["Sergei Zinin"]
|
9
|
+
s.date = %q{2013-08-13}
|
10
|
+
s.description = %q{A locking mechanism. Builds queue of concurrent code blocks using Redis.}
|
11
|
+
s.email = %q{szinin@partyearth.com}
|
12
|
+
s.extra_rdoc_files = [ "README.md" ]
|
13
|
+
s.files = `git ls-files`.split("\n")
|
14
|
+
s.homepage = %q{http://github.com/einzige/redis-locker}
|
15
|
+
s.licenses = ["MIT"]
|
16
|
+
s.require_paths = ["lib"]
|
17
|
+
s.rubygems_version = %q{1.6.2}
|
18
|
+
s.summary = %q{Destroys the concurrency of your code.}
|
19
|
+
|
20
|
+
if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0')
|
21
|
+
s.add_development_dependency(%q<bundler>, ["~> 1.0.0"])
|
22
|
+
s.add_runtime_dependency(%q<logger>, [">= 1.2.8"])
|
23
|
+
s.add_runtime_dependency(%q<redis>, [">= 3.0.3"])
|
24
|
+
else
|
25
|
+
s.add_dependency(%q<bundler>, ["~> 1.0.0"])
|
26
|
+
s.add_dependency(%q<logger>, [">= 1.2.8"])
|
27
|
+
s.add_dependency(%q<redis>, [">= 3.0.3"])
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,156 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
|
4
|
+
describe RedisLocker do
|
5
|
+
let(:queue_name) { 'queue_name' }
|
6
|
+
subject { described_class.new(queue_name, 6) }
|
7
|
+
|
8
|
+
describe "#initialize" do
|
9
|
+
it 'assigns key' do
|
10
|
+
subject.key.should == queue_name
|
11
|
+
end
|
12
|
+
|
13
|
+
it 'assigns time_limit' do
|
14
|
+
subject.time_limit.should == 6
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
describe "#enter_queue" do
|
19
|
+
context "calling multiple times" do
|
20
|
+
before { subject.enter_queue }
|
21
|
+
its(:running?) { should be_true }
|
22
|
+
it { expect { subject.enter_queue }.to raise_error("This block is already in the queue") }
|
23
|
+
|
24
|
+
context "after exit" do
|
25
|
+
before { subject.exit_queue }
|
26
|
+
its(:running?) { should be_false }
|
27
|
+
it { expect { subject.enter_queue }.not_to raise_error }
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
describe "#ready?" do
|
33
|
+
its(:ready?) { should be_true }
|
34
|
+
|
35
|
+
context "trash in a queue" do
|
36
|
+
before do
|
37
|
+
described_class.redis.lpush queue_name, 'whatever'
|
38
|
+
end
|
39
|
+
|
40
|
+
its(:current?) { should be_false }
|
41
|
+
its(:ready?) { should be_true }
|
42
|
+
end
|
43
|
+
|
44
|
+
context "pending timestamp in a queue" do
|
45
|
+
before do
|
46
|
+
subject.enter_queue
|
47
|
+
end
|
48
|
+
|
49
|
+
its(:current?) { should be_true }
|
50
|
+
|
51
|
+
context "stale" do
|
52
|
+
before do
|
53
|
+
described_class.redis.lpush queue_name, subject.timestamp.to_f + 10000
|
54
|
+
end
|
55
|
+
|
56
|
+
its(:current?) { should be_false }
|
57
|
+
its(:ready?) { should be_true }
|
58
|
+
end
|
59
|
+
|
60
|
+
context "not stale" do
|
61
|
+
context "same time" do
|
62
|
+
before do
|
63
|
+
described_class.redis.lpush queue_name, subject.timestamp.to_f
|
64
|
+
end
|
65
|
+
|
66
|
+
its(:current?) { should be_true }
|
67
|
+
its(:ready?) { should be_true }
|
68
|
+
end
|
69
|
+
|
70
|
+
context "later time" do
|
71
|
+
context "same time" do
|
72
|
+
before do
|
73
|
+
described_class.redis.lpush queue_name, subject.timestamp.to_f + 0.00001
|
74
|
+
end
|
75
|
+
|
76
|
+
its(:current?) { should be_false }
|
77
|
+
its(:ready?) { should be_true }
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
context "concurrent threads" do
|
84
|
+
let(:concurrent_locker_1) { described_class.new(queue_name) }
|
85
|
+
let(:concurrent_locker_2) { described_class.new(queue_name) }
|
86
|
+
let(:unrelated_locker) { described_class.new('unrelated') }
|
87
|
+
|
88
|
+
before do
|
89
|
+
concurrent_locker_1.enter_queue
|
90
|
+
concurrent_locker_2.enter_queue
|
91
|
+
unrelated_locker.enter_queue
|
92
|
+
end
|
93
|
+
|
94
|
+
it 'allows first locker in the queue to be run' do
|
95
|
+
concurrent_locker_1.ready?.should be_true
|
96
|
+
end
|
97
|
+
|
98
|
+
it 'makes second locker in the queue wait' do
|
99
|
+
concurrent_locker_2.ready?.should be_false
|
100
|
+
end
|
101
|
+
|
102
|
+
it 'does not have an impact on a separate queue' do
|
103
|
+
unrelated_locker.ready?.should be_true
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
describe "#run" do
|
109
|
+
context "with breaking block" do
|
110
|
+
before do
|
111
|
+
subject.should_receive(:exit_queue).once
|
112
|
+
end
|
113
|
+
|
114
|
+
it 'exits queue even if something fails' do
|
115
|
+
expect { subject.run { raise 'PIZDEC!' } }.to raise_error('PIZDEC!')
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
describe "locking", integrational: true do
|
120
|
+
it 'locks' do
|
121
|
+
looser = nil
|
122
|
+
winner = nil
|
123
|
+
|
124
|
+
concurrent_request = proc do |id|
|
125
|
+
proc do
|
126
|
+
described_class.new(queue_name).run do
|
127
|
+
looser = id
|
128
|
+
|
129
|
+
if winner
|
130
|
+
winner.should_not == looser
|
131
|
+
else
|
132
|
+
winner = id
|
133
|
+
sleep(5)
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
thr_1 = Thread.new &concurrent_request.call(1)
|
140
|
+
thr_2 = Thread.new &concurrent_request.call(2)
|
141
|
+
|
142
|
+
thr_1.join
|
143
|
+
thr_2.join
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
describe "#run!" do
|
149
|
+
context "reaching deadline" do
|
150
|
+
before do
|
151
|
+
subject.should_receive(:clear_queue)
|
152
|
+
end
|
153
|
+
it { expect { subject.run!(0.0000000001, true) { sleep(0.00001) } }.to raise_error(Timeout::Error) }
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
require "rspec"
|
2
|
+
require "redis-locker"
|
3
|
+
require "yaml"
|
4
|
+
|
5
|
+
RSpec.configure { |config| config.mock_with :rspec }
|
6
|
+
|
7
|
+
redis = Redis.new(YAML.load_file("spec/config/redis.yml"))
|
8
|
+
RedisLocker.redis = redis
|
9
|
+
RedisLocker.logger.level = Logger::WARN
|
10
|
+
|
11
|
+
RSpec.configure do |config|
|
12
|
+
config.filter_run_excluding integrational: true
|
13
|
+
|
14
|
+
config.before { redis.flushdb }
|
15
|
+
config.after { redis.flushdb }
|
16
|
+
end
|
metadata
ADDED
@@ -0,0 +1,104 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: redis-locker
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Sergei Zinin
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2013-08-13 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: bundler
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ~>
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: 1.0.0
|
22
|
+
type: :development
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ~>
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: 1.0.0
|
30
|
+
- !ruby/object:Gem::Dependency
|
31
|
+
name: logger
|
32
|
+
requirement: !ruby/object:Gem::Requirement
|
33
|
+
none: false
|
34
|
+
requirements:
|
35
|
+
- - ! '>='
|
36
|
+
- !ruby/object:Gem::Version
|
37
|
+
version: 1.2.8
|
38
|
+
type: :runtime
|
39
|
+
prerelease: false
|
40
|
+
version_requirements: !ruby/object:Gem::Requirement
|
41
|
+
none: false
|
42
|
+
requirements:
|
43
|
+
- - ! '>='
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
version: 1.2.8
|
46
|
+
- !ruby/object:Gem::Dependency
|
47
|
+
name: redis
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
49
|
+
none: false
|
50
|
+
requirements:
|
51
|
+
- - ! '>='
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: 3.0.3
|
54
|
+
type: :runtime
|
55
|
+
prerelease: false
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
57
|
+
none: false
|
58
|
+
requirements:
|
59
|
+
- - ! '>='
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: 3.0.3
|
62
|
+
description: A locking mechanism. Builds queue of concurrent code blocks using Redis.
|
63
|
+
email: szinin@partyearth.com
|
64
|
+
executables: []
|
65
|
+
extensions: []
|
66
|
+
extra_rdoc_files:
|
67
|
+
- README.md
|
68
|
+
files:
|
69
|
+
- .gitignore
|
70
|
+
- Gemfile
|
71
|
+
- README.md
|
72
|
+
- Rakefile
|
73
|
+
- lib/redis-locker.rb
|
74
|
+
- redis-locker.gemspec
|
75
|
+
- spec/config/redis.yml
|
76
|
+
- spec/config/redis.yml.example
|
77
|
+
- spec/lib/redis_locker_spec.rb
|
78
|
+
- spec/spec_helper.rb
|
79
|
+
homepage: http://github.com/einzige/redis-locker
|
80
|
+
licenses:
|
81
|
+
- MIT
|
82
|
+
post_install_message:
|
83
|
+
rdoc_options: []
|
84
|
+
require_paths:
|
85
|
+
- lib
|
86
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
87
|
+
none: false
|
88
|
+
requirements:
|
89
|
+
- - ! '>='
|
90
|
+
- !ruby/object:Gem::Version
|
91
|
+
version: '0'
|
92
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
93
|
+
none: false
|
94
|
+
requirements:
|
95
|
+
- - ! '>='
|
96
|
+
- !ruby/object:Gem::Version
|
97
|
+
version: '0'
|
98
|
+
requirements: []
|
99
|
+
rubyforge_project:
|
100
|
+
rubygems_version: 1.8.25
|
101
|
+
signing_key:
|
102
|
+
specification_version: 3
|
103
|
+
summary: Destroys the concurrency of your code.
|
104
|
+
test_files: []
|