worker_plugins 0.0.14 → 0.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 13eab7a67becfdbd1809b1930d1314a86c6c0cb217d39ae19181e1dec7453976
4
- data.tar.gz: 65b838cc21313b78f92d43b85a95adba991f50360d05be8ec11a2050b4b75133
3
+ metadata.gz: 343e7440c6234a58f501c96d553f019c653c7eb32c8dc07847d4f02d9ee8020b
4
+ data.tar.gz: 5100e6c819ee4f818082e87ef515e7362523b7e1c5b0c25ca386741e07e9d8ec
5
5
  SHA512:
6
- metadata.gz: 46ebc5d54657090702d0280e72c5f0d863effcbe7ea478892f492b81bfba829a478220fddbde9f2c9df9e7dc7efc07fa3ec5518aa6b455de81be41c7297e72d9
7
- data.tar.gz: 4b76bdfa275a1641d782ef84bc96e5e6658d866fb4aef3831f82986dabea510a4e61becb19179bf6a80535181b2492bd939346da8e53c7c110fd36940f181cf7
6
+ metadata.gz: a658e1f90e8f9c33503e526b98a59ef578e9a366dc388d79abbf8efec602c60fe5c920afa4e0772a113de5e47f4d71eba4bd7346dfc38dcbefd31ac7808deca1
7
+ data.tar.gz: c73827b981b2d50abc9f5bd3c388645fa382f0d052188f3ff85f5f3e0e8ac1d47e62ba01a2fa73a2d1e6d4c78475870f36cbb520723154491bffe44245536db6
data/README.md CHANGED
@@ -27,6 +27,46 @@ Optimally loop over resources on a workspace:
27
27
  workspace.each_resource(types: ['User']) do |user|
28
28
  ```
29
29
 
30
+ ## Scheduled cleanup of unused workplaces
31
+
32
+ `WorkerPlugins::DeleteOldWorkplaces` removes workplaces that haven't seen activity in a given window — both the workplace row's `updated_at` is older than the cutoff *and* no link on it has been created or updated since. Deletion runs in batches via raw `delete_all` to skip per-row callbacks.
33
+
34
+ ```ruby
35
+ result = WorkerPlugins::DeleteOldWorkplaces.execute!(older_than: 2.months)
36
+ # => {workplaces_deleted: <N>, links_deleted: <M>}
37
+ ```
38
+
39
+ Options:
40
+
41
+ - `older_than:` (required) — any object that responds to `.ago` (typically an `ActiveSupport::Duration` like `2.months` or `30.days`). The service computes the cutoff at call time.
42
+ - `batch_size:` (default `1000`) — how many stale workplaces to delete per round-trip.
43
+
44
+ The gem does not register a scheduler of its own. Wire the service into your application's background queue. Example with `sidekiq-scheduler`:
45
+
46
+ ```ruby
47
+ # config/sidekiq.yml
48
+ :scheduler:
49
+ :schedule:
50
+ DeleteOldWorkplaces:
51
+ cron: "0 40 3 * * *" # daily at 03:40 local time
52
+ args: ["WorkerPlugins::DeleteOldWorkplaces", {"older_than": "2.months"}]
53
+ class: ServiceScheduler # or whatever your project's service-dispatching worker is called
54
+ queue: low_priority
55
+ ```
56
+
57
+ If your `ServiceScheduler` only accepts YAML-serializable arguments, wrap the call in a thin application-side service:
58
+
59
+ ```ruby
60
+ class Workplaces::DeleteOld < ApplicationService
61
+ def perform
62
+ WorkerPlugins::DeleteOldWorkplaces.execute!(older_than: 2.months)
63
+ succeed!
64
+ end
65
+ end
66
+ ```
67
+
68
+ and schedule `Workplaces::DeleteOld` instead.
69
+
30
70
  ## Release
31
71
 
32
72
  Run the release task from a clean worktree:
@@ -8,17 +8,11 @@ class WorkerPlugins::AddQuery < WorkerPlugins::ApplicationService
8
8
  end
9
9
 
10
10
  def perform
11
- created # Cache which are about to be created
12
- add_query_to_workplace
13
- succeed!(created:)
11
+ succeed!(affected_count: add_query_to_workplace)
14
12
  end
15
13
 
16
14
  def add_query_to_workplace
17
- WorkerPlugins::WorkplaceLink.connection.execute(sql)
18
- end
19
-
20
- def created
21
- @created ||= resources_to_add.pluck(primary_key.to_sym)
15
+ WorkerPlugins::WorkplaceLink.connection.exec_update(sql, "WorkerPlugins::AddQuery INSERT", [])
22
16
  end
23
17
 
24
18
  def ids_added_already_query
@@ -40,19 +34,25 @@ class WorkerPlugins::AddQuery < WorkerPlugins::ApplicationService
40
34
  end
41
35
 
42
36
  def primary_key
43
- @primary_key ||= resources_to_add.klass.primary_key
37
+ @primary_key ||= model_class.primary_key
44
38
  end
45
39
 
46
40
  def resources_to_add
47
- # Correlate per row with NOT EXISTS instead of NOT IN + a materialized
48
- # subquery. The old form expanded into a nested `resource_id IN (SELECT
49
- # CAST(users.id AS CHAR) FROM users)` that did a full scan of the target
50
- # table when the outer query was unfiltered 60s+ on 340k+ users. This
51
- # uses the `(workplace_id, resource_type, resource_id)` composite index
52
- # for an index seek per row.
53
- @resources_to_add ||= query
54
- .distinct
55
- .where("NOT EXISTS (#{existing_workplace_link_exists_sql})")
41
+ # The unique index on `(workplace_id, resource_type, resource_id)` lets us
42
+ # skip the `WHERE NOT EXISTS` anti-join for the common unbounded query —
43
+ # duplicates are rejected on INSERT by the dialect-specific conflict
44
+ # clause in #sql. `.distinct` still handles same-row duplicates produced
45
+ # by joins in the caller's query (e.g. `User.joins(:tasks)`).
46
+ #
47
+ # When the caller scopes with `.limit` / `.offset`, we keep the anti-join
48
+ # so already-linked rows are filtered *before* the window is applied;
49
+ # otherwise `Task.limit(100)` could insert fewer than 100 new rows when
50
+ # some of those 100 are already linked.
51
+ @resources_to_add ||= if query.limit_value || query.offset_value
52
+ query.distinct.where("NOT EXISTS (#{existing_workplace_link_exists_sql})")
53
+ else
54
+ query.distinct
55
+ end
56
56
  end
57
57
 
58
58
  def existing_workplace_link_exists_sql
@@ -106,7 +106,7 @@ class WorkerPlugins::AddQuery < WorkerPlugins::ApplicationService
106
106
 
107
107
  def sql
108
108
  @sql ||= "
109
- INSERT INTO
109
+ #{insert_clause} INTO
110
110
  worker_plugins_workplace_links
111
111
 
112
112
  (
@@ -118,6 +118,23 @@ class WorkerPlugins::AddQuery < WorkerPlugins::ApplicationService
118
118
  )
119
119
 
120
120
  #{select_sql}
121
+ #{conflict_clause}
121
122
  "
122
123
  end
124
+
125
+ def insert_clause
126
+ if mysql?
127
+ "INSERT IGNORE"
128
+ elsif sqlite?
129
+ "INSERT OR IGNORE"
130
+ else
131
+ "INSERT"
132
+ end
133
+ end
134
+
135
+ def conflict_clause
136
+ return "" unless postgres?
137
+
138
+ "ON CONFLICT (workplace_id, resource_type, resource_id) DO NOTHING"
139
+ end
123
140
  end
@@ -36,4 +36,22 @@ class WorkerPlugins::ApplicationService < ServicePattern::Service
36
36
 
37
37
  adapter_name.include?("mysql") || adapter_name.include?("trilogy")
38
38
  end
39
+
40
+ # True when a relation applies no row-narrowing scope — so filtering
41
+ # workplace links with `resource_id IN (SELECT ... FROM <target_table>)` adds
42
+ # no semantic value and just forces the database to materialize every row
43
+ # of the target model. Call sites (RemoveQuery, QueryLinksStatus) use this
44
+ # to drop the subquery and count/delete by `resource_type` alone on large
45
+ # target models (e.g. 340k+ users).
46
+ def relation_unscoped?(relation)
47
+ relation.where_clause.empty? &&
48
+ relation.joins_values.empty? &&
49
+ relation.left_outer_joins_values.empty? &&
50
+ relation.group_values.empty? &&
51
+ relation.having_clause.empty? &&
52
+ relation.limit_value.nil? &&
53
+ relation.offset_value.nil? &&
54
+ relation.from_clause.value.nil? &&
55
+ relation.with_values.empty?
56
+ end
39
57
  end
@@ -0,0 +1,48 @@
1
+ class WorkerPlugins::DeleteOldWorkplaces < WorkerPlugins::ApplicationService
2
+ arguments :older_than
3
+ argument :batch_size, default: 1_000
4
+
5
+ # Deletes workplaces that haven't seen any activity since `older_than.ago` —
6
+ # both the workplace record itself is older than the cutoff *and* none of
7
+ # its links have been created / updated since. Links on deleted workplaces
8
+ # are removed with the parent via `dependent: :destroy`, but this service
9
+ # uses raw `delete_all` in batches to skip per-row callbacks and keep
10
+ # long-running cleanup jobs cheap.
11
+ #
12
+ # Intended to be scheduled by the consumer application from a Sidekiq
13
+ # worker (or equivalent) — the gem does not register a scheduler of its
14
+ # own.
15
+ def perform
16
+ cutoff = older_than.ago
17
+ workplaces_deleted = 0
18
+ links_deleted = 0
19
+
20
+ stale_workplaces(cutoff).in_batches(of: batch_size) do |batch|
21
+ batch_ids = batch.pluck(:id)
22
+
23
+ links_deleted += WorkerPlugins::WorkplaceLink
24
+ .where(workplace_id: batch_ids)
25
+ .delete_all
26
+ workplaces_deleted += WorkerPlugins::Workplace
27
+ .where(id: batch_ids)
28
+ .delete_all
29
+ end
30
+
31
+ succeed!(workplaces_deleted:, links_deleted:)
32
+ end
33
+
34
+ def stale_workplaces(cutoff)
35
+ workplaces_table = quote_table(WorkerPlugins::Workplace.table_name)
36
+ links_table = quote_table(WorkerPlugins::WorkplaceLink.table_name)
37
+
38
+ WorkerPlugins::Workplace
39
+ .where("#{workplaces_table}.#{quote_column(:updated_at)} < ?", cutoff)
40
+ .where(<<~SQL.squish, cutoff)
41
+ NOT EXISTS (
42
+ SELECT 1 FROM #{links_table}
43
+ WHERE #{links_table}.#{quote_column(:workplace_id)} = #{workplaces_table}.#{quote_column(:id)}
44
+ AND #{links_table}.#{quote_column(:updated_at)} >= ?
45
+ )
46
+ SQL
47
+ end
48
+ end
@@ -2,12 +2,8 @@ class WorkerPlugins::QueryLinksStatus < WorkerPlugins::ApplicationService
2
2
  arguments :query, :workplace
3
3
 
4
4
  def perform
5
- checked_count = workplace
6
- .workplace_links
7
- .where(resource_type: query.klass.name, resource_id: query_with_selected_ids)
8
- .count
9
-
10
5
  query_count = query.count
6
+ checked_count = count_linked_rows
11
7
 
12
8
  succeed!(
13
9
  all_checked: query_count == checked_count,
@@ -17,6 +13,49 @@ class WorkerPlugins::QueryLinksStatus < WorkerPlugins::ApplicationService
17
13
  )
18
14
  end
19
15
 
16
+ def count_linked_rows
17
+ base_scope = workplace.workplace_links.where(resource_type: query.klass.name)
18
+
19
+ # When the query applies no scoping, the original `resource_id IN (SELECT
20
+ # DISTINCT <target_table>.id FROM <target_table>)` subquery materialized
21
+ # every row of the target model just to count — 2+ seconds on a 340k-row
22
+ # target. We drop the DISTINCT subquery and instead `INNER JOIN` the
23
+ # target table on its primary key so the composite index on links drives
24
+ # the scan and each matching link does a cheap PK probe to confirm the
25
+ # target row still exists. Orphaned links (whose target has since been
26
+ # deleted) are correctly excluded from the count, so `checked_count`
27
+ # never exceeds `query_count`.
28
+ return base_scope.joins(unscoped_target_join_sql).count if relation_unscoped?(query)
29
+
30
+ base_scope.where(resource_id: query_with_selected_ids).count
31
+ end
32
+
33
+ def unscoped_target_join_sql
34
+ target_table = quote_table(query.klass.table_name)
35
+ target_pk = "#{target_table}.#{quote_column(query.klass.primary_key)}"
36
+ resource_id_column = "#{quote_table(WorkerPlugins::WorkplaceLink.table_name)}.#{quote_column(:resource_id)}"
37
+
38
+ "INNER JOIN #{target_table} ON #{target_pk} = #{resource_id_expression_for_join(resource_id_column)}"
39
+ end
40
+
41
+ # On MySQL / MariaDB and SQLite, implicit conversion handles comparing the
42
+ # target's primary key against the VARCHAR `resource_id` column. Postgres
43
+ # is strict about types and needs an explicit cast when they differ.
44
+ def resource_id_expression_for_join(resource_id_column)
45
+ return resource_id_column unless postgres?
46
+
47
+ target_pk_type = query.klass.column_for_attribute(query.klass.primary_key).type
48
+ resource_id_type = WorkerPlugins::WorkplaceLink.column_for_attribute(:resource_id).type
49
+
50
+ return resource_id_column if target_pk_type == resource_id_type
51
+
52
+ case target_pk_type
53
+ when :uuid then "CAST(#{resource_id_column} AS UUID)"
54
+ when :integer then "CAST(#{resource_id_column} AS BIGINT)"
55
+ else resource_id_column
56
+ end
57
+ end
58
+
20
59
  def query_with_selected_ids
21
60
  WorkerPlugins::SelectColumnWithTypeCast.execute!(
22
61
  column_name_to_select: query.klass.primary_key,
@@ -1,17 +1,21 @@
1
1
  class WorkerPlugins::RemoveQuery < WorkerPlugins::ApplicationService
2
2
  arguments :query, :workplace
3
3
 
4
- attr_reader :destroyed
5
-
6
4
  def perform
7
- remove_query_from_workplace
8
- succeed!(destroyed:, mode: :destroyed)
5
+ succeed!(affected_count: links_scope.delete_all)
9
6
  end
10
7
 
11
- def remove_query_from_workplace
12
- links_query = workplace.workplace_links.where(resource_type: model_class.name, resource_id: query_with_selected_ids)
13
- @destroyed = links_query.pluck(:resource_id)
14
- links_query.delete_all
8
+ def links_scope
9
+ scope = workplace.workplace_links.where(resource_type: model_class.name)
10
+ # When the caller's query applies no scoping, the `resource_id IN (SELECT
11
+ # ... FROM <target_table>)` subquery would materialize every row of the
12
+ # target model — the `resource_type = ?` filter alone is enough. Orphaned
13
+ # links (whose resource row has since been deleted) are deleted alongside
14
+ # live ones, which matches caller intent ("remove everything matching")
15
+ # and is the correct thing to do with dead references anyway.
16
+ return scope if relation_unscoped?(@query)
17
+
18
+ scope.where(resource_id: query_with_selected_ids)
15
19
  end
16
20
 
17
21
  def model_class
@@ -46,14 +46,24 @@ class WorkerPlugins::SelectColumnWithTypeCast < WorkerPlugins::ApplicationServic
46
46
  end
47
47
 
48
48
  def same_type?
49
- column_to_select.type == column_to_compare_with.type || mysql_string_uuid_compatible_types?
49
+ return true if column_to_select.type == column_to_compare_with.type
50
+
51
+ mysql_implicit_conversion_safe?
50
52
  end
51
53
 
52
- def mysql_string_uuid_compatible_types?
54
+ # On MySQL / MariaDB, implicit conversion handles comparisons between any
55
+ # string-ish column types (VARCHAR, CHAR, BINARY, UUID, or types AR doesn't
56
+ # recognize — e.g. MariaDB's native UUID type when using an older mysql2
57
+ # adapter). The explicit CAST is only needed when one side is numeric,
58
+ # because MySQL would then force a string → number conversion that loses
59
+ # rows containing non-numeric values.
60
+ def mysql_implicit_conversion_safe?
53
61
  return false unless mysql?
54
62
 
55
- types = [column_to_select.type, column_to_compare_with.type]
63
+ !numeric_type?(column_to_select.type) && !numeric_type?(column_to_compare_with.type)
64
+ end
56
65
 
57
- types.include?(:string) && types.include?(:uuid)
66
+ def numeric_type?(type)
67
+ %i[integer decimal float bigint].include?(type)
58
68
  end
59
69
  end
@@ -2,20 +2,30 @@ class WorkerPlugins::SwitchQuery < WorkerPlugins::ApplicationService
2
2
  arguments :query, :workplace
3
3
 
4
4
  def perform
5
- add_query_service = WorkerPlugins::AddQuery.new(query:, workplace:)
6
- created = add_query_service.created
7
-
8
- if created.empty?
9
- result = WorkerPlugins::RemoveQuery.execute!(query:, workplace:)
10
- succeed!(
11
- destroyed: result.fetch(:destroyed),
12
- mode: :destroyed
13
- )
5
+ # Decide mode *before* running the insert. Deciding it from AddQuery's
6
+ # post-insert `affected_count` would make concurrent "add" toggles
7
+ # destructive: if request A's INSERT commits first, overlapping
8
+ # request B would see `affected_count == 0` from its own (no-op)
9
+ # INSERT and flip to RemoveQuery, wiping out what A just added. A
10
+ # pre-insert EXISTS probe keeps the race window small in the same
11
+ # way the previous candidate-pluck approach did, without materializing
12
+ # any ids into Ruby.
13
+ if any_unlinked_candidate?
14
+ add_result = WorkerPlugins::AddQuery.execute!(query:, workplace:)
15
+ succeed!(affected_count: add_result.fetch(:affected_count), mode: :created)
14
16
  else
15
- succeed!(
16
- created: add_query_service.tap(&:add_query_to_workplace).created,
17
- mode: :created
18
- )
17
+ remove_result = WorkerPlugins::RemoveQuery.execute!(query:, workplace:)
18
+ succeed!(affected_count: remove_result.fetch(:affected_count), mode: :destroyed)
19
19
  end
20
20
  end
21
+
22
+ def any_unlinked_candidate?
23
+ add_service = WorkerPlugins::AddQuery.new(query:, workplace:)
24
+
25
+ add_service
26
+ .query
27
+ .distinct
28
+ .where("NOT EXISTS (#{add_service.existing_workplace_link_exists_sql})")
29
+ .exists?
30
+ end
21
31
  end
@@ -1,3 +1,3 @@
1
1
  module WorkerPlugins
2
- VERSION = "0.0.14".freeze
2
+ VERSION = "0.0.16".freeze
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: worker_plugins
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.14
4
+ version: 0.0.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kasper Stöckel
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-22 00:00:00.000000000 Z
11
+ date: 2026-04-23 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Rails framework for easily choosing and creating lists of objects and
14
14
  execute plugins against them.
@@ -26,6 +26,7 @@ files:
26
26
  - app/models/worker_plugins/workplace_link.rb
27
27
  - app/services/worker_plugins/add_query.rb
28
28
  - app/services/worker_plugins/application_service.rb
29
+ - app/services/worker_plugins/delete_old_workplaces.rb
29
30
  - app/services/worker_plugins/query_links_status.rb
30
31
  - app/services/worker_plugins/remove_query.rb
31
32
  - app/services/worker_plugins/select_column_with_type_cast.rb