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
@@ -3,6 +3,9 @@
3
3
  require "sequel/advisory_lock"
4
4
  require "sequel/database"
5
5
 
6
+ require "webhookdb/concurrent"
7
+ require "webhookdb/jobs/sync_target_run_sync"
8
+
6
9
  # Support exporting WebhookDB data into external services,
7
10
  # such as another Postgres instance or data warehouse (Snowflake, etc).
8
11
  #
@@ -21,9 +24,9 @@ class Webhookdb::SyncTarget < Webhookdb::Postgres::Model(:sync_targets)
21
24
  include Appydays::Configurable
22
25
  include Webhookdb::Dbutil
23
26
 
24
- class Deleted < StandardError; end
25
- class InvalidConnection < StandardError; end
26
- class SyncInProgress < StandardError; end
27
+ class Deleted < Webhookdb::WebhookdbError; end
28
+ class InvalidConnection < Webhookdb::WebhookdbError; end
29
+ class SyncInProgress < Webhookdb::WebhookdbError; end
27
30
 
28
31
  # Advisory locks for sync targets use this as the first int, and the id as the second.
29
32
  ADVISORY_LOCK_KEYSPACE = 2_000_000_000
@@ -32,6 +35,7 @@ class Webhookdb::SyncTarget < Webhookdb::Postgres::Model(:sync_targets)
32
35
  DB_VERIFY_TIMEOUT = 2000
33
36
  DB_VERIFY_STATEMENT = "SELECT 1"
34
37
  RAND = Random.new
38
+ MAX_STATS = 200
35
39
 
36
40
  configurable(:sync_target) do
37
41
  # Allow installs to set this much lower if they want a faster sync.
@@ -52,6 +56,12 @@ class Webhookdb::SyncTarget < Webhookdb::Postgres::Model(:sync_targets)
52
56
  # we must allow sync targets to use http urls. This should only
53
57
  # be used internally, and never in production.
54
58
  setting :allow_http, false
59
+ # Syncing may require serverside cursors, which open a transaction.
60
+ # To avoid long-lived transactions, any sync which has a transaction,
61
+ # and goes on longer than +max_transaction_seconds+,
62
+ # will 'soft abort' the sync and reschedule itself to continue
63
+ # using a new transaction.
64
+ setting :max_transaction_seconds, 10.minutes.to_i
55
65
 
56
66
  after_configured do
57
67
  if Webhookdb::RACK_ENV == "test"
@@ -87,8 +97,10 @@ class Webhookdb::SyncTarget < Webhookdb::Postgres::Model(:sync_targets)
87
97
  dataset_module do
88
98
  def due_for_sync(as_of:)
89
99
  never_synced = Sequel[last_synced_at: nil]
90
- next_due_at = Sequel[:last_synced_at] + (Sequel.lit("INTERVAL '1 second'") * Sequel[:period_seconds])
91
- due_before_now = next_due_at <= as_of
100
+ # Use 'last_synced_at <= (now - internal)' rather than 'last_synced_at + interval <= now'
101
+ # so we can use the last_synced_at index.
102
+ cutoff = (Sequel[as_of].cast("TIMESTAMPTZ") - (Sequel.lit("INTERVAL '1 second'") * Sequel[:period_seconds]))
103
+ due_before_now = Sequel[:last_synced_at] <= cutoff
92
104
  return self.where(never_synced | due_before_now)
93
105
  end
94
106
  end
@@ -168,13 +180,29 @@ class Webhookdb::SyncTarget < Webhookdb::Postgres::Model(:sync_targets)
168
180
  timeout: HTTP_VERIFY_TIMEOUT,
169
181
  follow_redirects: true,
170
182
  )
171
- rescue Timeout::Error => e
172
- raise InvalidConnection, "POST to #{cleanurl} timed out: #{e.message}"
173
- rescue Webhookdb::Http::Error => e
174
- raise InvalidConnection, "POST to #{cleanurl} failed: #{e.message}"
183
+ rescue StandardError => e
184
+ raise InvalidConnection, "POST to #{cleanurl} failed: #{e.message}" if
185
+ e.is_a?(Webhookdb::Http::Error) || self.transport_error?(e)
186
+ raise
175
187
  end
176
188
  end
177
189
 
190
+ # Return true if the given error is considered a 'transport' error,
191
+ # like a timeout, socket error, dns error, etc.
192
+ # This isn't a consistent class type.
193
+ def self.transport_error?(e)
194
+ return true if e.is_a?(Timeout::Error)
195
+ return true if e.is_a?(SocketError)
196
+ return true if e.is_a?(OpenSSL::SSL::SSLError)
197
+ # SystemCallError are Errno errors, we can get them when the url no longer resolves.
198
+ return true if e.is_a?(SystemCallError)
199
+ # Socket::ResolutionError is an error but I guess it's defined in C and we can't raise it in tests.
200
+ # Anything with an error_code assume is some transport-level issue and treat it as a connection issue,
201
+ # not a coding issue.
202
+ return true if e.respond_to?(:error_code)
203
+ return false
204
+ end
205
+
178
206
  def next_scheduled_sync(now:)
179
207
  return self.next_sync(self.period_seconds, now)
180
208
  end
@@ -200,6 +228,13 @@ class Webhookdb::SyncTarget < Webhookdb::Postgres::Model(:sync_targets)
200
228
  return RAND.rand(1..max_jitter)
201
229
  end
202
230
 
231
+ # @return [ActiveSupport::Duration,Integer]
232
+ def latency(now: Time.now)
233
+ return 0 if self.last_synced_at.nil?
234
+ return 0 if self.last_synced_at > now
235
+ return now - self.last_synced_at
236
+ end
237
+
203
238
  # Running a sync involves some work we always do (export, transform),
204
239
  # and then work that varies per-adapter (load).
205
240
  #
@@ -239,6 +274,7 @@ class Webhookdb::SyncTarget < Webhookdb::Postgres::Model(:sync_targets)
239
274
  # since the session will be ended.
240
275
  Webhookdb::Dbutil.borrow_conn(Webhookdb::Postgres::Model.uri) do |db|
241
276
  self.advisory_lock(db).with_lock? do
277
+ self.logger.info "starting_sync"
242
278
  routine = if self.connection_url.start_with?("https://", "http://")
243
279
  # Note that http links are not secure and should only be used for development purposes
244
280
  HttpRoutine.new(now, self)
@@ -261,6 +297,16 @@ class Webhookdb::SyncTarget < Webhookdb::Postgres::Model(:sync_targets)
261
297
  return displaysafe_url(self.connection_url)
262
298
  end
263
299
 
300
+ def log_tags
301
+ return {
302
+ sync_target_id: self.id,
303
+ sync_target_connection_url: self.displaysafe_connection_url,
304
+ service_integration_id: self.service_integration_id,
305
+ service_integration_service: self.service_integration.service_name,
306
+ service_integration_table: self.service_integration.table_name,
307
+ }
308
+ end
309
+
264
310
  # @return [String]
265
311
  def associated_type
266
312
  # Eventually we need to support orgs
@@ -284,6 +330,39 @@ class Webhookdb::SyncTarget < Webhookdb::Postgres::Model(:sync_targets)
284
330
  return "#{schema_name}.#{table_name}"
285
331
  end
286
332
 
333
+ # :section: Stats
334
+
335
+ def add_sync_stat(start, exception: nil, response_status: nil)
336
+ stat = {"t" => s2ms(start), "d" => s2ms(Time.now - start)}
337
+ stat["e"] = exception.class.name if exception
338
+ stat["rs"] = response_status unless response_status.nil?
339
+ stats = self.sync_stats
340
+ stats.prepend(stat)
341
+ stats.pop if stats.size > MAX_STATS
342
+ self.will_change_column(:sync_stats)
343
+ end
344
+
345
+ protected def s2ms(t) = (t.to_f * 1000).to_i
346
+ protected def ms2s(ms) = ms / 1000.0
347
+
348
+ def sync_stat_summary
349
+ return {} if self.sync_stats.empty?
350
+ earliest = self.sync_stats.last
351
+ latest = self.sync_stats.first
352
+ average_latency = (self.sync_stats.sum { |st| ms2s(st["d"]) }) / self.sync_stats.size
353
+ errors = self.sync_stats.count { |st| st["e"] || st["rs"] }
354
+ calls_per_minute = 60 / average_latency
355
+ rpm = self.page_size * calls_per_minute
356
+ rpm *= self.parallelism if self.parallelism.positive?
357
+ return {
358
+ latest: Time.at(ms2s(latest["t"]).to_i),
359
+ earliest: Time.at(ms2s(earliest["t"]).to_i),
360
+ average_latency: average_latency.round(2),
361
+ average_rows_minute: rpm.to_i,
362
+ errors:,
363
+ }
364
+ end
365
+
287
366
  # @return [Webhookdb::Organization]
288
367
  def organization
289
368
  return self.service_integration.organization
@@ -341,40 +420,112 @@ class Webhookdb::SyncTarget < Webhookdb::Postgres::Model(:sync_targets)
341
420
  end
342
421
  end
343
422
 
344
- def record(last_synced_at)
345
- self.sync_target.update(last_synced_at:)
423
+ def perform_db_op(&)
424
+ yield
346
425
  rescue Sequel::NoExistingObject => e
347
426
  raise Webhookdb::SyncTarget::Deleted, e
348
427
  end
428
+
429
+ def record(last_synced_at)
430
+ self.perform_db_op do
431
+ self.sync_target.update(last_synced_at:)
432
+ end
433
+ end
434
+
435
+ def with_stat(&)
436
+ start = Time.now
437
+ begin
438
+ yield
439
+ self.sync_target.add_sync_stat(start)
440
+ rescue Webhookdb::Http::Error => e
441
+ self.sync_target.add_sync_stat(start, response_status: e.status)
442
+ raise
443
+ rescue StandardError => e
444
+ self.sync_target.add_sync_stat(start, exception: e)
445
+ raise
446
+ end
447
+ end
448
+
449
+ def to_ms(t)
450
+ return (t.to_f * 1000).to_i
451
+ end
349
452
  end
350
453
 
351
454
  class HttpRoutine < Routine
455
+ def initialize(*)
456
+ super
457
+ @inflight_timestamps = []
458
+ @cleanurl, @authparams = Webhookdb::Http.extract_url_auth(self.sync_target.connection_url)
459
+ @threadpool = if self.sync_target.parallelism.zero?
460
+ Webhookdb::Concurrent::SerialPool.new
461
+ else
462
+ Webhookdb::Concurrent::ParallelizedPool.new(self.sync_target.parallelism)
463
+ end
464
+ @mutex = Thread::Mutex.new
465
+ end
466
+
352
467
  def run
468
+ timeout_at = Time.now + Webhookdb::SyncTarget.max_transaction_seconds
353
469
  page_size = self.sync_target.page_size
470
+ sync_result = :complete
354
471
  self.dataset_to_sync do |ds|
355
472
  chunk = []
356
- ds.paged_each(rows_per_fetch: page_size) do |row|
473
+ ds.paged_each(rows_per_fetch: page_size, cursor_name: "synctarget_#{self.sync_target.id}_cursor") do |row|
357
474
  chunk << row
358
- self._flush_http_chunk(chunk) if chunk.size >= page_size
475
+ if chunk.size >= page_size
476
+ # Do not share chunks across threads
477
+ self._flush_http_chunk(chunk.dup)
478
+ chunk.clear
479
+ if Time.now >= timeout_at && Thread.current[:sidekiq_context]
480
+ # If we've hit the timeout, stop any further syncing
481
+ sync_result = :timeout
482
+ break
483
+ end
484
+ end
359
485
  end
360
486
  self._flush_http_chunk(chunk) unless chunk.empty?
361
- # We should save 'now' as the timestamp, rather than the last updated row.
362
- # This is important because other we'd keep trying to sync the last row synced.
363
- self.record(self.now)
487
+ @threadpool.join
488
+ case sync_result
489
+ when :timeout
490
+ # If the sync timed out, use the last recorded sync timestamp,
491
+ # and re-enqueue the job, so the sync will pick up where it left off.
492
+ self.sync_target.logger.info("sync_target_transaction_timeout", self.sync_target.log_tags)
493
+ Webhookdb::Jobs::SyncTargetRunSync.perform_async(self.sync_target.id)
494
+ else
495
+ # The sync completed normally.
496
+ # Save 'now' as the timestamp, rather than the last updated row.
497
+ # This is important because other we'd keep trying to sync the last row synced.
498
+ self.record(self.now)
499
+ end
500
+ end
501
+ rescue Webhookdb::Concurrent::Timeout => e
502
+ # This should never really happen, but it does, so record it while we debug it.
503
+ self.perform_db_op do
504
+ self.sync_target.save_changes
364
505
  end
365
- rescue Webhookdb::Http::Error, Errno::ECONNRESET, Net::ReadTimeout, Net::OpenTimeout, OpenSSL::SSL::SSLError => e
366
- # This is handled well so no need to re-raise.
506
+ self.sync_target.logger.error("sync_target_pool_timeout_error", self.sync_target.log_tags, e)
507
+ rescue StandardError => e
508
+ # Errors talking to the http server are handled well so no need to re-raise.
367
509
  # We already committed the last page that was successful,
368
510
  # so we can just stop syncing at this point to try again later.
369
-
511
+ raise e unless e.is_a?(Webhookdb::Http::Error) || Webhookdb::SyncTarget.transport_error?(e)
512
+ self.perform_db_op do
513
+ # Save any outstanding stats.
514
+ self.sync_target.save_changes
515
+ end
370
516
  # Don't spam our logs with downstream errors
371
517
  idem_key = "sync_target_http_error-#{self.sync_target.id}-#{e.class.name}"
372
518
  Webhookdb::Idempotency.every(1.hour).in_memory.under_key(idem_key) do
373
- self.sync_target.logger.warn("sync_target_http_error", error: e)
519
+ self.sync_target.logger.warn("sync_target_http_error", self.sync_target.log_tags, e)
374
520
  end
375
521
  end
376
522
 
377
523
  def _flush_http_chunk(chunk)
524
+ chunk_ts = chunk.last.fetch(self.replicator.timestamp_column.name)
525
+ @mutex.synchronize do
526
+ @inflight_timestamps << chunk_ts
527
+ @inflight_timestamps.sort!
528
+ end
378
529
  sint = self.sync_target.service_integration
379
530
  body = {
380
531
  rows: chunk,
@@ -383,19 +534,34 @@ class Webhookdb::SyncTarget < Webhookdb::Postgres::Model(:sync_targets)
383
534
  table: sint.table_name,
384
535
  sync_timestamp: self.now,
385
536
  }
386
- cleanurl, authparams = Webhookdb::Http.extract_url_auth(self.sync_target.connection_url)
387
- Webhookdb::Http.post(
388
- cleanurl,
389
- body,
390
- timeout: sint.organization.sync_target_timeout,
391
- logger: self.sync_target.logger,
392
- basic_auth: authparams,
393
- )
394
- latest_ts = chunk.last.fetch(self.replicator.timestamp_column.name)
395
- # The client committed the sync page we sent. Record it in case of a future error,
396
- # so we don't re-send the same page.
397
- self.record(latest_ts)
398
- chunk.clear
537
+ @threadpool.post do
538
+ self.with_stat do
539
+ Webhookdb::Http.post(
540
+ @cleanurl,
541
+ body,
542
+ timeout: sint.organization.sync_target_timeout,
543
+ logger: self.sync_target.logger,
544
+ basic_auth: @authparams,
545
+ )
546
+ end
547
+ # On success, we want to commit the latest timestamp we sent to the client,
548
+ # so it can be recorded. Then in the case of an error on later rows,
549
+ # we won't re-sync rows we've already processed (with earlier updated timestamps).
550
+ @mutex.synchronize do
551
+ this_ts_idx = @inflight_timestamps.index { |t| t == chunk_ts }
552
+ raise Webhookdb::InvariantViolation, "timestamp no longer found!?" if this_ts_idx.nil?
553
+ # However, we only want to record the timestamp if this request is the earliest inflight request;
554
+ # ie, if a later request finishes before an earlier one, we don't want to record the timestamp
555
+ # of the later request as 'finished' since the earlier one didn't finish.
556
+ # This does mean though that, if the earliest request errors, we'll throw away the work
557
+ # done by the later request.
558
+ # Note that each row can only appear in a sync once, even if it is modified after the sync starts;
559
+ # thus, parallel httpsync should be fine for most clients to handle,
560
+ # since race conditions *on the same row* cannot happen even with parallel httpsync.
561
+ self.record(chunk_ts) if this_ts_idx.zero?
562
+ @inflight_timestamps.delete_at(this_ts_idx)
563
+ end
564
+ end
399
565
  end
400
566
  end
401
567
 
@@ -445,7 +611,9 @@ class Webhookdb::SyncTarget < Webhookdb::Postgres::Model(:sync_targets)
445
611
  schema_expr = schema_lines.join(";\n") + ";"
446
612
  if schema_expr != self.sync_target.last_applied_schema
447
613
  adapter_conn.execute(schema_expr)
448
- self.sync_target.update(last_applied_schema: schema_expr)
614
+ self.perform_db_op do
615
+ self.sync_target.update(last_applied_schema: schema_expr)
616
+ end
449
617
  end
450
618
  tempfile = Tempfile.new("whdbsyncout-#{self.sync_target.id}")
451
619
  begin
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "webhookdb/postgres/model"
4
+
5
+ class Webhookdb::SystemLogEvent < Webhookdb::Postgres::Model(:system_log_events)
6
+ plugin :text_searchable, terms: [:title, :body]
7
+
8
+ many_to_one :actor, class: "Webhookdb::Customer"
9
+ end
@@ -7,7 +7,7 @@ require "webhookdb"
7
7
  module Webhookdb::Tasks
8
8
  class Admin < Rake::TaskLib
9
9
  def initialize
10
- super()
10
+ super
11
11
  namespace :admin do
12
12
  desc "Add roles to the named org"
13
13
  task :role, [:org_key, :role] do |_, args|
@@ -9,7 +9,7 @@ require "webhookdb/postgres"
9
9
  module Webhookdb::Tasks
10
10
  class Annotate < Rake::TaskLib
11
11
  def initialize
12
- super()
12
+ super
13
13
  desc "Update model annotations"
14
14
  task :annotate do
15
15
  unless `git diff`.blank?
@@ -9,7 +9,7 @@ require "webhookdb/postgres"
9
9
  module Webhookdb::Tasks
10
10
  class DB < Rake::TaskLib
11
11
  def initialize
12
- super()
12
+ super
13
13
  namespace :db do
14
14
  desc "Drop all tables in the public schema."
15
15
  task :drop_tables do
@@ -41,6 +41,18 @@ module Webhookdb::Tasks
41
41
  desc "Re-create the database tables. Drop tables and migrate."
42
42
  task reset: ["db:drop_tables", "db:migrate"]
43
43
 
44
+ desc "Set all model tables to UNLOGGED. Do this after test migrating. NEVER PROD."
45
+ task :unlogged do
46
+ raise "Unly run under RACK_ENV=test" unless Webhookdb::RACK_ENV == "test"
47
+ require "webhookdb/postgres"
48
+ Webhookdb::Postgres.load_superclasses
49
+ Webhookdb::Postgres.each_model_superclass do |sc|
50
+ sc.tsort.reverse_each do |m|
51
+ self.exec(sc.db, "ALTER TABLE #{m.tablename} SET UNLOGGED")
52
+ end
53
+ end
54
+ end
55
+
44
56
  task :drop_replication_databases do
45
57
  require "webhookdb/postgres"
46
58
  Webhookdb::Postgres.load_superclasses
@@ -9,7 +9,7 @@ require "webhookdb/postgres"
9
9
  module Webhookdb::Tasks
10
10
  class Docs < Rake::TaskLib
11
11
  def initialize
12
- super()
12
+ super
13
13
  namespace :docs do
14
14
  desc "Write out auto-generated docs for integrations."
15
15
  task :replicators, [:out, :name] do |_, args|
@@ -8,7 +8,7 @@ require "webhookdb/postgres"
8
8
  module Webhookdb::Tasks
9
9
  class Fixture < Rake::TaskLib
10
10
  def initialize
11
- super()
11
+ super
12
12
  namespace :fixture do
13
13
  desc "Create a bunch of fake integrations and fill them with data."
14
14
  task :full do
@@ -5,7 +5,7 @@ require "rake/tasklib"
5
5
  module Webhookdb::Tasks
6
6
  class Message < Rake::TaskLib
7
7
  def initialize
8
- super()
8
+ super
9
9
  namespace :message do
10
10
  desc "Render the specified message"
11
11
  task :render, [:template_class, :out] do |_t, args|
@@ -9,7 +9,7 @@ require "webhookdb/postgres"
9
9
  module Webhookdb::Tasks
10
10
  class Regress < Rake::TaskLib
11
11
  def initialize
12
- super()
12
+ super
13
13
  namespace :regress do
14
14
  desc "Creates databases for all orgs that do not have them."
15
15
  task :prepare do
@@ -7,7 +7,7 @@ require "webhookdb"
7
7
  module Webhookdb::Tasks
8
8
  class Release < Rake::TaskLib
9
9
  def initialize
10
- super()
10
+ super
11
11
  desc "Migrate replication tables for each integration, ensure all columns and backfill new columns."
12
12
  task :migrate_replication_tables do
13
13
  Webhookdb.load_app
@@ -7,7 +7,7 @@ require "webhookdb"
7
7
  module Webhookdb::Tasks
8
8
  class Sidekiq < Rake::TaskLib
9
9
  def initialize
10
- super()
10
+ super
11
11
  namespace :sidekiq do
12
12
  desc "Clear the Sidekiq redis DB (flushdb). " \
13
13
  "Only use on local, and only for legit reasons, " \
@@ -8,7 +8,7 @@ require "webhookdb"
8
8
  module Webhookdb::Tasks
9
9
  class Specs < Rake::TaskLib
10
10
  def initialize
11
- super()
11
+ super
12
12
  namespace :specs do
13
13
  desc "Run API integration tests in the 'integration' folder of this gem. " \
14
14
  "To run your own tests, create a task similar to this one, " \
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Webhookdb
4
- VERSION = "1.3.1"
4
+ VERSION = "1.5.0"
5
5
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "webhookdb/jobs/webhook_subscription_delivery_attempt"
3
+ require "webhookdb/jobs/webhook_subscription_delivery_event"
4
4
 
5
5
  # Webhook subscriptions have a few parts:
6
6
  #
@@ -136,9 +136,8 @@ class Webhookdb::WebhookSubscription < Webhookdb::Postgres::Model(:webhook_subsc
136
136
  rescue StandardError => e
137
137
  self.logger.error(
138
138
  "webhook_subscription_delivery_failure",
139
- error: e,
140
- webhook_subscription_id: self.id,
141
- webhook_subscription_delivery_id: d.id,
139
+ {webhook_subscription_id: self.id, webhook_subscription_delivery_id: d.id},
140
+ e,
142
141
  )
143
142
  d.add_attempt(status: e.is_a?(Webhookdb::Http::Error) ? e.status : 0)
144
143
  if attempt < MAX_DELIVERY_ATTEMPTS
data/lib/webhookdb.rb CHANGED
@@ -19,6 +19,8 @@ Money.locale_backend = :i18n
19
19
  Money.default_currency = "USD"
20
20
  Money.rounding_mode = BigDecimal::ROUND_HALF_UP
21
21
 
22
+ ActiveSupport.to_time_preserves_timezone = true
23
+
22
24
  module Appydays::Configurable
23
25
  def self.fetch_env(keys, default=:__keyerror, env: ENV)
24
26
  keys = [keys] unless keys.respond_to?(:to_ary)
@@ -35,27 +37,33 @@ module Webhookdb
35
37
  include Appydays::Configurable
36
38
  extend Webhookdb::MethodUtilities
37
39
 
40
+ # Base class for all WebhookDB errors.
41
+ class WebhookdbError < StandardError; end
42
+
43
+ # Class for errors that usually should not be rescued from.
44
+ class ProgrammingError < WebhookdbError; end
45
+
38
46
  # Error raised when we cannot take an action
39
47
  # because some condition has not been set up right.
40
- class InvalidPrecondition < StandardError; end
48
+ class InvalidPrecondition < ProgrammingError; end
41
49
 
42
50
  # Error raised when, after we take an action,
43
51
  # something we expect to have changed has not changed.
44
- class InvalidPostcondition < StandardError; end
52
+ class InvalidPostcondition < ProgrammingError; end
45
53
 
46
54
  # Some invariant has been violated, which we never expect to see.
47
- class InvariantViolation < StandardError; end
55
+ class InvariantViolation < ProgrammingError; end
48
56
 
49
57
  # Error raised when a customer gives us some invalid input.
50
58
  # Allows the library to raise the error with the message,
51
59
  # and is caught automatically by the service as a 400.
52
- class InvalidInput < StandardError; end
60
+ class InvalidInput < WebhookdbError; end
53
61
 
54
62
  # Raised when an organization's database cannot be modified.
55
- class DatabaseLocked < StandardError; end
63
+ class DatabaseLocked < WebhookdbError; end
56
64
 
57
65
  # Used in various places that need to short-circuit code in regression mode.
58
- class RegressionModeSkip < StandardError; end
66
+ class RegressionModeSkip < WebhookdbError; end
59
67
 
60
68
  APPLICATION_NAME = "Webhookdb"
61
69
  RACK_ENV = Appydays::Configurable.fetch_env(["RACK_ENV", "RUBY_ENV"], "development")
@@ -78,7 +86,7 @@ module Webhookdb
78
86
  nil,
79
87
  key: "LOG_LEVEL",
80
88
  side_effect: ->(v) { Appydays::Loggable.default_level = v if v }
81
- setting :log_format, nil
89
+ setting :log_format, :json_trunc
82
90
  setting :app_url, "http://localhost:18002"
83
91
  setting :api_url, "http://localhost:#{ENV.fetch('PORT', 18_001)}"
84
92
  setting :bust_idempotency, false
@@ -93,6 +101,8 @@ module Webhookdb
93
101
  end
94
102
  end
95
103
 
104
+ def self.admin_url = self.api_url
105
+
96
106
  # Regression mode is true when we re replaying webhooks locally,
97
107
  # or for some other reason, want to disable certain checks we use in production.
98
108
  # For example, we may want to ignore certain errors (like if integrations are missing dependency rows),
@@ -134,7 +144,23 @@ module Webhookdb
134
144
  # :section: Errors
135
145
  #
136
146
 
137
- class LockFailed < StandardError; end
147
+ class LockFailed < WebhookdbError; end
148
+
149
+ # This exception is rescued in the API and returned to the caller via +merror!+.
150
+ # This is mostly used in synchronously-processed replicators that need to return
151
+ # very specific types of errors during processing.
152
+ # This is very rare.
153
+ class ExceptionCarrier < WebhookdbError
154
+ attr_reader :status, :code, :more, :headers
155
+
156
+ def initialize(status, message, code: nil, more: {}, headers: {})
157
+ super(message)
158
+ @status = status
159
+ @code = code
160
+ @more = more
161
+ @headers = headers
162
+ end
163
+ end
138
164
 
139
165
  ### Generate a key for the specified Sequel model +instance+ and
140
166
  ### any additional +parts+ that can be used for idempotent requests.