hivent 1.0.4 → 1.0.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: e360be776496b49ad0334d22d5bc52d20554829a
4
- data.tar.gz: 8a3027f19ecf4bebad1701a0d575f8e56b9a8c4a
3
+ metadata.gz: bbc8f5896f4204e96e80a1de33bffd52ffe26e0f
4
+ data.tar.gz: 47590d0d52a5d0ef933bac1aa4f3346ca4b5bfbc
5
5
  SHA512:
6
- metadata.gz: 76d2ad583fae68f5470124f671c69221d5e9db4ffe76576c73bdf16f75357cf05f3cd6c49fd412450d18f2b2fe1294553caa0bd9ada38b6a59feb433f7e1ba35
7
- data.tar.gz: f9ed4579ff32e15235f6c066e10390721e235d88f64971e5f67e1725b30d9b4a58dd3eb364de34c34dff70d719f36a007ade0c64faece3d0263edd9674d6d28b
6
+ metadata.gz: 42af01f8e3b8152d0b2a90dc963907d28abc10c74d6d5f557519dc66267968bc54c14cb22b39e9f85ea8beaae7c57b4f9d64263e52995448929d88604285273b
7
+ data.tar.gz: c09144a1cf4542f74ed8c5bfa5d372a9039bcadc8973f0ff89b1d902b92a264765daabe24e3a04831f0d9b4e82b12c4f9997c234364951b585d91f518d1f5e79
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
@@ -22,13 +22,16 @@ Gem::Specification.new do |spec|
22
22
  spec.add_dependency "activesupport", "~> 5.0"
23
23
  spec.add_dependency "retryable", "~> 2.0"
24
24
  spec.add_dependency "redis", "~> 3.3"
25
- spec.add_dependency "event_emitter", "~> 0.2"
25
+ spec.add_dependency "emittr", "~> 0.1"
26
26
 
27
27
  spec.add_development_dependency "bundler", "~> 1.12"
28
28
  spec.add_development_dependency "rspec", "~> 3.5"
29
29
  spec.add_development_dependency "rspec-its", "~> 1.2"
30
+ spec.add_development_dependency "rspec-eventually", "~> 0.2"
30
31
  spec.add_development_dependency "pry-byebug", "~> 3.4"
31
32
  spec.add_development_dependency "simplecov", "~> 0.12"
32
33
  spec.add_development_dependency "codeclimate-test-reporter", "~> 0.6"
33
34
  spec.add_development_dependency "rubocop", "~> 0.43"
35
+ spec.add_development_dependency "gem-release", "~> 0.7"
36
+ spec.add_development_dependency "rake", "~> 11.3"
34
37
  end
@@ -3,7 +3,7 @@ require "active_support"
3
3
  require "active_support/core_ext"
4
4
  require "retryable"
5
5
  require "json"
6
- require "event_emitter"
6
+ require "emittr"
7
7
 
8
8
  require "hivent/config"
9
9
 
@@ -3,7 +3,7 @@ module Hivent
3
3
 
4
4
  class Emitter
5
5
 
6
- include EventEmitter
6
+ include Emittr::Events
7
7
  attr_accessor :events
8
8
 
9
9
  WILDCARD = :all
@@ -18,6 +18,10 @@ module Hivent
18
18
  end
19
19
  end
20
20
 
21
+ def emit(name, *data)
22
+ super(name.to_sym, *data)
23
+ end
24
+
21
25
  private
22
26
 
23
27
  def emittable_event_names(payload)
@@ -8,6 +8,7 @@ module Hivent
8
8
  include Hivent::Redis::Extensions
9
9
 
10
10
  LUA_CONSUMER = File.expand_path("../lua/consumer.lua", __FILE__)
11
+ LUA_HEARTBEAT = File.expand_path("../lua/heartbeat.lua", __FILE__)
11
12
  # In milliseconds
12
13
  SLEEP_TIME = 200
13
14
  CONSUMER_TTL = 1000
@@ -21,22 +22,23 @@ module Hivent
21
22
  end
22
23
 
23
24
  def run!
25
+ start_heartbeat!
24
26
  consume while !@stop
25
27
  end
26
28
 
27
29
  def stop!
28
30
  @stop = true
31
+ stop_heartbeat!
29
32
  end
30
33
 
31
34
  def queues
32
- script(LUA_CONSUMER, @service_name, @name, CONSUMER_TTL)
35
+ script(LUA_CONSUMER, @service_name, @name, CONSUMER_TTL) || []
33
36
  end
34
37
 
35
38
  def consume
36
39
  to_process = items
37
40
 
38
41
  to_process.each do |(queue, item)|
39
- @redis.rpop(queue)
40
42
  payload = nil
41
43
  begin
42
44
  payload = JSON.parse(item).with_indifferent_access
@@ -48,6 +50,8 @@ module Hivent
48
50
  @redis.lpush(dead_letter_queue_name(queue), item)
49
51
 
50
52
  @life_cycle_event_handler.event_processing_failed(e, payload, item, dead_letter_queue_name(queue))
53
+ ensure
54
+ @redis.rpop(queue)
51
55
  end
52
56
  end
53
57
 
@@ -56,6 +60,26 @@ module Hivent
56
60
 
57
61
  private
58
62
 
63
+ def start_heartbeat!
64
+ stop_heartbeat!
65
+
66
+ @heartbeat = Thread.new do
67
+ loop do
68
+ heartbeat!
69
+
70
+ Kernel.sleep(SLEEP_TIME.to_f / 1000)
71
+ end
72
+ end
73
+ end
74
+
75
+ def stop_heartbeat!
76
+ @heartbeat.exit if @heartbeat.present?
77
+ end
78
+
79
+ def heartbeat!
80
+ script(LUA_HEARTBEAT, @service_name, @name, CONSUMER_TTL)
81
+ end
82
+
59
83
  def items
60
84
  queues
61
85
  .map { |queue| [queue, @redis.lindex(queue, -1)] }
@@ -50,25 +50,6 @@ local function table_eq(table1, table2)
50
50
  return recurse(table1, table2)
51
51
  end
52
52
 
53
- local function keepalive(service, consumer)
54
- redis.call("SET", service .. ":" .. consumer .. ":alive", "true", "PX", CONSUMER_TTL)
55
- redis.call("SADD", service .. ":consumers", consumer)
56
- end
57
-
58
- local function cleanup(service)
59
- local consumer_index_key = service .. ":consumers"
60
- local consumers = redis.call("SMEMBERS", consumer_index_key)
61
-
62
- for _, consumer in ipairs(consumers) do
63
- local consumer_status_key = service .. ":" .. consumer .. ":alive"
64
- local alive = redis.call("GET", consumer_status_key)
65
-
66
- if not alive then
67
- redis.call("SREM", consumer_index_key, consumer)
68
- end
69
- end
70
- end
71
-
72
53
  local function distribute(consumers, partition_count)
73
54
  local distribution = {}
74
55
  local consumer_count = table.getn(consumers)
@@ -163,17 +144,4 @@ local function rebalance(service_name, consumer_name)
163
144
  end
164
145
  end
165
146
 
166
- local function heartbeat(service_name, consumer_name)
167
- -- keep consumer alive
168
- keepalive(service_name, consumer_name)
169
-
170
- -- clean up dead consumers
171
- cleanup(service_name)
172
-
173
- -- rebalance
174
- local new_config = rebalance(service_name, consumer_name)
175
-
176
- return new_config
177
- end
178
-
179
- return heartbeat(service_name, consumer_name)
147
+ return rebalance(service_name, consumer_name)
@@ -0,0 +1,34 @@
1
+ local service_name = ARGV[1]
2
+ local consumer_name = ARGV[2]
3
+ local CONSUMER_TTL = ARGV[3]
4
+
5
+ local function keepalive(service, consumer)
6
+ redis.call("SET", service .. ":" .. consumer .. ":alive", "true", "PX", CONSUMER_TTL)
7
+ redis.call("SADD", service .. ":consumers", consumer)
8
+ end
9
+
10
+ local function cleanup(service)
11
+ local consumer_index_key = service .. ":consumers"
12
+ local consumers = redis.call("SMEMBERS", consumer_index_key)
13
+
14
+ for _, consumer in ipairs(consumers) do
15
+ local consumer_status_key = service .. ":" .. consumer .. ":alive"
16
+ local alive = redis.call("GET", consumer_status_key)
17
+
18
+ if not alive then
19
+ redis.call("SREM", consumer_index_key, consumer)
20
+ end
21
+ end
22
+ end
23
+
24
+ local function heartbeat(service_name, consumer_name)
25
+ -- keep consumer alive
26
+ keepalive(service_name, consumer_name)
27
+
28
+ -- clean up dead consumers
29
+ cleanup(service_name)
30
+
31
+ return true
32
+ end
33
+
34
+ return heartbeat(service_name, consumer_name)
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
  module Hivent
3
3
 
4
- VERSION = File.read(File.expand_path('../../../.version', __FILE__)).strip.freeze
4
+ VERSION = "1.0.5".freeze
5
5
 
6
6
  end
@@ -83,7 +83,7 @@ describe Hivent::AbstractSignal do
83
83
 
84
84
  describe "#receive" do
85
85
  after :each do
86
- Hivent.emitter.remove_listener name
86
+ Hivent.emitter.off name
87
87
  Hivent.emitter.events.clear
88
88
  end
89
89
 
@@ -1,4 +1,3 @@
1
- # frozen_string_literal: true
2
1
  require "spec_helper"
3
2
 
4
3
  describe Hivent::Redis::Consumer do
@@ -7,12 +6,19 @@ describe Hivent::Redis::Consumer do
7
6
  let(:redis) { Redis.new(url: REDIS_URL) }
8
7
  let(:service_name) { "a_service" }
9
8
  let(:consumer_name) { "a_consumer" }
10
- let(:life_cycle_event_handler) { double("Hivent::LifeCycleEventHandler").as_null_object }
9
+ let(:life_cycle_event_handler) { double("Hivent::Redis::LifeCycleEventHandler").as_null_object }
10
+
11
+ before :each do
12
+ stub_const("#{described_class}::CONSUMER_TTL", 1000)
13
+ end
11
14
 
12
15
  after :each do
16
+ Hivent.emitter.off
13
17
  redis.flushall
14
18
 
15
- Hivent.emitter.__events.clear
19
+ Thread.list.each do |thread|
20
+ thread.exit unless thread == Thread.current
21
+ end
16
22
  end
17
23
 
18
24
  describe "#queues" do
@@ -20,8 +26,8 @@ describe Hivent::Redis::Consumer do
20
26
  # 1. Marks every consumer as "alive"
21
27
  # 2. Resets every consumer
22
28
  # 3. Distributes partitions evenly
23
- 3.times do
24
- consumers.each(&:queues)
29
+ consumers.map do |consumer|
30
+ Thread.new { consumer.run! }
25
31
  end
26
32
  end
27
33
 
@@ -34,8 +40,13 @@ describe Hivent::Redis::Consumer do
34
40
 
35
41
  let(:partition_count) { 2 }
36
42
 
43
+ before :each do
44
+ Thread.new { consumer.run! }
45
+ sleep 0.1
46
+ end
47
+
37
48
  it "returns all available partitions" do
38
- expect(subject.length).to eq(partition_count)
49
+ expect { subject.length }.to eventually eq(partition_count)
39
50
  end
40
51
  end
41
52
 
@@ -48,89 +59,78 @@ describe Hivent::Redis::Consumer do
48
59
  before :each do
49
60
  # Hearbeat from first consumer,
50
61
  # marking it as "alive"
51
- consumer1.queues
62
+ Thread.new { consumer1.run! }
63
+ sleep 0.1
52
64
  end
53
65
 
54
66
  it "assigns all available partitions to the living consumer" do
55
- distribution = [consumer1.queues, consumer2.queues]
56
-
57
- expect(distribution.map(&:length)).to eq([2, 0])
67
+ expect { [consumer1.queues, consumer2.queues].map(&:length) }.to eventually eq([2, 0])
58
68
  end
59
69
 
60
70
  describe "balancing" do
61
- it "resets the first consumer for rebalancing" do
62
- # Marks consumer 1 as alive, assigning all partitions
63
- consumer1.queues
64
- # Marks consumer 2 as alive, assigning 0 partitions to
65
- # start rebalancing
66
- consumer2.queues
67
-
68
- # Assigns 0 partitions to finish resetting
69
- expect(consumer1.queues.length).to eq(0)
70
- end
71
-
72
71
  it "assigns half the partitions after reset" do
72
+ threads = []
73
73
  # Fully resets
74
- consumer1.queues
75
- consumer2.queues
76
- consumer1.queues
74
+ threads << Thread.new { consumer1.run! }
75
+ sleep 0.1
76
+ threads << Thread.new { consumer2.run! }
77
+ sleep 0.1
77
78
 
78
79
  # Distributes partitions across consumers
79
- expect(consumer2.queues.length).to eq(1)
80
+ expect { consumer2.queues.length }.to eventually eq(1)
80
81
  end
81
82
 
82
83
  it "rebalances partitions across both consumers" do
83
- consumer1.queues
84
- consumer2.queues
85
- consumer1.queues
86
- consumer2.queues
84
+ threads = []
85
+
86
+ threads << Thread.new { consumer1.run! }
87
+ sleep 0.1
88
+ threads << Thread.new { consumer2.run! }
89
+ sleep 0.1
90
+
91
+ Thread.new { consumer1.stop! }
92
+ Thread.new { consumer2.stop! }
93
+ sleep 0.1
94
+
95
+ threads << Thread.new { consumer1.run! }
96
+ sleep 0.1
97
+ threads << Thread.new { consumer2.run! }
98
+ sleep 0.1
87
99
 
88
100
  # Distributes partitions across consumers
89
- expect(consumer1.queues.length).to eq(1)
101
+ expect { consumer1.queues.length }.to eventually eq(1)
90
102
  end
91
103
 
92
104
  context "when one of the consumers dies" do
93
105
  before :each do
94
106
  stub_const("#{described_class}::CONSUMER_TTL", 50)
95
107
 
96
- balance([consumer1, consumer2])
97
- count = 0
108
+ threads = balance([consumer1, consumer2])
109
+ Thread.new { consumer1.stop! }
110
+ threads.first.exit
98
111
 
99
- while count <= 2
100
- consumer2.queues
101
- count += 1
102
-
103
- sleep described_class::CONSUMER_TTL.to_f / 1000
104
- end
112
+ sleep described_class::CONSUMER_TTL.to_f / 1000
105
113
  end
106
114
 
107
115
  it "assigns those consumer's partitions to another consumer" do
108
- expect(consumer2.queues.length).to eq(2)
116
+ expect { consumer2.queues.length }.to eventually eq(2)
109
117
  end
110
118
  end
111
119
  end
112
120
  end
113
121
 
114
122
  context "when both consumers are alive" do
115
- subject do
116
- [consumer1.queues, consumer2.queues]
117
- end
118
-
119
123
  before :each do
120
124
  balance([consumer1, consumer2])
121
125
  end
122
126
 
123
127
  it "returns all available partitions" do
124
- expect(subject.map(&:length)).to eq([1, 1])
128
+ expect { [consumer1.queues, consumer2.queues].map(&:length) }.to eventually eq([1, 1])
125
129
  end
126
130
  end
127
131
  end
128
132
 
129
133
  context "with more consumers than partitions" do
130
- subject do
131
- [consumer1.queues, consumer2.queues]
132
- end
133
-
134
134
  let(:consumer1) { described_class.new(redis, service_name, "#{consumer_name}1", life_cycle_event_handler) }
135
135
  let(:consumer2) { described_class.new(redis, service_name, "#{consumer_name}2", life_cycle_event_handler) }
136
136
  let(:partition_count) { 1 }
@@ -140,7 +140,7 @@ describe Hivent::Redis::Consumer do
140
140
  end
141
141
 
142
142
  it "returns all available partitions" do
143
- expect(subject.map(&:length)).to eq([1, 0])
143
+ expect { [consumer1.queues, consumer2.queues].map(&:length) }.to eventually eq([1, 0])
144
144
  end
145
145
  end
146
146
 
@@ -158,7 +158,7 @@ describe Hivent::Redis::Consumer do
158
158
  end
159
159
 
160
160
  it "returns all available partitions" do
161
- expect(subject.map(&:length)).to eq([2, 1])
161
+ expect { [consumer1.queues, consumer2.queues].map(&:length) }.to eventually eq([2, 1])
162
162
  end
163
163
  end
164
164
 
@@ -184,12 +184,20 @@ describe Hivent::Redis::Consumer do
184
184
  let(:producer) { Hivent::Redis::Producer.new(redis) }
185
185
 
186
186
  before :each do
187
+ Thread.new { consumer.run! }
188
+ sleep 0.1
189
+
187
190
  redis.set("#{service_name}:partition_count", partition_count)
188
191
  redis.sadd(event[:meta][:name], service_name)
189
192
 
190
193
  producer.write(event[:meta][:name], event.to_json, 0)
191
194
  end
192
195
 
196
+ after :each do
197
+ Thread.new { consumer.stop! }
198
+ sleep 0.1
199
+ end
200
+
193
201
  context "when there are items ready to be consumed" do
194
202
 
195
203
  it "emits the item with indifferent access" do
@@ -300,24 +308,35 @@ describe Hivent::Redis::Consumer do
300
308
  end
301
309
 
302
310
  describe "#run!" do
303
- subject { Thread.new { consumer.run! } }
304
-
305
311
  let(:partition_count) { 2 }
306
312
 
307
313
  before :each do
308
314
  redis.set("#{service_name}:partition_count", partition_count)
309
315
 
310
316
  allow(consumer).to receive(:consume)
317
+
318
+ stub_const("#{described_class}::CONSUMER_TTL", 10000)
311
319
  end
312
320
 
313
- it "processes items" do
314
- thread = subject
321
+ it "starts its heartbeat" do
322
+ thread = Thread.new { consumer.run! }
315
323
 
316
- sleep 0.1
324
+ sleep 1
325
+
326
+ is_alive = redis.get("#{service_name}:#{consumer_name}:alive")
317
327
 
318
328
  thread.kill
319
329
 
330
+ expect(is_alive).to be
331
+ end
332
+
333
+ it "processes items" do
334
+ thread = Thread.new { consumer.run! }
335
+ sleep 0.2
336
+
320
337
  expect(consumer).to have_received(:consume).at_least(:once)
338
+
339
+ thread.kill
321
340
  end
322
341
 
323
342
  end
@@ -328,6 +347,22 @@ describe Hivent::Redis::Consumer do
328
347
 
329
348
  before :each do
330
349
  redis.set("#{service_name}:partition_count", partition_count)
350
+ stub_const("#{described_class}::CONSUMER_TTL", 10)
351
+ end
352
+
353
+ it "stops its heartbeat" do
354
+ thread = Thread.new do
355
+ consumer.run!
356
+ end
357
+
358
+ sleep 0.1
359
+
360
+ consumer.stop!
361
+ thread.kill
362
+
363
+ sleep 0.2
364
+
365
+ expect { redis.get("#{service_name}:#{consumer_name}:alive") }.to eventually be_nil
331
366
  end
332
367
 
333
368
  it "stops processing" do
@@ -338,6 +373,7 @@ describe Hivent::Redis::Consumer do
338
373
  sleep 0.1
339
374
 
340
375
  consumer.stop!
376
+ thread.kill
341
377
 
342
378
  # nil is returned if timeout expires
343
379
  expect(thread.join(2)).to eq(thread)
@@ -83,7 +83,7 @@ describe Hivent do
83
83
  let(:increase) { 5 }
84
84
 
85
85
  after :each do
86
- Hivent.emitter.remove_listener "my_signal:1"
86
+ Hivent.emitter.off "my_signal:1"
87
87
  end
88
88
 
89
89
  it "consumes events" do
@@ -2,6 +2,7 @@
2
2
  require 'simplecov'
3
3
 
4
4
  require 'rspec/its'
5
+ require 'rspec/eventually'
5
6
  require 'pry'
6
7
 
7
8
  require 'hivent'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hivent
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.4
4
+ version: 1.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bruno Abrantes
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2016-10-10 00:00:00.000000000 Z
11
+ date: 2016-10-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -53,19 +53,19 @@ dependencies:
53
53
  - !ruby/object:Gem::Version
54
54
  version: '3.3'
55
55
  - !ruby/object:Gem::Dependency
56
- name: event_emitter
56
+ name: emittr
57
57
  requirement: !ruby/object:Gem::Requirement
58
58
  requirements:
59
59
  - - "~>"
60
60
  - !ruby/object:Gem::Version
61
- version: '0.2'
61
+ version: '0.1'
62
62
  type: :runtime
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
- version: '0.2'
68
+ version: '0.1'
69
69
  - !ruby/object:Gem::Dependency
70
70
  name: bundler
71
71
  requirement: !ruby/object:Gem::Requirement
@@ -108,6 +108,20 @@ dependencies:
108
108
  - - "~>"
109
109
  - !ruby/object:Gem::Version
110
110
  version: '1.2'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rspec-eventually
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '0.2'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '0.2'
111
125
  - !ruby/object:Gem::Dependency
112
126
  name: pry-byebug
113
127
  requirement: !ruby/object:Gem::Requirement
@@ -164,6 +178,34 @@ dependencies:
164
178
  - - "~>"
165
179
  - !ruby/object:Gem::Version
166
180
  version: '0.43'
181
+ - !ruby/object:Gem::Dependency
182
+ name: gem-release
183
+ requirement: !ruby/object:Gem::Requirement
184
+ requirements:
185
+ - - "~>"
186
+ - !ruby/object:Gem::Version
187
+ version: '0.7'
188
+ type: :development
189
+ prerelease: false
190
+ version_requirements: !ruby/object:Gem::Requirement
191
+ requirements:
192
+ - - "~>"
193
+ - !ruby/object:Gem::Version
194
+ version: '0.7'
195
+ - !ruby/object:Gem::Dependency
196
+ name: rake
197
+ requirement: !ruby/object:Gem::Requirement
198
+ requirements:
199
+ - - "~>"
200
+ - !ruby/object:Gem::Version
201
+ version: '11.3'
202
+ type: :development
203
+ prerelease: false
204
+ version_requirements: !ruby/object:Gem::Requirement
205
+ requirements:
206
+ - - "~>"
207
+ - !ruby/object:Gem::Version
208
+ version: '11.3'
167
209
  description: ''
168
210
  email:
169
211
  - bruno@brunoabrantes.com
@@ -179,10 +221,10 @@ files:
179
221
  - ".ruby-version"
180
222
  - ".simplecov.template"
181
223
  - ".travis.yml"
182
- - ".version"
183
224
  - Gemfile
184
225
  - LICENSE
185
226
  - README.md
227
+ - Rakefile
186
228
  - bin/hivent
187
229
  - hivent.gemspec
188
230
  - lib/hivent.rb
@@ -197,6 +239,7 @@ files:
197
239
  - lib/hivent/redis/consumer.rb
198
240
  - lib/hivent/redis/extensions.rb
199
241
  - lib/hivent/redis/lua/consumer.lua
242
+ - lib/hivent/redis/lua/heartbeat.lua
200
243
  - lib/hivent/redis/lua/producer.lua
201
244
  - lib/hivent/redis/producer.rb
202
245
  - lib/hivent/redis/redis.rb
data/.version DELETED
@@ -1 +0,0 @@
1
- 1.0.4