webhookdb 1.3.1 → 1.5.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.
Files changed (164) hide show
  1. checksums.yaml +4 -4
  2. data/admin-dist/assets/{index-6aebf805.js → index-9306dd28.js} +39 -39
  3. data/admin-dist/index.html +1 -1
  4. data/data/messages/templates/errors/generic_backfill.email.liquid +30 -0
  5. data/data/messages/templates/errors/icalendar_fetch.email.liquid +8 -2
  6. data/data/messages/templates/specs/with_fields.email.liquid +6 -0
  7. data/db/migrations/026_undo_integration_backfill_cursor.rb +2 -0
  8. data/db/migrations/032_remove_db_defaults.rb +2 -0
  9. data/db/migrations/043_text_search.rb +2 -0
  10. data/db/migrations/045_system_log.rb +15 -0
  11. data/db/migrations/046_indices.rb +14 -0
  12. data/db/migrations/047_sync_parallelism.rb +9 -0
  13. data/db/migrations/048_sync_stats.rb +9 -0
  14. data/db/migrations/049_error_handlers.rb +18 -0
  15. data/db/migrations/050_logged_webhook_indices.rb +25 -0
  16. data/db/migrations/051_partitioning.rb +9 -0
  17. data/integration/async_spec.rb +0 -2
  18. data/integration/service_integrations_spec.rb +0 -2
  19. data/lib/amigo/durable_job.rb +2 -2
  20. data/lib/amigo/job_in_context.rb +12 -0
  21. data/lib/webhookdb/admin.rb +6 -0
  22. data/lib/webhookdb/admin_api/data_provider.rb +1 -0
  23. data/lib/webhookdb/admin_api/entities.rb +8 -0
  24. data/lib/webhookdb/aggregate_result.rb +1 -1
  25. data/lib/webhookdb/api/entities.rb +6 -2
  26. data/lib/webhookdb/api/error_handlers.rb +104 -0
  27. data/lib/webhookdb/api/helpers.rb +25 -1
  28. data/lib/webhookdb/api/icalproxy.rb +22 -0
  29. data/lib/webhookdb/api/install.rb +2 -1
  30. data/lib/webhookdb/api/organizations.rb +6 -0
  31. data/lib/webhookdb/api/saved_queries.rb +1 -0
  32. data/lib/webhookdb/api/saved_views.rb +1 -0
  33. data/lib/webhookdb/api/service_integrations.rb +2 -1
  34. data/lib/webhookdb/api/sync_targets.rb +1 -1
  35. data/lib/webhookdb/api/system.rb +5 -0
  36. data/lib/webhookdb/api/webhook_subscriptions.rb +1 -0
  37. data/lib/webhookdb/api.rb +4 -1
  38. data/lib/webhookdb/apps.rb +4 -0
  39. data/lib/webhookdb/async/autoscaler.rb +10 -0
  40. data/lib/webhookdb/async/job.rb +4 -0
  41. data/lib/webhookdb/async/scheduled_job.rb +4 -0
  42. data/lib/webhookdb/async.rb +2 -0
  43. data/lib/webhookdb/backfiller.rb +17 -4
  44. data/lib/webhookdb/concurrent.rb +96 -0
  45. data/lib/webhookdb/connection_cache.rb +57 -10
  46. data/lib/webhookdb/console.rb +1 -1
  47. data/lib/webhookdb/customer/reset_code.rb +1 -1
  48. data/lib/webhookdb/customer.rb +5 -4
  49. data/lib/webhookdb/database_document.rb +1 -1
  50. data/lib/webhookdb/db_adapter/default_sql.rb +1 -14
  51. data/lib/webhookdb/db_adapter/partition.rb +14 -0
  52. data/lib/webhookdb/db_adapter/partitioning.rb +8 -0
  53. data/lib/webhookdb/db_adapter/pg.rb +77 -5
  54. data/lib/webhookdb/db_adapter/snowflake.rb +15 -6
  55. data/lib/webhookdb/db_adapter.rb +25 -3
  56. data/lib/webhookdb/dbutil.rb +2 -0
  57. data/lib/webhookdb/errors.rb +34 -0
  58. data/lib/webhookdb/fixtures/logged_webhooks.rb +4 -0
  59. data/lib/webhookdb/fixtures/organization_error_handlers.rb +20 -0
  60. data/lib/webhookdb/http.rb +30 -16
  61. data/lib/webhookdb/icalendar.rb +30 -9
  62. data/lib/webhookdb/jobs/amigo_test_jobs.rb +1 -1
  63. data/lib/webhookdb/jobs/backfill.rb +21 -25
  64. data/lib/webhookdb/jobs/create_mirror_table.rb +3 -4
  65. data/lib/webhookdb/jobs/deprecated_jobs.rb +3 -0
  66. data/lib/webhookdb/jobs/emailer.rb +2 -1
  67. data/lib/webhookdb/jobs/front_signalwire_message_channel_sync_inbound.rb +15 -0
  68. data/lib/webhookdb/jobs/icalendar_delete_stale_cancelled_events.rb +7 -2
  69. data/lib/webhookdb/jobs/icalendar_enqueue_syncs.rb +74 -11
  70. data/lib/webhookdb/jobs/icalendar_enqueue_syncs_for_urls.rb +22 -0
  71. data/lib/webhookdb/jobs/icalendar_sync.rb +21 -9
  72. data/lib/webhookdb/jobs/increase_event_handler.rb +3 -2
  73. data/lib/webhookdb/jobs/{logged_webhook_replay.rb → logged_webhooks_replay.rb} +5 -3
  74. data/lib/webhookdb/jobs/message_dispatched.rb +1 -0
  75. data/lib/webhookdb/jobs/model_event_system_log_tracker.rb +112 -0
  76. data/lib/webhookdb/jobs/monitor_metrics.rb +29 -0
  77. data/lib/webhookdb/jobs/organization_database_migration_notify.rb +32 -0
  78. data/lib/webhookdb/jobs/organization_database_migration_run.rb +4 -6
  79. data/lib/webhookdb/jobs/organization_error_handler_dispatch.rb +26 -0
  80. data/lib/webhookdb/jobs/prepare_database_connections.rb +1 -0
  81. data/lib/webhookdb/jobs/process_webhook.rb +11 -12
  82. data/lib/webhookdb/jobs/renew_watch_channel.rb +10 -10
  83. data/lib/webhookdb/jobs/replication_migration.rb +5 -2
  84. data/lib/webhookdb/jobs/reset_code_create_dispatch.rb +1 -2
  85. data/lib/webhookdb/jobs/scheduled_backfills.rb +2 -2
  86. data/lib/webhookdb/jobs/send_invite.rb +3 -2
  87. data/lib/webhookdb/jobs/send_test_webhook.rb +1 -3
  88. data/lib/webhookdb/jobs/send_webhook.rb +4 -5
  89. data/lib/webhookdb/jobs/stale_row_deleter.rb +31 -0
  90. data/lib/webhookdb/jobs/sync_target_enqueue_scheduled.rb +3 -0
  91. data/lib/webhookdb/jobs/sync_target_run_sync.rb +9 -15
  92. data/lib/webhookdb/jobs/{webhook_subscription_delivery_attempt.rb → webhook_subscription_delivery_event.rb} +5 -8
  93. data/lib/webhookdb/liquid/expose.rb +1 -1
  94. data/lib/webhookdb/liquid/filters.rb +1 -1
  95. data/lib/webhookdb/liquid/partial.rb +2 -2
  96. data/lib/webhookdb/logged_webhook/resilient.rb +3 -3
  97. data/lib/webhookdb/logged_webhook.rb +16 -2
  98. data/lib/webhookdb/message/email_transport.rb +1 -1
  99. data/lib/webhookdb/message/transport.rb +1 -1
  100. data/lib/webhookdb/message.rb +55 -4
  101. data/lib/webhookdb/messages/error_generic_backfill.rb +47 -0
  102. data/lib/webhookdb/messages/error_icalendar_fetch.rb +5 -0
  103. data/lib/webhookdb/messages/error_signalwire_send_sms.rb +2 -0
  104. data/lib/webhookdb/messages/specs.rb +16 -0
  105. data/lib/webhookdb/organization/alerting.rb +56 -6
  106. data/lib/webhookdb/organization/database_migration.rb +2 -2
  107. data/lib/webhookdb/organization/db_builder.rb +5 -4
  108. data/lib/webhookdb/organization/error_handler.rb +141 -0
  109. data/lib/webhookdb/organization.rb +76 -10
  110. data/lib/webhookdb/postgres/model.rb +1 -0
  111. data/lib/webhookdb/postgres/model_utilities.rb +2 -0
  112. data/lib/webhookdb/postgres.rb +3 -4
  113. data/lib/webhookdb/replicator/base.rb +202 -68
  114. data/lib/webhookdb/replicator/base_stale_row_deleter.rb +165 -0
  115. data/lib/webhookdb/replicator/column.rb +2 -0
  116. data/lib/webhookdb/replicator/email_octopus_contact_v1.rb +0 -1
  117. data/lib/webhookdb/replicator/fake.rb +106 -88
  118. data/lib/webhookdb/replicator/front_signalwire_message_channel_app_v1.rb +131 -61
  119. data/lib/webhookdb/replicator/github_repo_v1_mixin.rb +17 -0
  120. data/lib/webhookdb/replicator/icalendar_calendar_v1.rb +197 -32
  121. data/lib/webhookdb/replicator/icalendar_event_v1.rb +20 -44
  122. data/lib/webhookdb/replicator/icalendar_event_v1_partitioned.rb +33 -0
  123. data/lib/webhookdb/replicator/intercom_contact_v1.rb +1 -0
  124. data/lib/webhookdb/replicator/intercom_conversation_v1.rb +1 -0
  125. data/lib/webhookdb/replicator/intercom_v1_mixin.rb +49 -6
  126. data/lib/webhookdb/replicator/partitionable_mixin.rb +116 -0
  127. data/lib/webhookdb/replicator/shopify_v1_mixin.rb +1 -1
  128. data/lib/webhookdb/replicator/signalwire_message_v1.rb +31 -1
  129. data/lib/webhookdb/replicator/sponsy_v1_mixin.rb +1 -1
  130. data/lib/webhookdb/replicator/transistor_episode_stats_v1.rb +0 -1
  131. data/lib/webhookdb/replicator/transistor_episode_v1.rb +11 -5
  132. data/lib/webhookdb/replicator/webhook_request.rb +8 -0
  133. data/lib/webhookdb/replicator.rb +6 -3
  134. data/lib/webhookdb/service/helpers.rb +4 -0
  135. data/lib/webhookdb/service/middleware.rb +6 -2
  136. data/lib/webhookdb/service/view_api.rb +1 -1
  137. data/lib/webhookdb/service.rb +10 -10
  138. data/lib/webhookdb/service_integration.rb +19 -1
  139. data/lib/webhookdb/signalwire.rb +1 -1
  140. data/lib/webhookdb/spec_helpers/async.rb +0 -4
  141. data/lib/webhookdb/spec_helpers/sentry.rb +32 -0
  142. data/lib/webhookdb/spec_helpers/shared_examples_for_replicators.rb +239 -64
  143. data/lib/webhookdb/spec_helpers.rb +1 -0
  144. data/lib/webhookdb/sync_target.rb +202 -34
  145. data/lib/webhookdb/system_log_event.rb +9 -0
  146. data/lib/webhookdb/tasks/admin.rb +1 -1
  147. data/lib/webhookdb/tasks/annotate.rb +1 -1
  148. data/lib/webhookdb/tasks/db.rb +13 -1
  149. data/lib/webhookdb/tasks/docs.rb +1 -1
  150. data/lib/webhookdb/tasks/fixture.rb +1 -1
  151. data/lib/webhookdb/tasks/message.rb +1 -1
  152. data/lib/webhookdb/tasks/regress.rb +1 -1
  153. data/lib/webhookdb/tasks/release.rb +1 -1
  154. data/lib/webhookdb/tasks/sidekiq.rb +1 -1
  155. data/lib/webhookdb/tasks/specs.rb +1 -1
  156. data/lib/webhookdb/version.rb +1 -1
  157. data/lib/webhookdb/webhook_subscription.rb +3 -4
  158. data/lib/webhookdb.rb +34 -8
  159. metadata +114 -64
  160. data/lib/webhookdb/jobs/customer_created_notify_internal.rb +0 -22
  161. data/lib/webhookdb/jobs/organization_database_migration_notify_finished.rb +0 -21
  162. data/lib/webhookdb/jobs/organization_database_migration_notify_started.rb +0 -21
  163. /data/lib/webhookdb/jobs/{logged_webhook_resilient_replay.rb → logged_webhooks_resilient_replay.rb} +0 -0
  164. /data/lib/webhookdb/jobs/{webhook_resource_notify_integrations.rb → webhookdb_resource_notify_integrations.rb} +0 -0
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Webhookdb::Errors
4
+ class << self
5
+ # Call the given block for the given exception, each cause (see +Exception#cause+),
6
+ # and each wrapped errors (see +Amigo::Retry::Error#wrapped+).
7
+ # If the block returns +true+ for any exception, stop walking.
8
+ def each_cause(ex, &)
9
+ raise LocalJumpError unless block_given?
10
+ return true if yield(ex) == true
11
+ caused_got = ex.cause && each_cause(ex.cause, &)
12
+ return true if caused_got == true
13
+ wrapped_got = ex.respond_to?(:wrapped) && ex.wrapped && each_cause(ex.wrapped, &)
14
+ return true if wrapped_got == true
15
+ return nil
16
+ end
17
+
18
+ # Run the given block for each cause (see +each_cause),
19
+ # returning the first exception the block returns +true+ for.
20
+ def find_cause(ex, &)
21
+ raise LocalJumpError unless block_given?
22
+ got = nil
23
+ each_cause(ex) do |cause|
24
+ if yield(cause) == true
25
+ got = cause
26
+ true
27
+ else
28
+ false
29
+ end
30
+ end
31
+ return got
32
+ end
33
+ end
34
+ end
@@ -35,6 +35,10 @@ module Webhookdb::Fixtures::LoggedWebhooks
35
35
  self.response_status = rand(400..599)
36
36
  end
37
37
 
38
+ decorator :truncated do |t=Time.now|
39
+ self.truncated_at = t
40
+ end
41
+
38
42
  decorator :with_organization do |org={}|
39
43
  org = Webhookdb::Fixtures.organization.create(org) unless org.is_a?(Webhookdb::Organization)
40
44
  self.organization = org
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faker"
4
+
5
+ require "webhookdb/fixtures"
6
+
7
+ module Webhookdb::Fixtures::OrganizationErrorHandlers
8
+ extend Webhookdb::Fixtures
9
+
10
+ fixtured_class Webhookdb::Organization::ErrorHandler
11
+
12
+ base :organization_error_handler do
13
+ self.url ||= Faker::Internet.url
14
+ end
15
+
16
+ before_saving do |instance|
17
+ instance.organization ||= Webhookdb::Fixtures.organization.create
18
+ instance
19
+ end
20
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "appydays/configurable"
4
4
  require "appydays/loggable/httparty_formatter"
5
+ require "down/httpx"
5
6
  require "httparty"
6
7
 
7
8
  module Webhookdb::Http
@@ -11,7 +12,7 @@ module Webhookdb::Http
11
12
  end
12
13
 
13
14
  # Error raised when some API has rate limited us.
14
- class BaseError < StandardError; end
15
+ class BaseError < Webhookdb::WebhookdbError; end
15
16
 
16
17
  class Error < BaseError
17
18
  attr_reader :response, :body, :uri, :status, :http_method
@@ -96,25 +97,38 @@ module Webhookdb::Http
96
97
  options[:log_level] = self.log_level
97
98
  end
98
99
 
99
- # Convenience wrapper around Down that handles gzip.
100
+ # Convenience wrapper around Down so we can use our preferred implementation.
101
+ # See commit history for more info.
100
102
  # @return Array<Down::ChunkedIO, IO> Tuple
101
103
  def self.chunked_download(request_url, rewindable: false, **down_kw)
102
- io = Down::NetHttp.open(request_url, rewindable:, **down_kw)
103
- if io.data[:headers].fetch("Content-Encoding", "").include?("gzip")
104
- # If the response is gzipped, Down doesn't handle it properly.
105
- # Wrap it with gzip reader, and force the encoding to binary
106
- # the server may send back a header like Content-Type: text/plain; UTF-8,
107
- # so each line Down yields via #gets will have force_encoding('utf-8').
108
- # https://github.com/janko/down/issues/87
109
- io.instance_variable_set(:@encoding, "binary")
110
- io = Zlib::GzipReader.wrap(io)
111
- end
104
+ uri = URI(request_url)
105
+ raise URI::InvalidURIError, "#{request_url} must be an http/s url" unless ["http", "https"].include?(uri.scheme)
106
+ down_kw[:headers] ||= {}
107
+ down_kw[:headers]["User-Agent"] ||= self.user_agent
108
+ io = Down::Httpx.open(uri, rewindable:, **down_kw)
112
109
  return io
113
110
  end
111
+ end
112
+
113
+ class Down::Httpx
114
+ alias _original_response_error! response_error!
115
+ def response_error!(response)
116
+ # For some reason, Down's httpx backend uses TooManyRedirects for every status code...
117
+ raise Down::NotModified if response.status == 304
118
+ return self._original_response_error!(response)
119
+ end
120
+ end
114
121
 
115
- def self.gzipped?(string)
116
- return false if string.length < 3
117
- b = string[..2].bytes
118
- return b[0] == 0x1f && b[1] == 0x8b
122
+ class HTTPX::Response::Body
123
+ alias _original_initialize initialize
124
+ def initialize(*)
125
+ _original_initialize(*)
126
+ # If the encoding is an invalid one like 'utf8' vs 'utf-8', modify what's was in the charset.
127
+ # See https://github.com/HoneyryderChuck/httpx/issues/66
128
+ return unless @encoding.is_a?(String) && (md = @encoding.match(/^(utf)(\d+)$/))
129
+ @encoding = "#{md[1]}-#{md[2]}"
119
130
  end
120
131
  end
132
+
133
+ # Not sure why, but Down uses this, loads the plugin, but the constant isn't defined.
134
+ require "httpx/plugins/follow_redirects"
@@ -7,20 +7,41 @@ module Webhookdb::Icalendar
7
7
  # If a manual backfill is attempted, direct customer to this url.
8
8
  DOCUMENTATION_URL = "https://docs.webhookdb.com/guides/icalendar/"
9
9
 
10
+ EVENT_REPLICATORS = ["icalendar_event_v1", "icalendar_event_v1_partitioned"].freeze
11
+
10
12
  include Appydays::Configurable
11
13
 
12
14
  configurable(:icalendar) do
13
- # Do not store events older then this when syncing recurring events.
15
+ # Do not store events older than this when syncing recurring events.
14
16
  # Many icalendar feeds are misconfigured and this prevents enumerating 2000+ years of recurrence.
15
- setting :oldest_recurring_event, "1990-01-01", convert: ->(s) { Date.parse(s) }
16
- # Sync icalendar calendars only this often.
17
- # Most services only update every day or so. Assume it takes 5s to sync each feed (request, parse, upsert).
18
- # If you have 10,000 feeds, that is 50,000 seconds, or almost 14 hours of processing time,
19
- # or two threads for 7 hours. The resyncs are spread out across the sync period
20
- # (ie, no thundering herd every 8 hours), but it is still a good idea to sync as infrequently as possible.
17
+ setting :oldest_recurring_event, "2000-01-01", convert: ->(s) { Date.parse(s) }
18
+ # Calendars feeds are considered 'fresh' if they have been synced this long ago or less.
19
+ # Most services only update every day or so.
20
+ # Assume it takes 5s to sync each feed (request, parse, upsert).
21
+ # If you have 10,000 feeds, that is 50,000 seconds,
22
+ # or almost 14 hours of processing time, or two threads for 7 hours.
21
23
  setting :sync_period_hours, 6
24
+ # When stale feeds are scheduled for a resync,
25
+ # 'smear' them along this duration. Using 0 would immediately enqueue syncs of all stale feeds,
26
+ # which could saturate the job server. The number here means that feeds will be refreshed between every
27
+ # +sync_period_hours+ and +sync_period_hours+ + +sync_period_splay_hours+.
28
+ setting :sync_period_splay_hours, 1
29
+ # Number of threads for the 'precheck' threadpool, used when enqueing icalendar sync jobs.
30
+ # Since the precheck process uses many threads, but each check is resource-light and not latency-sensitive,
31
+ # we use a shared threadpool for it.
32
+ setting :precheck_feed_change_pool_size, 12
33
+
34
+ # Cancelled events that were last updated this long ago are deleted from the database.
35
+ setting :stale_cancelled_event_threshold_days, 20
36
+ # The stale row deleter job will look for rows this far before the threshold.
37
+ setting :stale_cancelled_event_lookback_days, 3
22
38
 
23
- # Cancelled events that were cancelled this long ago are deleted from the database.
24
- setting :stale_cancelled_event_threshold_days, 35
39
+ # The URL of the icalproxy server, if using one.
40
+ # See https://github.com/webhookdb/icalproxy for more info.
41
+ # Used to get property HTTP semantics for any icalendar feed, like Etag and HEAD requests.
42
+ setting :proxy_url, ""
43
+ # Api key of the icalproxy server, if using one.
44
+ # See https://github.com/webhookdb/icalproxy
45
+ setting :proxy_api_key, ""
25
46
  end
26
47
  end
@@ -103,7 +103,7 @@ class Webhookdb::Jobs::RetryChecker
103
103
  when "die"
104
104
  raise Amigo::Retry::Die
105
105
  else
106
- raise Amigo::Retry::Die.new(attempts, interval)
106
+ raise Amigo::Retry::OrDie.new(attempts, interval)
107
107
  end
108
108
  end
109
109
  end
@@ -13,40 +13,36 @@ class Webhookdb::Jobs::Backfill
13
13
  on "webhookdb.backfilljob.run"
14
14
  sidekiq_options queue: "netout"
15
15
 
16
- def dependent_queues
17
- # This is really the lowest-priority job so always defer to other queues.
18
- return super
19
- end
16
+ # This is really the lowest-priority job so always defer to other queues.
20
17
 
21
18
  def _perform(event)
22
19
  begin
23
20
  bfjob = self.lookup_model(Webhookdb::BackfillJob, event.payload)
24
21
  rescue RuntimeError => e
25
- self.logger.info "skipping_missing_backfill_job", error: e
22
+ self.set_job_tags(result: "skipped_missing_backfill_job", exception: e)
26
23
  return
27
24
  end
28
25
  sint = bfjob.service_integration
29
26
  bflock = bfjob.ensure_service_integration_lock
30
- self.with_log_tags(sint.log_tags.merge(backfill_job_id: bfjob.opaque_id)) do
31
- sint.db.transaction do
32
- unless bflock.lock?
33
- self.logger.info "skipping_locked_backfill_job"
34
- bfjob.update(finished_at: Time.now)
35
- break
36
- end
37
- bfjob.refresh
38
- if bfjob.finished?
39
- self.logger.info "skipping_finished_backfill_job"
40
- break
41
- end
42
- begin
43
- sint.replicator.backfill(bfjob)
44
- rescue Webhookdb::Replicator::CredentialsMissing
45
- # The credentials could have been cleared out, so just finish this job.
46
- self.logger.info "skipping_backfill_job_without_credentials"
47
- bfjob.update(finished_at: Time.now)
48
- break
49
- end
27
+ self.set_job_tags(sint.log_tags.merge(backfill_job_id: bfjob.opaque_id))
28
+ sint.db.transaction do
29
+ unless bflock.lock?
30
+ self.set_job_tags(result: "skipped_locked_backfill_job")
31
+ bfjob.update(finished_at: Time.now)
32
+ break
33
+ end
34
+ bfjob.refresh
35
+ if bfjob.finished?
36
+ self.set_job_tags(result: "skipped_finished_backfill_job")
37
+ break
38
+ end
39
+ begin
40
+ sint.replicator.backfill(bfjob)
41
+ rescue Webhookdb::Replicator::CredentialsMissing
42
+ # The credentials could have been cleared out, so just finish this job.
43
+ self.set_job_tags(result: "skipped_backfill_job_without_credentials")
44
+ bfjob.update(finished_at: Time.now)
45
+ break
50
46
  end
51
47
  end
52
48
  end
@@ -10,9 +10,8 @@ class Webhookdb::Jobs::CreateMirrorTable
10
10
 
11
11
  def _perform(event)
12
12
  sint = self.lookup_model(Webhookdb::ServiceIntegration, event)
13
- self.with_log_tags(sint.log_tags) do
14
- svc = Webhookdb::Replicator.create(sint)
15
- svc.create_table(if_not_exists: true)
16
- end
13
+ self.set_job_tags(sint.log_tags)
14
+ svc = Webhookdb::Replicator.create(sint)
15
+ svc.create_table(if_not_exists: true)
17
16
  end
18
17
  end
@@ -14,6 +14,9 @@ Amigo::DeprecatedJobs.install(
14
14
  "Jobs::ConvertKitBroadcastBackfill",
15
15
  "Jobs::ConvertKitSubscriberBackfill",
16
16
  "Jobs::ConvertKitTagBackfill",
17
+ "Jobs::CustomerCreatedNotifyInternal",
18
+ "Jobs::OrganizationDatabaseMigrationNotifyStarted",
19
+ "Jobs::OrganizationDatabaseMigrationNotifyFinished",
17
20
  "Jobs::RssBackfillPoller",
18
21
  "Jobs::TwilioScheduledBackfill",
19
22
  )
@@ -9,6 +9,7 @@ class Webhookdb::Jobs::Emailer
9
9
  splay 5.seconds
10
10
 
11
11
  def _perform
12
- Webhookdb::Message.send_unsent
12
+ sent = Webhookdb::Message.send_unsent
13
+ self.set_job_tags(sent_messages: sent.count)
13
14
  end
14
15
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "webhookdb/async/job"
4
+ require "webhookdb/jobs"
5
+
6
+ # See +Webhookdb::Replicator::FrontSignalwireMessageChannelAppV1#alert_async_failed_signalwire_send+
7
+ # for why we need to pull this into an async job.
8
+ class Webhookdb::Jobs::FrontSignalwireMessageChannelSyncInbound
9
+ extend Webhookdb::Async::Job
10
+
11
+ def perform(service_integration_id, kwargs)
12
+ sint = self.lookup_model(Webhookdb::ServiceIntegration, service_integration_id)
13
+ sint.replicator.sync_front_inbound_message(**kwargs.symbolize_keys)
14
+ end
15
+ end
@@ -9,10 +9,15 @@ class Webhookdb::Jobs::IcalendarDeleteStaleCancelledEvents
9
9
  cron "37 7 * * *" # Once a day
10
10
  splay 120
11
11
 
12
+ ADVISORY_LOCK_ID = 1_236_432_568
13
+
12
14
  def _perform
13
- Webhookdb::ServiceIntegration.where(service_name: "icalendar_event_v1").each do |sint|
15
+ Webhookdb::ServiceIntegration.where(service_name: Webhookdb::Icalendar::EVENT_REPLICATORS).each do |sint|
14
16
  self.with_log_tags(sint.log_tags) do
15
- sint.replicator.delete_stale_cancelled_events
17
+ sint.replicator.with_advisory_lock(ADVISORY_LOCK_ID) do
18
+ deleted_rows = sint.replicator.stale_row_deleter.run
19
+ self.set_job_tags("#{sint.organization.key}_#{sint.table_name}" => deleted_rows)
20
+ end
16
21
  end
17
22
  end
18
23
  end
@@ -5,27 +5,90 @@ require "webhookdb/jobs"
5
5
 
6
6
  # For every IcalendarCalendar row needing a sync (across all service integrations),
7
7
  # enqueue a +Webhookdb::Jobs::IcalendarSync+ job.
8
- # Jobs are 'splayed' over 1/4 of the configured calendar sync period (see +Webhookdb::Icalendar+)
9
- # to avoid a thundering herd.
8
+ #
9
+ # Because icalendars need to be synced periodically,
10
+ # and there can be quite a lot, we have to be clever with how we sync them to both avoid syncing too often,
11
+ # and especially avoid saturating workers with syncs.
12
+ # We also have to handle workers running behind, the app being slow, etc.
13
+ #
14
+ # - This job runs every 30 minutes.
15
+ # - It finds rows never synced,
16
+ # or haven't been synced in 8 hours (see +Webhookdb::Icalendar.sync_period_hours+
17
+ # for the actual value, we'll assume 8 hours for this doc).
18
+ # - Enqueue a row sync sync job for between 1 second and 60 minutes from now
19
+ # (see +Webhookdb::Icalendar.sync_period_splay_hours+ for actual upper bound value).
20
+ # - When the sync job runs, if the row has been synced less than 8 hours ago, the job noops.
21
+ #
22
+ # This design will lead to the same calendar being enqueued for a sync multiple times
23
+ # (for a splay of 1 hour, and running this job every 30 minutes, about half the jobs in the queue will be duplicate).
24
+ # This isn't a problem however, since the first sync will run but the duplicates will noop
25
+ # since the row will be seen as recently synced.
26
+ #
27
+ # Importantly, if there is a thundering herd situation,
28
+ # because there is a massive traunch of rows that need to be synced,
29
+ # and it takes maybe 10 hours to sync rather than one,
30
+ # the herd will be thinned and smeared over time as each row is synced.
31
+ # There isn't much we can do for the initial herd (other than making sure
32
+ # only some number of syncs are processing for a given org at a time)
33
+ # but we won't keep getting thundering herds from the same calendars over time.
10
34
  class Webhookdb::Jobs::IcalendarEnqueueSyncs
11
35
  extend Webhookdb::Async::ScheduledJob
12
36
 
13
- cron "*/30 * * * *" # Every 30 minutes
37
+ # See docs for explanation of why we run this often.
38
+ cron "*/30 * * * *"
14
39
  splay 30
15
40
 
16
41
  def _perform
17
- max_splay = Webhookdb::Icalendar.sync_period_hours.hours.to_i / 4
42
+ self.advisory_lock.with_lock? do
43
+ self.__perform
44
+ end
45
+ end
46
+
47
+ # Just a random big number
48
+ LOCK_ID = 2_161_457_251_202_716_167
49
+
50
+ def advisory_lock
51
+ return Sequel::AdvisoryLock.new(Webhookdb::Customer.db, LOCK_ID)
52
+ end
53
+
54
+ def __perform
55
+ max_projected_out_seconds = Webhookdb::Icalendar.sync_period_splay_hours.hours.to_i
56
+ total_count = 0
57
+ threadpool = Concurrent::ThreadPoolExecutor.new(
58
+ name: "ical-precheck",
59
+ max_threads: Webhookdb::Icalendar.precheck_feed_change_pool_size,
60
+ min_threads: 1,
61
+ idletime: 40,
62
+ max_queue: 0,
63
+ fallback_policy: :caller_runs,
64
+ synchronous: false,
65
+ )
18
66
  Webhookdb::ServiceIntegration.dataset.where_each(service_name: "icalendar_calendar_v1") do |sint|
19
- sint.replicator.admin_dataset do |ds|
20
- sint.replicator.rows_needing_sync(ds).each do |row|
21
- calendar_external_id = row.fetch(:external_id)
22
- self.with_log_tags(sint.log_tags) do
23
- splay = rand(1..max_splay)
24
- enqueued_job_id = Webhookdb::Jobs::IcalendarSync.perform_in(splay, sint.id, calendar_external_id)
25
- self.logger.debug("enqueued_icalendar_sync", calendar_external_id:, enqueued_job_id:)
67
+ sint_count = 0
68
+ self.with_log_tags(sint.log_tags) do
69
+ repl = sint.replicator
70
+ repl.admin_dataset do |ds|
71
+ row_ds = repl.
72
+ rows_needing_sync(ds).
73
+ order(:pk).
74
+ select(:external_id, :ics_url, :last_fetch_context)
75
+ row_ds.paged_each(rows_per_fetch: 500, cursor_name: "ical_enqueue_#{sint.id}_cursor") do |row|
76
+ threadpool.post do
77
+ break unless repl.feed_changed?(row)
78
+ calendar_external_id = row.fetch(:external_id)
79
+ perform_in = rand(1..max_projected_out_seconds)
80
+ enqueued_job_id = Webhookdb::Jobs::IcalendarSync.perform_in(perform_in, sint.id, calendar_external_id)
81
+ self.logger.debug("enqueued_icalendar_sync", calendar_external_id:, enqueued_job_id:, perform_in:)
82
+ sint_count += 1
83
+ end
26
84
  end
27
85
  end
28
86
  end
87
+ total_count += sint_count
88
+ self.set_job_tags("#{sint.organization.key}_#{sint.table_name}" => sint_count)
29
89
  end
90
+ threadpool.shutdown
91
+ threadpool.wait_for_termination
92
+ self.set_job_tags(total_enqueued: total_count)
30
93
  end
31
94
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "webhookdb/async/job"
4
+
5
+ class Webhookdb::Jobs::IcalendarEnqueueSyncsForUrls
6
+ extend Webhookdb::Async::Job
7
+
8
+ def perform(urls)
9
+ self.set_job_tags(url_count: urls.length)
10
+ row_count = 0
11
+ Webhookdb::ServiceIntegration.where(service_name: "icalendar_calendar_v1").each do |sint|
12
+ sint.replicator.admin_dataset do |ds|
13
+ affected_row_ext_ids = ds.where(ics_url: urls).select_map(:external_id)
14
+ affected_row_ext_ids.each do |ext_id|
15
+ Webhookdb::Jobs::IcalendarSync.perform_async(sint.id, ext_id)
16
+ end
17
+ row_count += affected_row_ext_ids.length
18
+ end
19
+ end
20
+ self.set_job_tags(result: "icalendar_enqueued_syncs", row_count:)
21
+ end
22
+ end
@@ -1,23 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "amigo/semaphore_backoff_job"
4
+
3
5
  require "webhookdb/async/job"
4
6
  require "webhookdb/jobs"
5
7
 
6
8
  class Webhookdb::Jobs::IcalendarSync
7
9
  extend Webhookdb::Async::Job
10
+ include Amigo::SemaphoreBackoffJob
8
11
 
9
- sidekiq_options retry: false
12
+ sidekiq_options retry: false, queue: "netout"
10
13
 
11
14
  def perform(sint_id, calendar_external_id)
12
15
  sint = self.lookup_model(Webhookdb::ServiceIntegration, sint_id)
13
- self.with_log_tags(sint.log_tags.merge(calendar_external_id:)) do
14
- row = sint.replicator.admin_dataset { |ds| ds[external_id: calendar_external_id] }
15
- if row.nil?
16
- self.logger.warn("icalendar_sync_row_miss", calendar_external_id:)
17
- return
18
- end
19
- self.logger.debug("icalendar_sync_start")
20
- sint.replicator.sync_row(row)
16
+ self.set_job_tags(sint.log_tags.merge(calendar_external_id:))
17
+ row = sint.replicator.admin_dataset { |ds| ds[external_id: calendar_external_id] }
18
+ if row.nil?
19
+ self.set_job_tags(result: "icalendar_sync_row_miss")
20
+ return
21
21
  end
22
+ self.logger.debug("icalendar_sync_start")
23
+ sint.replicator.sync_row(row)
24
+ self.set_job_tags(result: "icalendar_synced")
25
+ end
26
+
27
+ def before_perform(sint_id, *)
28
+ @sint = self.lookup_model(Webhookdb::ServiceIntegration, sint_id)
22
29
  end
30
+
31
+ def semaphore_key = "semaphore-icalendarsync-#{@sint.organization_id}"
32
+ def semaphore_size = @sint.organization.job_semaphore_size
33
+ def semaphore_expiry = 15.minutes
34
+ def semaphore_backoff = 60 + (rand * 30)
23
35
  end
@@ -8,13 +8,14 @@ class Webhookdb::Jobs::IncreaseEventHandler
8
8
  on "increase.*"
9
9
 
10
10
  def _perform(event)
11
+ self.set_job_tags(increase_event_name: event.name)
11
12
  case event.name
12
13
  when "increase.oauth_connection.deactivated"
13
14
  conn_id = event.payload[0].fetch("associated_object_id")
14
- self.logger.info("increase_oauth_disconnect", oauth_connection_id: conn_id)
15
+ self.set_job_tags(result: "increase_oauth_disconnected", oauth_connection_id: conn_id)
15
16
  Webhookdb::Oauth::IncreaseProvider.disconnect_oauth(conn_id)
16
17
  else
17
- self.logger.info("increase_event_noop")
18
+ self.set_job_tags(result: "increase_event_noop")
18
19
  end
19
20
  end
20
21
  end
@@ -10,8 +10,10 @@ class Webhookdb::Jobs::LoggedWebhooksReplay
10
10
 
11
11
  def _perform(event)
12
12
  lwh = self.lookup_model(Webhookdb::LoggedWebhook, event)
13
- self.with_log_tags(service_integration_opaque_id: lwh.service_integration_opaque_id) do
14
- lwh.retry_one(truncate_successful: true)
15
- end
13
+ self.set_job_tags(
14
+ logged_webhook_id: lwh.id,
15
+ service_integration_opaque_id: lwh.service_integration_opaque_id,
16
+ )
17
+ lwh.retry_one(truncate_successful: true)
16
18
  end
17
19
  end
@@ -9,6 +9,7 @@ class Webhookdb::Jobs::MessageDispatched
9
9
 
10
10
  def _perform(event)
11
11
  delivery = self.lookup_model(Webhookdb::Message::Delivery, event)
12
+ self.set_job_tags(delivery_id: delivery.id, to: delivery.to)
12
13
  Webhookdb::Idempotency.once_ever.under_key("message-dispatched-#{delivery.id}") do
13
14
  delivery.send!
14
15
  end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "webhookdb/async/job"
4
+ require "webhookdb/messages/invite"
5
+
6
+ class Webhookdb::Jobs::ModelEventSystemLogTracker
7
+ extend Webhookdb::Async::Job
8
+
9
+ on "webhookdb.*"
10
+
11
+ def _perform(event)
12
+ self.set_job_tags(event_name: event.name)
13
+ case event.name
14
+ when "webhookdb.customer.created"
15
+ self.alert_customer_created(event)
16
+ when "webhookdb.organization.created"
17
+ self.alert_org_created(event)
18
+ when "webhookdb.serviceintegration.created"
19
+ self.alert_sint_created(event)
20
+ when "webhookdb.serviceintegration.destroyed"
21
+ self.alert_sint_destroyed(event)
22
+ else
23
+ self.set_job_tags(result: "noop")
24
+ end
25
+ end
26
+
27
+ def create_event(title, body, link)
28
+ Webhookdb::SystemLogEvent.create(
29
+ at: Time.now,
30
+ title:,
31
+ body:,
32
+ link:,
33
+ )
34
+ end
35
+
36
+ def alert_customer_created(event)
37
+ customer = self.lookup_model(Webhookdb::Customer, event)
38
+ Webhookdb::DeveloperAlert.new(
39
+ subsystem: "Customer Created",
40
+ emoji: ":hook:",
41
+ fallback: "New customer created: #{customer.inspect}",
42
+ fields: [
43
+ {title: "Id", value: customer.id, short: true},
44
+ {title: "Email", value: customer.email, short: true},
45
+ {title: "Link", value: customer.admin_link},
46
+ ],
47
+ ).emit
48
+ create_event("Customer Created", customer.email, customer.admin_link)
49
+ self.set_job_tags(result: "created_customer", email: customer.email)
50
+ end
51
+
52
+ def alert_org_created(event)
53
+ org = self.lookup_model(Webhookdb::Organization, event)
54
+ Webhookdb::DeveloperAlert.new(
55
+ subsystem: "Organization Created",
56
+ emoji: ":office:",
57
+ fallback: "Organization created: #{org.inspect}",
58
+ fields: [
59
+ {title: "Id", value: org.id, short: true},
60
+ {title: "Email", value: org.name, short: true},
61
+ {title: "Link", value: org.admin_link},
62
+ ],
63
+ ).emit
64
+ create_event("Organization Created", "#{org.name} (#{org.key})", org.admin_link)
65
+ self.set_job_tags(result: "created_organization", key: org.key)
66
+ end
67
+
68
+ def alert_sint_created(event)
69
+ sint = self.lookup_model(Webhookdb::ServiceIntegration, event)
70
+ Webhookdb::DeveloperAlert.new(
71
+ subsystem: "Integration Created",
72
+ emoji: ":fax:",
73
+ fallback: "Service Integration #{sint.service_name} (#{sint.opaque_id}) created",
74
+ fields: [
75
+ {title: "Id", value: sint.opaque_id, short: true},
76
+ {title: "Service", value: sint.service_name, short: true},
77
+ {title: "Table", value: sint.table_name, short: true},
78
+ {title: "Org Name", value: sint.organization.name, short: true},
79
+ {title: "Link", value: sint.admin_link},
80
+ ],
81
+ ).emit
82
+ create_event(
83
+ "Integration Created",
84
+ "#{sint.service_name} (#{sint.opaque_id}) created in #{sint.organization.name}",
85
+ sint.admin_link,
86
+ )
87
+ self.set_job_tags(result: "created_service_integration", opaque_id: sint.opaque_id, service: sint.service_name)
88
+ end
89
+
90
+ def alert_sint_destroyed(event)
91
+ pl = event.payload[1].symbolize_keys
92
+ org = Webhookdb::Organization[pl[:organization_id]]
93
+ Webhookdb::DeveloperAlert.new(
94
+ subsystem: "Integration Deleted",
95
+ emoji: ":funeral_urn:",
96
+ fallback: "Service Integration #{pl[:service_name]} (#{pl[:opaque_id]}) deleted",
97
+ fields: [
98
+ {title: "Id", value: pl[:opaque_id], short: true},
99
+ {title: "Service", value: pl[:service_name], short: true},
100
+ {title: "Table", value: pl[:table_name], short: true},
101
+ {title: "Org Name", value: org.name, short: true},
102
+ {title: "Link", value: org.admin_link},
103
+ ],
104
+ ).emit
105
+ create_event(
106
+ "Integration Deleted",
107
+ "#{pl[:service_name]} (#{pl[:opaque_id]}) deleted from #{org.name}",
108
+ org.admin_link,
109
+ )
110
+ self.set_job_tags(result: "destroyed_service_integration", opaque_id: pl[:oparque_id], service: pl[:service_name])
111
+ end
112
+ end