octocore-mongo 0.0.6

Sign up to get free protection for your applications and to get access to all the features.
Files changed (105) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGES.md +12 -0
  3. data/CONTRIBUTING +0 -0
  4. data/Gemfile +11 -0
  5. data/Gemfile.lock +150 -0
  6. data/LICENSE +202 -0
  7. data/MAINTAINERS +0 -0
  8. data/NOTICE +8 -0
  9. data/README.md +69 -0
  10. data/Rakefile +51 -0
  11. data/bin/fakestream-mongo +258 -0
  12. data/bin/octocore-admin-mongo +54 -0
  13. data/lib/octocore-mongo.rb +157 -0
  14. data/lib/octocore-mongo/baseline.rb +131 -0
  15. data/lib/octocore-mongo/callbacks.rb +117 -0
  16. data/lib/octocore-mongo/config.rb +39 -0
  17. data/lib/octocore-mongo/config/config.yml +1 -0
  18. data/lib/octocore-mongo/config/search/index/user.yml +42 -0
  19. data/lib/octocore-mongo/counter.rb +265 -0
  20. data/lib/octocore-mongo/counter/helpers.rb +168 -0
  21. data/lib/octocore-mongo/email.rb +63 -0
  22. data/lib/octocore-mongo/featureflag.rb +79 -0
  23. data/lib/octocore-mongo/helpers.rb +6 -0
  24. data/lib/octocore-mongo/helpers/api_consumer_helper.rb +51 -0
  25. data/lib/octocore-mongo/helpers/api_helper.rb +65 -0
  26. data/lib/octocore-mongo/helpers/api_logger.rb +14 -0
  27. data/lib/octocore-mongo/helpers/client_helper.rb +104 -0
  28. data/lib/octocore-mongo/helpers/kong_helper.rb +164 -0
  29. data/lib/octocore-mongo/helpers/sinatra_helper.rb +22 -0
  30. data/lib/octocore-mongo/kafka_bridge.rb +60 -0
  31. data/lib/octocore-mongo/kldivergence.rb +14 -0
  32. data/lib/octocore-mongo/mailer.rb +1 -0
  33. data/lib/octocore-mongo/mailer/subscriber_mailer.rb +32 -0
  34. data/lib/octocore-mongo/message_parser.rb +114 -0
  35. data/lib/octocore-mongo/models.rb +275 -0
  36. data/lib/octocore-mongo/models/contactus.rb +42 -0
  37. data/lib/octocore-mongo/models/enterprise.rb +75 -0
  38. data/lib/octocore-mongo/models/enterprise/adapter_details.rb +18 -0
  39. data/lib/octocore-mongo/models/enterprise/api_event.rb +14 -0
  40. data/lib/octocore-mongo/models/enterprise/api_hit.rb +20 -0
  41. data/lib/octocore-mongo/models/enterprise/api_key.rb +11 -0
  42. data/lib/octocore-mongo/models/enterprise/api_track.rb +13 -0
  43. data/lib/octocore-mongo/models/enterprise/app_init.rb +13 -0
  44. data/lib/octocore-mongo/models/enterprise/app_login.rb +12 -0
  45. data/lib/octocore-mongo/models/enterprise/app_logout.rb +12 -0
  46. data/lib/octocore-mongo/models/enterprise/authorization.rb +67 -0
  47. data/lib/octocore-mongo/models/enterprise/category.rb +14 -0
  48. data/lib/octocore-mongo/models/enterprise/category_baseline.rb +19 -0
  49. data/lib/octocore-mongo/models/enterprise/category_hit.rb +26 -0
  50. data/lib/octocore-mongo/models/enterprise/category_trend.rb +19 -0
  51. data/lib/octocore-mongo/models/enterprise/conversions.rb +69 -0
  52. data/lib/octocore-mongo/models/enterprise/ctr.rb +54 -0
  53. data/lib/octocore-mongo/models/enterprise/dimension_choice.rb +21 -0
  54. data/lib/octocore-mongo/models/enterprise/engagement_time.rb +43 -0
  55. data/lib/octocore-mongo/models/enterprise/funnel_data.rb +20 -0
  56. data/lib/octocore-mongo/models/enterprise/funnel_tracker.rb +19 -0
  57. data/lib/octocore-mongo/models/enterprise/funnels.rb +129 -0
  58. data/lib/octocore-mongo/models/enterprise/gcm.rb +21 -0
  59. data/lib/octocore-mongo/models/enterprise/newsfeed_hit.rb +52 -0
  60. data/lib/octocore-mongo/models/enterprise/notification_hit.rb +42 -0
  61. data/lib/octocore-mongo/models/enterprise/page.rb +15 -0
  62. data/lib/octocore-mongo/models/enterprise/page_view.rb +14 -0
  63. data/lib/octocore-mongo/models/enterprise/pageload_time.rb +43 -0
  64. data/lib/octocore-mongo/models/enterprise/product.rb +22 -0
  65. data/lib/octocore-mongo/models/enterprise/product_baseline.rb +20 -0
  66. data/lib/octocore-mongo/models/enterprise/product_hit.rb +26 -0
  67. data/lib/octocore-mongo/models/enterprise/product_page_view.rb +13 -0
  68. data/lib/octocore-mongo/models/enterprise/product_trend.rb +18 -0
  69. data/lib/octocore-mongo/models/enterprise/push_key.rb +15 -0
  70. data/lib/octocore-mongo/models/enterprise/rules.rb +45 -0
  71. data/lib/octocore-mongo/models/enterprise/segment.rb +65 -0
  72. data/lib/octocore-mongo/models/enterprise/segment_data.rb +22 -0
  73. data/lib/octocore-mongo/models/enterprise/tag.rb +14 -0
  74. data/lib/octocore-mongo/models/enterprise/tag_baseline.rb +19 -0
  75. data/lib/octocore-mongo/models/enterprise/tag_hit.rb +26 -0
  76. data/lib/octocore-mongo/models/enterprise/tag_trend.rb +19 -0
  77. data/lib/octocore-mongo/models/enterprise/template.rb +18 -0
  78. data/lib/octocore-mongo/models/plans.rb +17 -0
  79. data/lib/octocore-mongo/models/subscribe.rb +13 -0
  80. data/lib/octocore-mongo/models/user.rb +13 -0
  81. data/lib/octocore-mongo/models/user/push_token.rb +15 -0
  82. data/lib/octocore-mongo/models/user/user_browser_details.rb +16 -0
  83. data/lib/octocore-mongo/models/user/user_location_history.rb +15 -0
  84. data/lib/octocore-mongo/models/user/user_persona.rb +101 -0
  85. data/lib/octocore-mongo/models/user/user_phone_details.rb +17 -0
  86. data/lib/octocore-mongo/models/user/user_profile.rb +20 -0
  87. data/lib/octocore-mongo/models/user/user_timeline.rb +111 -0
  88. data/lib/octocore-mongo/record.rb +20 -0
  89. data/lib/octocore-mongo/schedeuleable.rb +20 -0
  90. data/lib/octocore-mongo/scheduler.rb +72 -0
  91. data/lib/octocore-mongo/search.rb +5 -0
  92. data/lib/octocore-mongo/search/client.rb +33 -0
  93. data/lib/octocore-mongo/search/indexer.rb +0 -0
  94. data/lib/octocore-mongo/search/searchable.rb +18 -0
  95. data/lib/octocore-mongo/search/setup.rb +71 -0
  96. data/lib/octocore-mongo/segment.rb +287 -0
  97. data/lib/octocore-mongo/stats.rb +33 -0
  98. data/lib/octocore-mongo/trendable.rb +88 -0
  99. data/lib/octocore-mongo/trends.rb +158 -0
  100. data/lib/octocore-mongo/utils.rb +90 -0
  101. data/lib/octocore-mongo/version.rb +4 -0
  102. data/octocore-mongo.gemspec +50 -0
  103. data/spec/lib/stats_spec.rb +20 -0
  104. data/spec/spec_helper.rb +103 -0
  105. metadata +545 -0
@@ -0,0 +1,33 @@
1
+ require 'statsd-ruby'
2
+
3
+ module Octo
4
+
5
+ # Instrumentation and Statistical module
6
+ module Stats
7
+
8
+ # Instrument a block identified by its name
9
+ # @param [Symbol] name The name by which this would be identified
10
+ def instrument(name)
11
+ if stats
12
+ stats.time(name.to_s, &Proc.new)
13
+ else
14
+ yield
15
+ end
16
+ end
17
+
18
+ # Get stats instance
19
+ def stats
20
+ if statd_config
21
+ @statsd = Statsd.new(*statd_config.values) unless @statsd
22
+ @statsd
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ # Get stats config from Octo
29
+ def statd_config
30
+ Octo.get_config :statsd
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,88 @@
1
+ require 'octocore-mongo/kldivergence'
2
+ require 'octocore-mongo/counter'
3
+
4
+ module Octo
5
+
6
+ module Trendable
7
+
8
+ include Octo::KLDivergence
9
+
10
+ # Define the columns necessary for a trendable model
11
+ def trendables
12
+ key :divergence, Float
13
+ key :obp, Float
14
+ end
15
+
16
+ # Define the baseline class for this trend
17
+ def baseline(klass)
18
+ @baseline_klass = klass
19
+ end
20
+
21
+ # Define the class for trends
22
+ def trends_class(klass)
23
+ @trends_klass = klass
24
+ end
25
+
26
+ # Aggregates and attempts to store it into the database. This would only
27
+ # work if the class that extends Octo::Counter includes from
28
+ # Cequel::Record
29
+ def aggregate!(ts = Time.now.floor)
30
+ unless self.ancestors.include?MongoMapper::Document
31
+ raise NoMethodError, 'aggregate! not defined for this counter'
32
+ end
33
+
34
+ aggr = aggregate(ts)
35
+ sum = aggregate_sum(aggr)
36
+ aggr.each do |_ts, counterVals|
37
+ counterVals.each do |obj, count|
38
+ counter = self.new
39
+ counter.enterprise_id = obj.enterprise.id
40
+ counter.uid = obj.unique_id
41
+ counter.count = count
42
+ counter.type = Octo::Counter::TYPE_MINUTE
43
+ counter.ts = _ts
44
+ totalCount = sum[_ts][obj.enterprise_id.to_s].to_f
45
+ counter.obp = (count * 1.0)/totalCount
46
+
47
+ baseline_value = get_baseline_value(:TYPE_MINUTE, obj)
48
+ counter.divergence = kl_divergence(counter.obp,
49
+ baseline_value)
50
+ counter.save!
51
+ end
52
+ end
53
+ call_completion_hook(Octo::Counter::TYPE_MINUTE, ts)
54
+ end
55
+
56
+ private
57
+
58
+ # Aggregates to find the sum of all counters for an enterprise
59
+ # at a time
60
+ # @param [Hash] aggr The aggregated hash
61
+ # @return [Hash] The summed up hash
62
+ def aggregate_sum(aggr)
63
+ sum = {}
64
+ aggr.each do |ts, counterVals|
65
+ sum[ts] = {} unless sum.has_key?ts
66
+ counterVals.each do |obj, count|
67
+ if obj.respond_to?(:enterprise_id)
68
+ eid = obj.public_send(:enterprise_id).to_s
69
+ sum[ts][eid] = sum[ts].fetch(eid, 0) + count
70
+ end
71
+ end
72
+ end
73
+ sum
74
+ end
75
+
76
+ # Get the baseline value for an object.
77
+ # @param [Fixnum] baseline_type The type of baseline to fetch
78
+ # @param [Object] object The object for which baseline is to
79
+ # be fetched
80
+ def get_baseline_value(baseline_type, object)
81
+ clazz = @baseline_klass.constantize
82
+ clazz.public_send(:get_baseline_value, baseline_type, object)
83
+ end
84
+
85
+
86
+ end
87
+
88
+ end
@@ -0,0 +1,158 @@
1
+ require 'octocore-mongo/baseline'
2
+ require 'octocore-mongo/counter'
3
+
4
+ module Octo
5
+
6
+ module Trends
7
+
8
+ include Octo::Counter::Helper
9
+
10
+ DEFAULT_COUNT = 10
11
+
12
+ # Define the columns needed for Trends
13
+ def trendable
14
+ key :type, Integer
15
+ key :ts, Time
16
+ key :rank, Integer
17
+
18
+ key :score, Float
19
+ key :uid, String
20
+
21
+ generate_aggregators { |ts, method|
22
+ trendtype = method_names_type_counter(method)
23
+ aggregate_and_create trendtype, ts
24
+ }
25
+
26
+ end
27
+
28
+ # Aggregates and creates trends for all the enterprises for a specific
29
+ # trend type at a specific timestamp
30
+ # @param [Fixnum] oftype The type of trend to be calculated
31
+ # @param [Time] ts The time at which trend needs to be calculated
32
+ def aggregate_and_create(oftype, ts = Time.now.floor)
33
+ Octo::Enterprise.each do |enterprise|
34
+ calculate(enterprise.id, oftype, ts)
35
+ end
36
+ end
37
+
38
+ # Override the aggregate! defined in counter class as the calculations
39
+ # for trending are a little different
40
+ def aggregate!(ts = Time.now.floor)
41
+ aggregate_and_create(Octo::Counter::TYPE_MINUTE, ts)
42
+ end
43
+
44
+ # Performs the actual trend calculation
45
+ # @param [String] enterprise_id The enterprise ID for whom trend needs to be found
46
+ # @param [Fixnum] trend_type The trend type to be calculates
47
+ # @param [Time] ts The Timestamp at which trend needs to be calculated.
48
+ def calculate(enterprise_id, trend_type, ts = Time.now.floor)
49
+ args = {
50
+ enterprise_id: enterprise_id,
51
+ ts: ts,
52
+ type: trend_type
53
+ }
54
+
55
+ klass = @trend_for.constantize
56
+ hitsResult = klass.public_send(:where, args)
57
+ trends = hitsResult.map { |h| counter2trend(h) }
58
+
59
+ # group trends as per the time of their happening and rank them on their
60
+ # score
61
+ grouped_trends = trends.group_by { |x| x.ts }
62
+ grouped_trends.each do |_ts, trendlist|
63
+ sorted_trendlist = trendlist.sort_by { |x| x.score }
64
+ sorted_trendlist.each_with_index do |trend, index|
65
+ trend.rank = index
66
+ trend.type = trend_type
67
+ trend.save!
68
+ end
69
+ end
70
+ end
71
+
72
+ # Define the class for which trends shall be found
73
+ def trend_for(klass)
74
+ unless klass.constantize.ancestors.include?MongoMapper::Document
75
+ raise ArgumentError, "Class #{ klass } does not represent a DB Model"
76
+ else
77
+ @trend_for = klass
78
+ end
79
+ end
80
+
81
+ # Define the class which would be returned while fetching trending objects
82
+ def trend_class(klass)
83
+ @trend_class = klass
84
+ end
85
+
86
+ # Gets the trend of a type at a time
87
+ # @param [String] enterprise_id The ID of enterprise for whom trend to fetch
88
+ # @param [Fixnum] type The type of trend to fetch
89
+ # @param [Hash] opts The options to be provided for finding trends
90
+ def get_trending(enterprise_id, type, opts={})
91
+ ts = opts.fetch(:ts, Time.now.floor)
92
+ args = {
93
+ enterprise_id: enterprise_id,
94
+ ts: opts.fetch(:ts, Time.now.floor),
95
+ type: type
96
+ }
97
+ res = where(args).limit(opts.fetch(:limit, DEFAULT_COUNT))
98
+ enterprise = Octo::Enterprise.find_by_id(enterprise_id)
99
+ if res.count == 0 and enterprise.fakedata?
100
+ Octo.logger.info 'Beginning to fake data'
101
+ res = []
102
+ if ts.class == Range
103
+ ts_begin = ts.begin
104
+ ts_end = ts.end
105
+ ts_begin.to(ts_end, 1.day).each do |_ts|
106
+ 3.times do |rank|
107
+ items = @trend_class.constantize.send(:where, {enterprise_id: enterprise_id}).first(10)
108
+ if items.count > 0
109
+ uid = items.shuffle.pop.unique_id
110
+ _args = args.merge( ts: _ts, rank: rank, score: rank+1, uid: uid )
111
+ res << self.new(_args).save!
112
+ end
113
+ end
114
+ end
115
+ elsif ts.class == Time
116
+ 3.times do |rank|
117
+ uid = 0
118
+ items = @trend_class.constantize.send(:where, {enterprise_id: enterprise_id}).first(10)
119
+ if items.count > 0
120
+ uid = items.shuffle.pop.unique_id
121
+ _args = args.merge( rank: rank, score: rank+1, uid: uid )
122
+ res << self.new(_args).save!
123
+ end
124
+ end
125
+ end
126
+ end
127
+ res.map do |r|
128
+ clazz = @trend_class.constantize
129
+ clazz.public_send(:recreate_from, r)
130
+ end
131
+ end
132
+
133
+ private
134
+
135
+ # Converts a couunter into a trend
136
+ # @param [Object] counter A counter object. This object must belong
137
+ # to one of the counter types defined in models.
138
+ # @return [Object] Returns a trend instance corresponding to the counter
139
+ # instance
140
+ def counter2trend(counter)
141
+ self.new({
142
+ enterprise: counter.enterprise,
143
+ score: score(counter.divergence),
144
+ uid: counter.uid,
145
+ ts: counter.ts
146
+ })
147
+ end
148
+
149
+ # For now, just make sure divergence is absolute. This is responsible to
150
+ # score a counter on the basis of its divergence
151
+ # @param [Float] divergence The divergence value
152
+ # @return [Float] The score
153
+ def score(divergence)
154
+ return 0 if divergence.nil?
155
+ divergence.abs
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,90 @@
1
+ require 'set'
2
+
3
+ module Octo
4
+
5
+ module Utils
6
+
7
+ class << self
8
+
9
+ # Serialize one record before adding it to the cache. Creates a ruby byte
10
+ # stream
11
+ # @param [Object] record Any object to be serialized
12
+ def serialize(record)
13
+ Marshal::dump(record).to_s
14
+ end
15
+
16
+ # Deserialize a data.
17
+ # @param [String] data A string containing Marshal dump of the object
18
+ def deserialize(data)
19
+ Marshal::load(data)
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ class ::Time
26
+
27
+ # Find floor time
28
+ # @param [Fixnum] height The minutes of height for floor. Defaults to 1
29
+ def floor(height = 1)
30
+ if height < 1
31
+ height = 1
32
+ end
33
+ sec = height.to_i * 60
34
+ Time.at((self.to_i / sec).round * sec)
35
+ end
36
+
37
+ # Find ceil time
38
+ # @param [Fixnum] height The minutes of height for ceil. Defaults to 1
39
+ def ceil(height = 1)
40
+ if height < 1
41
+ height = 1
42
+ end
43
+ sec = height.to_i * 60
44
+ Time.at((1 + (self.to_i / sec)).round * sec)
45
+ end
46
+
47
+ # Finds the steps between two time.
48
+ # @param [Time] to The end time
49
+ # @param [Time] step The step time. Defaults to 15.minute
50
+ # @return [Array<Time>] An array containint times
51
+ def to(to, step = 15.minutes)
52
+ [self].tap { |array| array << array.last + step while array.last < to }
53
+ end
54
+
55
+ end
56
+
57
+ class ::String
58
+
59
+ # Create a custom method to convert strings to Slugs
60
+ def to_slug
61
+ #strip the string
62
+ ret = self.strip
63
+
64
+ #blow away apostrophes
65
+ ret.gsub!(/['`]/,'')
66
+
67
+ # @ --> at, and & --> and
68
+ ret.gsub!(/\s*@\s*/, ' at ')
69
+ ret.gsub!(/\s*&\s*/, ' and ')
70
+
71
+ #replace all non alphanumeric, underscore or periods with underscore
72
+ ret.gsub!(/\s*[^A-Za-z0-9\.\-]\s*/, '_')
73
+
74
+ #convert double underscores to single
75
+ ret.gsub!(/_+/,'_')
76
+
77
+ #strip off leading/trailing underscore
78
+ ret.gsub!(/\A[_\.]+|[_\.]+\z/,'')
79
+
80
+ ret
81
+ end
82
+
83
+ end
84
+
85
+ class ::Hash
86
+ def deep_merge(second)
87
+ merger = proc { |key, v1, v2| Hash === v1 && Hash === v2 ? v1.merge(v2, &merger) : Array === v1 && Array === v2 ? v1 | v2 : [:undefined, nil, :nil].include?(v2) ? v1 : v2 }
88
+ self.merge(second.to_h, &merger)
89
+ end
90
+ end
@@ -0,0 +1,4 @@
1
+ module Octo
2
+ # The current version of the library
3
+ VERSION = '0.0.6'
4
+ end
@@ -0,0 +1,50 @@
1
+ require File.expand_path('../lib/octocore-mongo/version', __FILE__)
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = 'octocore-mongo'
5
+ s.version = Octo::VERSION
6
+
7
+ s.summary = 'Octo Enterprise Core Modules for MongoDB'
8
+ s.description = <<DESC
9
+ Octocore is the core framework of Octomatic Enterprise. It
10
+ contains all the core models, tasks, actions etc for MongoDB
11
+ DESC
12
+
13
+ s.authors = ['Dipankar Sarkar']
14
+ s.email = 'dipankar@octo.ai'
15
+ s.files = Dir['lib/**/*.rb', 'spec/**/*.rb', '[A-Z]*', 'lib/**/*.yml']
16
+
17
+ s.executables << 'fakestream-mongo'
18
+ s.executables << 'octocore-admin-mongo'
19
+
20
+ s.homepage =
21
+ 'https://github.com/octoai/gem-octocore-mongo'
22
+ s.license = 'MIT'
23
+
24
+ s.has_rdoc = true
25
+ s.extra_rdoc_files = 'README.md'
26
+
27
+ s.required_ruby_version = '>= 2.1.5'
28
+
29
+ s.add_runtime_dependency 'activemodel-serializers-xml', '~> 1.0.1', '>= 1.0.1'
30
+ s.add_runtime_dependency 'active_model_serializers', '~> 0.9.5', '>= 0.9.5'
31
+ s.add_runtime_dependency 'redis', '~> 3.2.2', '>= 3.2.0'
32
+ s.add_runtime_dependency 'redis-queue', '~> 0.0.4', '>= 0.0.4'
33
+ s.add_runtime_dependency 'hiredis', '~> 0.6.1', '>= 0.6.0'
34
+ s.add_runtime_dependency 'rake', '~> 11.1.0', '>= 11.1.0'
35
+ s.add_runtime_dependency 'resque', '~> 1.26.0', '>= 1.26.0'
36
+ s.add_runtime_dependency 'resque-scheduler', '~> 4.1.0', '>= 4.1.0'
37
+ s.add_runtime_dependency 'descriptive_statistics', '~> 2.5.1', '>= 2.5.0'
38
+ s.add_runtime_dependency 'statsd-ruby', '~> 1.3.0', '>= 1.3.0'
39
+ s.add_runtime_dependency 'hooks', '~> 0.4.1', '>= 0.4.1'
40
+ s.add_runtime_dependency 'json', '~> 1.8.1', '>= 1.8.1'
41
+ s.add_runtime_dependency 'ruby-kafka', '~> 0.3.2', '>= 0.3.2'
42
+ s.add_runtime_dependency 'mandrill-api', '~> 1.0', '>= 1.0.53'
43
+ s.add_runtime_dependency 'elasticsearch', '~> 1.0.17', '>= 1.0.17'
44
+ s.add_runtime_dependency 'faraday', '~> 0.9.2', '>= 0.9.2'
45
+ s.add_runtime_dependency 'mongo_mapper', '~> 0.13.1'
46
+ s.add_runtime_dependency 'bson_ext', '~> 1.12', '>= 1.12.5'
47
+
48
+ s.add_development_dependency 'rspec', '~> 3.4.0', '>= 3.4.0'
49
+ s.add_development_dependency 'parallel_tests', '~> 2.5.0', '>= 2.5.0'
50
+ end
@@ -0,0 +1,20 @@
1
+ require_relative '../spec_helper'
2
+
3
+ describe Octo::Stats do
4
+
5
+ describe '#instrument' do
6
+ let(:dummy_class) { Class.new { extend Octo::Stats } }
7
+
8
+ it 'yields the given block' do
9
+ block = Proc.new { puts 'hello world' }
10
+ name = :hello_world
11
+
12
+ expect(dummy_class).to receive(:instrument).
13
+ with(name, &block).
14
+ and_yield(&block)
15
+
16
+ dummy_class.instrument(name, &block)
17
+ end
18
+ end
19
+
20
+ end