webhookdb 1.4.0 → 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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/db/migrations/026_undo_integration_backfill_cursor.rb +2 -0
  3. data/db/migrations/032_remove_db_defaults.rb +2 -0
  4. data/db/migrations/043_text_search.rb +2 -0
  5. data/db/migrations/047_sync_parallelism.rb +9 -0
  6. data/db/migrations/048_sync_stats.rb +9 -0
  7. data/db/migrations/049_error_handlers.rb +18 -0
  8. data/db/migrations/050_logged_webhook_indices.rb +25 -0
  9. data/db/migrations/051_partitioning.rb +9 -0
  10. data/integration/async_spec.rb +0 -2
  11. data/integration/service_integrations_spec.rb +0 -2
  12. data/lib/amigo/durable_job.rb +2 -2
  13. data/lib/amigo/job_in_context.rb +12 -0
  14. data/lib/webhookdb/api/entities.rb +6 -2
  15. data/lib/webhookdb/api/error_handlers.rb +104 -0
  16. data/lib/webhookdb/api/helpers.rb +8 -1
  17. data/lib/webhookdb/api/icalproxy.rb +22 -0
  18. data/lib/webhookdb/api/install.rb +2 -1
  19. data/lib/webhookdb/api/saved_queries.rb +1 -0
  20. data/lib/webhookdb/api/saved_views.rb +1 -0
  21. data/lib/webhookdb/api/service_integrations.rb +1 -1
  22. data/lib/webhookdb/api/sync_targets.rb +1 -1
  23. data/lib/webhookdb/api/system.rb +5 -0
  24. data/lib/webhookdb/api/webhook_subscriptions.rb +1 -0
  25. data/lib/webhookdb/api.rb +4 -1
  26. data/lib/webhookdb/apps.rb +4 -0
  27. data/lib/webhookdb/async/autoscaler.rb +10 -0
  28. data/lib/webhookdb/async/job.rb +4 -0
  29. data/lib/webhookdb/async/scheduled_job.rb +4 -0
  30. data/lib/webhookdb/async.rb +2 -0
  31. data/lib/webhookdb/backfiller.rb +17 -4
  32. data/lib/webhookdb/concurrent.rb +96 -0
  33. data/lib/webhookdb/connection_cache.rb +29 -8
  34. data/lib/webhookdb/customer.rb +2 -2
  35. data/lib/webhookdb/database_document.rb +1 -1
  36. data/lib/webhookdb/db_adapter/default_sql.rb +1 -14
  37. data/lib/webhookdb/db_adapter/partition.rb +14 -0
  38. data/lib/webhookdb/db_adapter/partitioning.rb +8 -0
  39. data/lib/webhookdb/db_adapter/pg.rb +77 -5
  40. data/lib/webhookdb/db_adapter/snowflake.rb +15 -6
  41. data/lib/webhookdb/db_adapter.rb +24 -2
  42. data/lib/webhookdb/fixtures/logged_webhooks.rb +4 -0
  43. data/lib/webhookdb/fixtures/organization_error_handlers.rb +20 -0
  44. data/lib/webhookdb/http.rb +29 -15
  45. data/lib/webhookdb/icalendar.rb +30 -9
  46. data/lib/webhookdb/jobs/amigo_test_jobs.rb +1 -1
  47. data/lib/webhookdb/jobs/backfill.rb +21 -25
  48. data/lib/webhookdb/jobs/create_mirror_table.rb +3 -4
  49. data/lib/webhookdb/jobs/deprecated_jobs.rb +2 -0
  50. data/lib/webhookdb/jobs/emailer.rb +2 -1
  51. data/lib/webhookdb/jobs/front_signalwire_message_channel_sync_inbound.rb +15 -0
  52. data/lib/webhookdb/jobs/icalendar_delete_stale_cancelled_events.rb +7 -2
  53. data/lib/webhookdb/jobs/icalendar_enqueue_syncs.rb +74 -11
  54. data/lib/webhookdb/jobs/icalendar_enqueue_syncs_for_urls.rb +22 -0
  55. data/lib/webhookdb/jobs/icalendar_sync.rb +21 -9
  56. data/lib/webhookdb/jobs/increase_event_handler.rb +3 -2
  57. data/lib/webhookdb/jobs/logged_webhooks_replay.rb +5 -3
  58. data/lib/webhookdb/jobs/message_dispatched.rb +1 -0
  59. data/lib/webhookdb/jobs/model_event_system_log_tracker.rb +7 -0
  60. data/lib/webhookdb/jobs/monitor_metrics.rb +1 -1
  61. data/lib/webhookdb/jobs/organization_database_migration_notify.rb +32 -0
  62. data/lib/webhookdb/jobs/organization_database_migration_run.rb +4 -6
  63. data/lib/webhookdb/jobs/organization_error_handler_dispatch.rb +26 -0
  64. data/lib/webhookdb/jobs/prepare_database_connections.rb +1 -0
  65. data/lib/webhookdb/jobs/process_webhook.rb +11 -12
  66. data/lib/webhookdb/jobs/renew_watch_channel.rb +7 -10
  67. data/lib/webhookdb/jobs/replication_migration.rb +5 -2
  68. data/lib/webhookdb/jobs/reset_code_create_dispatch.rb +1 -2
  69. data/lib/webhookdb/jobs/scheduled_backfills.rb +2 -2
  70. data/lib/webhookdb/jobs/send_invite.rb +3 -2
  71. data/lib/webhookdb/jobs/send_test_webhook.rb +1 -3
  72. data/lib/webhookdb/jobs/send_webhook.rb +4 -5
  73. data/lib/webhookdb/jobs/stale_row_deleter.rb +31 -0
  74. data/lib/webhookdb/jobs/sync_target_enqueue_scheduled.rb +3 -0
  75. data/lib/webhookdb/jobs/sync_target_run_sync.rb +9 -15
  76. data/lib/webhookdb/jobs/webhook_subscription_delivery_event.rb +5 -8
  77. data/lib/webhookdb/liquid/expose.rb +1 -1
  78. data/lib/webhookdb/liquid/filters.rb +1 -1
  79. data/lib/webhookdb/liquid/partial.rb +2 -2
  80. data/lib/webhookdb/logged_webhook/resilient.rb +3 -3
  81. data/lib/webhookdb/logged_webhook.rb +16 -2
  82. data/lib/webhookdb/message/email_transport.rb +1 -1
  83. data/lib/webhookdb/message.rb +2 -2
  84. data/lib/webhookdb/messages/error_generic_backfill.rb +2 -0
  85. data/lib/webhookdb/messages/error_icalendar_fetch.rb +2 -0
  86. data/lib/webhookdb/messages/error_signalwire_send_sms.rb +2 -0
  87. data/lib/webhookdb/organization/alerting.rb +50 -4
  88. data/lib/webhookdb/organization/database_migration.rb +1 -1
  89. data/lib/webhookdb/organization/db_builder.rb +4 -3
  90. data/lib/webhookdb/organization/error_handler.rb +141 -0
  91. data/lib/webhookdb/organization.rb +62 -9
  92. data/lib/webhookdb/postgres/model_utilities.rb +2 -0
  93. data/lib/webhookdb/postgres.rb +1 -3
  94. data/lib/webhookdb/replicator/base.rb +136 -29
  95. data/lib/webhookdb/replicator/base_stale_row_deleter.rb +165 -0
  96. data/lib/webhookdb/replicator/email_octopus_contact_v1.rb +0 -1
  97. data/lib/webhookdb/replicator/fake.rb +100 -88
  98. data/lib/webhookdb/replicator/front_signalwire_message_channel_app_v1.rb +105 -44
  99. data/lib/webhookdb/replicator/github_repo_v1_mixin.rb +17 -0
  100. data/lib/webhookdb/replicator/icalendar_calendar_v1.rb +144 -23
  101. data/lib/webhookdb/replicator/icalendar_event_v1.rb +20 -44
  102. data/lib/webhookdb/replicator/icalendar_event_v1_partitioned.rb +33 -0
  103. data/lib/webhookdb/replicator/intercom_contact_v1.rb +1 -0
  104. data/lib/webhookdb/replicator/intercom_conversation_v1.rb +1 -0
  105. data/lib/webhookdb/replicator/intercom_v1_mixin.rb +24 -2
  106. data/lib/webhookdb/replicator/partitionable_mixin.rb +116 -0
  107. data/lib/webhookdb/replicator/shopify_v1_mixin.rb +1 -1
  108. data/lib/webhookdb/replicator/signalwire_message_v1.rb +1 -2
  109. data/lib/webhookdb/replicator/sponsy_v1_mixin.rb +1 -1
  110. data/lib/webhookdb/replicator/transistor_episode_stats_v1.rb +0 -1
  111. data/lib/webhookdb/replicator.rb +4 -1
  112. data/lib/webhookdb/service/helpers.rb +4 -0
  113. data/lib/webhookdb/service/middleware.rb +6 -2
  114. data/lib/webhookdb/service_integration.rb +5 -0
  115. data/lib/webhookdb/signalwire.rb +1 -1
  116. data/lib/webhookdb/spec_helpers/async.rb +0 -4
  117. data/lib/webhookdb/spec_helpers/sentry.rb +32 -0
  118. data/lib/webhookdb/spec_helpers/shared_examples_for_replicators.rb +87 -1
  119. data/lib/webhookdb/spec_helpers.rb +1 -0
  120. data/lib/webhookdb/sync_target.rb +195 -29
  121. data/lib/webhookdb/tasks/admin.rb +1 -1
  122. data/lib/webhookdb/tasks/annotate.rb +1 -1
  123. data/lib/webhookdb/tasks/db.rb +13 -1
  124. data/lib/webhookdb/tasks/docs.rb +1 -1
  125. data/lib/webhookdb/tasks/fixture.rb +1 -1
  126. data/lib/webhookdb/tasks/message.rb +1 -1
  127. data/lib/webhookdb/tasks/regress.rb +1 -1
  128. data/lib/webhookdb/tasks/release.rb +1 -1
  129. data/lib/webhookdb/tasks/sidekiq.rb +1 -1
  130. data/lib/webhookdb/tasks/specs.rb +1 -1
  131. data/lib/webhookdb/version.rb +1 -1
  132. data/lib/webhookdb/webhook_subscription.rb +2 -3
  133. data/lib/webhookdb.rb +3 -1
  134. metadata +88 -54
  135. data/lib/webhookdb/jobs/organization_database_migration_notify_finished.rb +0 -21
  136. data/lib/webhookdb/jobs/organization_database_migration_notify_started.rb +0 -21
@@ -40,7 +40,6 @@ class Webhookdb::Replicator::TransistorEpisodeStatsV1 < Webhookdb::Replicator::B
40
40
  :compound_identity,
41
41
  TEXT,
42
42
  data_key: "<compound key, see converter>",
43
- index: true,
44
43
  optional: true,
45
44
  converter: CONV_REMOTE_KEY,
46
45
  )
@@ -21,6 +21,9 @@ class Webhookdb::Replicator
21
21
  # Usually this is due to a missing dependency.
22
22
  class CredentialsMissing < Webhookdb::WebhookdbError; end
23
23
 
24
+ # Raised when the columns or indices for a replicator are invalid.
25
+ class BrokenSpecification < Webhookdb::WebhookdbError; end
26
+
24
27
  # Statically describe a replicator.
25
28
  class Descriptor < Webhookdb::TypedStruct
26
29
  # @!attribute name
@@ -142,7 +145,7 @@ class Webhookdb::Replicator
142
145
  end
143
146
 
144
147
  class IndexSpec < Webhookdb::TypedStruct
145
- attr_reader :columns, :where
148
+ attr_reader :columns, :where, :identifier
146
149
  end
147
150
 
148
151
  class << self
@@ -16,6 +16,10 @@ module Webhookdb::Service::Helpers
16
16
  return Webhookdb::Service.logger
17
17
  end
18
18
 
19
+ def set_request_tags(tags)
20
+ Webhookdb::Service::Middleware::RequestLogger.set_request_tags(tags)
21
+ end
22
+
19
23
  # Return the currently-authenticated user,
20
24
  # or respond with a 401 if there is no authenticated user.
21
25
  def current_customer
@@ -128,12 +128,16 @@ module Webhookdb::Service::Middleware
128
128
  def request_tags(env)
129
129
  tags = super
130
130
  begin
131
- tags[:customer_id] = env["warden"].user(:customer)&.id || 0
131
+ c = env["warden"].user(:customer)
132
132
  rescue Sequel::DatabaseError
133
133
  # If we cant hit the database, ignore this for now.
134
134
  # We run this code on all code paths, including those that don't need the customer,
135
135
  # and we want those to run even if the DB is down (like health checks, for example).
136
- nil
136
+ c = nil
137
+ end
138
+ if c
139
+ tags[:customer_id] = c.id || 0
140
+ tags[:customer] = c.email
137
141
  end
138
142
  return tags
139
143
  end
@@ -342,6 +342,11 @@ class Webhookdb::ServiceIntegration < Webhookdb::Postgres::Model(:service_integr
342
342
  # @!attribute skip_webhook_verification
343
343
  # @return [Boolean] Set this to disable webhook verification on this integration.
344
344
  # Useful when replaying logged webhooks.
345
+
346
+ # @!attribute partition_value
347
+ # @return [Integer] Value to control partitioning. For replicators that use hash partitioning,
348
+ # this defines the number of partitions. For other partition types, like range,
349
+ # the meaning of this value depends on the replicator itself.
345
350
  end
346
351
 
347
352
  # Table: service_integrations
@@ -9,7 +9,7 @@ module Webhookdb::Signalwire
9
9
 
10
10
  configurable(:signalwire) do
11
11
  setting :http_timeout, 30
12
- setting :sms_allowlist, [], convert: ->(s) { s.split }
12
+ setting :sms_allowlist, [], convert: lambda(&:split)
13
13
  end
14
14
 
15
15
  def self.send_sms(from:, to:, body:, project_id:, **kw)
@@ -22,10 +22,6 @@ module Webhookdb::SpecHelpers::Async
22
22
  Webhookdb::Slack.http_client = Webhookdb::Slack::NoOpHttpClient.new
23
23
  Webhookdb::Slack.suppress_all = false
24
24
  end
25
- if example.metadata[:sentry]
26
- Webhookdb::Sentry.dsn = "http://public:secret@not-really-sentry.nope/someproject"
27
- Webhookdb::Sentry.run_after_configured_hooks
28
- end
29
25
  end
30
26
 
31
27
  context.after(:each) do |example|
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "webhookdb/spec_helpers"
4
+ require "webhookdb/sentry"
5
+
6
+ module Webhookdb::SpecHelpers::Sentry
7
+ def self.included(context)
8
+ context.before(:each) do |example|
9
+ if example.metadata[:sentry]
10
+ # We need to fake doing what Sentry would be doing for initialization,
11
+ # so we can assert it has the right data in its scope.
12
+ Webhookdb::Sentry.dsn = "https://public:secret@test-sentry.webhookdb.com/whdb"
13
+ hub = Sentry::Hub.new(
14
+ Sentry::Client.new(Sentry::Configuration.new),
15
+ Sentry::Scope.new,
16
+ )
17
+ expect(Sentry).to_not be_initialized
18
+ Sentry.instance_variable_set(:@main_hub, hub)
19
+ expect(Sentry).to be_initialized
20
+ end
21
+ end
22
+
23
+ context.after(:each) do |example|
24
+ if example.metadata[:sentry]
25
+ Webhookdb::Sentry.reset_configuration
26
+ expect(Sentry).to_not be_initialized
27
+ end
28
+ end
29
+
30
+ super
31
+ end
32
+ end
@@ -767,7 +767,7 @@ RSpec.shared_examples "a replicator that alerts on backfill auth errors" do
767
767
  sint.organization.remove_related_database
768
768
  end
769
769
 
770
- it "dispatches an alert and returns true for handled errors" do
770
+ it "dispatches an alert and returns true for handled errors (using default alert)" do
771
771
  create_all_dependencies(sint)
772
772
  setup_dependencies(sint, insert_required_data_callback)
773
773
  Webhookdb::Fixtures.organization_membership.org(sint.organization).verified.admin.create
@@ -782,6 +782,21 @@ RSpec.shared_examples "a replicator that alerts on backfill auth errors" do
782
782
  )
783
783
  end
784
784
 
785
+ it "dispatches an alert and returns true for handled errors (using error handler)" do
786
+ create_all_dependencies(sint)
787
+ setup_dependencies(sint, insert_required_data_callback)
788
+ Webhookdb::Fixtures.organization_membership.org(sint.organization).verified.admin.create
789
+ req = stub_service_request
790
+ eh = Webhookdb::Fixtures.organization_error_handler(organization: sint.organization).create
791
+ error_handle_req = stub_request(:post, eh.url).and_return(status: 204)
792
+ handled_responses.each { |(m, arg)| req.send(m, arg) }
793
+ handled_responses.count.times do
794
+ backfill(sint)
795
+ end
796
+ expect(req).to have_been_made.times(handled_responses.count)
797
+ expect(error_handle_req).to have_been_made.times(handled_responses.count)
798
+ end
799
+
785
800
  it "does not dispatch an alert, and raises the original error, if unhandled" do
786
801
  create_all_dependencies(sint)
787
802
  setup_dependencies(sint, insert_required_data_callback)
@@ -1002,3 +1017,74 @@ RSpec.shared_examples "a replicator backfilling against the table of its depende
1002
1017
  expect(svc.readonly_dataset(&:all)).to have_length(3)
1003
1018
  end
1004
1019
  end
1020
+
1021
+ # These shared examples test partitioning
1022
+
1023
+ RSpec.shared_examples "a replicator that supports hash partitioning" do
1024
+ let(:service_name) { described_class.descriptor.name }
1025
+ let(:partitions) { 4 }
1026
+ let(:sint) { Webhookdb::Fixtures.service_integration.create(service_name:, partition_value: partitions) }
1027
+ let(:svc) { Webhookdb::Replicator.create(sint) }
1028
+ Webhookdb::SpecHelpers::Whdb.setup_upsert_webhook_example(self)
1029
+
1030
+ before(:each) do
1031
+ sint.organization.prepare_database_connections
1032
+ end
1033
+
1034
+ after(:each) do
1035
+ sint.organization.remove_related_database
1036
+ end
1037
+
1038
+ def body(_i) = raise NotImplementedError
1039
+
1040
+ it "creates and uses the specified number of hash partitions" do
1041
+ expect(svc).to be_partition
1042
+ expect(svc.partitioning).to be_a(Webhookdb::DBAdapter::Partitioning)
1043
+ m = svc.create_table_modification
1044
+ expect(m.transaction_statements).to include(
1045
+ match(/CREATE TABLE .*_0 PARTITION OF .* FOR VALUES WITH \(MODULUS \d, REMAINDER 0\)/),
1046
+ match(/CREATE TABLE .*_1 PARTITION OF .* FOR VALUES WITH \(MODULUS \d, REMAINDER 1\)/),
1047
+ match(/CREATE TABLE .*_2 PARTITION OF .* FOR VALUES WITH \(MODULUS \d, REMAINDER 2\)/),
1048
+ match(/CREATE TABLE .*_3 PARTITION OF .* FOR VALUES WITH \(MODULUS \d, REMAINDER 3\)/),
1049
+ )
1050
+ expect { svc.create_table }.to_not raise_error
1051
+ rowcount = partitions + 2 # Make sure we hit all the remainders and extra
1052
+ Array.new(2) do
1053
+ (0...rowcount).each do |i|
1054
+ upsert_webhook(svc, body: body(i))
1055
+ end
1056
+ end
1057
+ svc.readonly_dataset do |ds|
1058
+ expect(ds.all).to have_length(rowcount)
1059
+ end
1060
+ end
1061
+ end
1062
+
1063
+ RSpec.shared_examples "a replicator that supports range partitioning" do
1064
+ let(:service_name) { described_class.descriptor.name }
1065
+ let(:sint) { Webhookdb::Fixtures.service_integration.create(service_name:) }
1066
+ let(:svc) { Webhookdb::Replicator.create(sint) }
1067
+ Webhookdb::SpecHelpers::Whdb.setup_upsert_webhook_example(self)
1068
+
1069
+ before(:each) do
1070
+ sint.organization.prepare_database_connections
1071
+ end
1072
+
1073
+ after(:each) do
1074
+ sint.organization.remove_related_database
1075
+ end
1076
+
1077
+ def body(_i) = raise NotImplementedError
1078
+
1079
+ it "prepares partitions" do
1080
+ expect(svc).to be_partition
1081
+ expect(svc.partitioning).to be_a(Webhookdb::DBAdapter::Partitioning)
1082
+ m = svc.create_table_modification
1083
+ expect(m.transaction_statements).to include(
1084
+ match(/PARTITION BY RANGE \(/),
1085
+ )
1086
+ expect { svc.create_table }.to_not raise_error
1087
+ # Awaiting implementation
1088
+ expect { upsert_webhook(svc, body: body(1)) }.to raise_error(/no partition of relation/)
1089
+ end
1090
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "oj"
4
+ require "rspec"
4
5
  require "webhookdb"
5
6
 
6
7
  RSpec::Matchers.define_negated_matcher(:exclude, :include)
@@ -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
  #
@@ -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"
@@ -170,13 +180,29 @@ class Webhookdb::SyncTarget < Webhookdb::Postgres::Model(:sync_targets)
170
180
  timeout: HTTP_VERIFY_TIMEOUT,
171
181
  follow_redirects: true,
172
182
  )
173
- rescue Timeout::Error => e
174
- raise InvalidConnection, "POST to #{cleanurl} timed out: #{e.message}"
175
- rescue Webhookdb::Http::Error => e
176
- 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
177
187
  end
178
188
  end
179
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
+
180
206
  def next_scheduled_sync(now:)
181
207
  return self.next_sync(self.period_seconds, now)
182
208
  end
@@ -202,6 +228,13 @@ class Webhookdb::SyncTarget < Webhookdb::Postgres::Model(:sync_targets)
202
228
  return RAND.rand(1..max_jitter)
203
229
  end
204
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
+
205
238
  # Running a sync involves some work we always do (export, transform),
206
239
  # and then work that varies per-adapter (load).
207
240
  #
@@ -241,6 +274,7 @@ class Webhookdb::SyncTarget < Webhookdb::Postgres::Model(:sync_targets)
241
274
  # since the session will be ended.
242
275
  Webhookdb::Dbutil.borrow_conn(Webhookdb::Postgres::Model.uri) do |db|
243
276
  self.advisory_lock(db).with_lock? do
277
+ self.logger.info "starting_sync"
244
278
  routine = if self.connection_url.start_with?("https://", "http://")
245
279
  # Note that http links are not secure and should only be used for development purposes
246
280
  HttpRoutine.new(now, self)
@@ -263,6 +297,16 @@ class Webhookdb::SyncTarget < Webhookdb::Postgres::Model(:sync_targets)
263
297
  return displaysafe_url(self.connection_url)
264
298
  end
265
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
+
266
310
  # @return [String]
267
311
  def associated_type
268
312
  # Eventually we need to support orgs
@@ -286,6 +330,39 @@ class Webhookdb::SyncTarget < Webhookdb::Postgres::Model(:sync_targets)
286
330
  return "#{schema_name}.#{table_name}"
287
331
  end
288
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
+
289
366
  # @return [Webhookdb::Organization]
290
367
  def organization
291
368
  return self.service_integration.organization
@@ -343,40 +420,112 @@ class Webhookdb::SyncTarget < Webhookdb::Postgres::Model(:sync_targets)
343
420
  end
344
421
  end
345
422
 
346
- def record(last_synced_at)
347
- self.sync_target.update(last_synced_at:)
423
+ def perform_db_op(&)
424
+ yield
348
425
  rescue Sequel::NoExistingObject => e
349
426
  raise Webhookdb::SyncTarget::Deleted, e
350
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
351
452
  end
352
453
 
353
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
+
354
467
  def run
468
+ timeout_at = Time.now + Webhookdb::SyncTarget.max_transaction_seconds
355
469
  page_size = self.sync_target.page_size
470
+ sync_result = :complete
356
471
  self.dataset_to_sync do |ds|
357
472
  chunk = []
358
- 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|
359
474
  chunk << row
360
- 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
361
485
  end
362
486
  self._flush_http_chunk(chunk) unless chunk.empty?
363
- # We should save 'now' as the timestamp, rather than the last updated row.
364
- # This is important because other we'd keep trying to sync the last row synced.
365
- 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
366
505
  end
367
- rescue Webhookdb::Http::Error, Errno::ECONNRESET, Net::ReadTimeout, Net::OpenTimeout, OpenSSL::SSL::SSLError => e
368
- # 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.
369
509
  # We already committed the last page that was successful,
370
510
  # so we can just stop syncing at this point to try again later.
371
-
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
372
516
  # Don't spam our logs with downstream errors
373
517
  idem_key = "sync_target_http_error-#{self.sync_target.id}-#{e.class.name}"
374
518
  Webhookdb::Idempotency.every(1.hour).in_memory.under_key(idem_key) do
375
- 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)
376
520
  end
377
521
  end
378
522
 
379
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
380
529
  sint = self.sync_target.service_integration
381
530
  body = {
382
531
  rows: chunk,
@@ -385,19 +534,34 @@ class Webhookdb::SyncTarget < Webhookdb::Postgres::Model(:sync_targets)
385
534
  table: sint.table_name,
386
535
  sync_timestamp: self.now,
387
536
  }
388
- cleanurl, authparams = Webhookdb::Http.extract_url_auth(self.sync_target.connection_url)
389
- Webhookdb::Http.post(
390
- cleanurl,
391
- body,
392
- timeout: sint.organization.sync_target_timeout,
393
- logger: self.sync_target.logger,
394
- basic_auth: authparams,
395
- )
396
- latest_ts = chunk.last.fetch(self.replicator.timestamp_column.name)
397
- # The client committed the sync page we sent. Record it in case of a future error,
398
- # so we don't re-send the same page.
399
- self.record(latest_ts)
400
- 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
401
565
  end
402
566
  end
403
567
 
@@ -447,7 +611,9 @@ class Webhookdb::SyncTarget < Webhookdb::Postgres::Model(:sync_targets)
447
611
  schema_expr = schema_lines.join(";\n") + ";"
448
612
  if schema_expr != self.sync_target.last_applied_schema
449
613
  adapter_conn.execute(schema_expr)
450
- 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
451
617
  end
452
618
  tempfile = Tempfile.new("whdbsyncout-#{self.sync_target.id}")
453
619
  begin
@@ -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