worker_plugins 0.0.15 → 0.0.17

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: 46a8d25556ad9d41f62307376bf457a790ab3e67e934678cac0ad9b754112e8e
4
- data.tar.gz: 2163df96c0f85a9102f1694fbeda59f06f70c296924e0a9dd9320a0b239e0f15
3
+ metadata.gz: 0724aebdce87819ebafc787c475284e25e7a6ee31837040e8532b0c400c95db3
4
+ data.tar.gz: dc8cd4edddcdce80d22c2e3c10990f4fa966f112fe67f01c1bd58443c313079b
5
5
  SHA512:
6
- metadata.gz: ef92d06893d7b9a1e56ade70c4b78432c0cd3b04544db92c5827865883d97b9912fc392930e53d26324c6bb6dbec23481e101fb4ba0218c51a2e52ef1634c497
7
- data.tar.gz: 24c4313891e0864b95695af807eca5586739c997039cc3bb54b82050964af2dbd92806c90b3e7077d8acdd93f5b636dd306e6300331961b41ef71d424f8a099a
6
+ metadata.gz: f58a6c6a65122966a57b948f78758761825e2eed97bb13fb4277b627ec4db11bade6856b2baf23e1f1056fb7169d545bfeaf6d565bbbddbb027bfb61c9d16f26
7
+ data.tar.gz: a4fb5385013ab116c856bd5b5986ce04bcccbfb18b342edb41f463d652e664ecdd0b01567e75cf321f0299d64d8adfff5b37c9d2ef2ed75f81da31286975884a
data/README.md CHANGED
@@ -27,6 +27,59 @@ 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
+
70
+ ## Scheduled cleanup of orphan links
71
+
72
+ `WorkerPlugins::DeleteOrphanLinks` removes `worker_plugins_workplace_links` whose target row no longer exists — i.e. links that point at a resource that was destroyed without the link being cleaned up alongside. Run it periodically from a background job to keep the links table consistent and keep probes like `QueryLinksStatus#checked_count` honest.
73
+
74
+ ```ruby
75
+ result = WorkerPlugins::DeleteOrphanLinks.execute!
76
+ # => {deleted_count: <N>}
77
+ ```
78
+
79
+ Links whose `resource_type` doesn't resolve to a Ruby class (e.g. a model was renamed or removed) are left alone — cleaning those up requires human judgement.
80
+
81
+ Schedule the same way as `DeleteOldWorkplaces` — typically once a day off-hours.
82
+
30
83
  ## Release
31
84
 
32
85
  Run the release task from a clean worktree:
@@ -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
@@ -0,0 +1,39 @@
1
+ class WorkerPlugins::DeleteOrphanLinks < WorkerPlugins::ApplicationService
2
+ # Deletes `worker_plugins_workplace_links` whose target row no longer
3
+ # exists — i.e. links pointing at a resource that was destroyed without
4
+ # the link being cleaned up alongside. Intended to be scheduled by the
5
+ # consumer application from a background job.
6
+ #
7
+ # Links whose `resource_type` doesn't resolve to a Ruby class (e.g. a
8
+ # model was renamed or removed) are left alone — cleaning those up is
9
+ # a separate concern that requires human judgement.
10
+ def perform
11
+ deleted_count = distinct_resource_types.sum do |resource_type|
12
+ delete_orphans_for(resource_type)
13
+ end
14
+
15
+ succeed!(deleted_count:)
16
+ end
17
+
18
+ def distinct_resource_types
19
+ WorkerPlugins::WorkplaceLink.distinct.pluck(:resource_type)
20
+ end
21
+
22
+ def delete_orphans_for(resource_type)
23
+ model_class = resource_type.safe_constantize
24
+ return 0 unless model_class
25
+
26
+ WorkerPlugins::WorkplaceLink
27
+ .where(resource_type:)
28
+ .where.not(resource_id: live_ids_query(model_class))
29
+ .delete_all
30
+ end
31
+
32
+ def live_ids_query(model_class)
33
+ WorkerPlugins::SelectColumnWithTypeCast.execute!(
34
+ column_name_to_select: model_class.primary_key,
35
+ column_to_compare_with: WorkerPlugins::WorkplaceLink.column_for_attribute(:resource_id),
36
+ query: model_class.all
37
+ )
38
+ end
39
+ 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(query_count)
11
7
 
12
8
  succeed!(
13
9
  all_checked: query_count == checked_count,
@@ -17,6 +13,29 @@ class WorkerPlugins::QueryLinksStatus < WorkerPlugins::ApplicationService
17
13
  )
18
14
  end
19
15
 
16
+ def count_linked_rows(query_count)
17
+ base_scope = workplace.workplace_links.where(resource_type: query.klass.name)
18
+
19
+ # Fast path for unscoped queries: a plain index-only COUNT against the
20
+ # `(workplace_id, resource_type, resource_id)` composite index resolves in
21
+ # ~50 ms even for workplaces with hundreds of thousands of links. Joining
22
+ # back to the target table to exclude orphaned links (whose resource row
23
+ # has since been destroyed) would take 10+ seconds on the same data
24
+ # regardless of which shape we pick — the DB still has to cross-reference
25
+ # every link against the target's primary key. Instead we clamp the raw
26
+ # count to `query_count`; `WorkerPlugins::DeleteOrphanLinks` (scheduled
27
+ # daily by consumers) keeps the orphan count at zero so the raw count
28
+ # equals the live-linked count in practice. When orphans do briefly exist
29
+ # between cleanup runs, clamping bounds the over-count at the query's
30
+ # total — `all_checked` / `some_checked` stay correct because they're
31
+ # computed off the clamped value.
32
+ if relation_unscoped?(query)
33
+ [base_scope.count, query_count].min
34
+ else
35
+ base_scope.where(resource_id: query_with_selected_ids).count
36
+ end
37
+ end
38
+
20
39
  def query_with_selected_ids
21
40
  WorkerPlugins::SelectColumnWithTypeCast.execute!(
22
41
  column_name_to_select: query.klass.primary_key,
@@ -7,33 +7,17 @@ class WorkerPlugins::RemoveQuery < WorkerPlugins::ApplicationService
7
7
 
8
8
  def links_scope
9
9
  scope = workplace.workplace_links.where(resource_type: model_class.name)
10
- return scope if unscoped_query?
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)
11
17
 
12
18
  scope.where(resource_id: query_with_selected_ids)
13
19
  end
14
20
 
15
- # If the caller's query has no meaningful scoping applied, the `resource_id
16
- # IN (SELECT ... FROM <target_table>)` subquery would simply materialize
17
- # every row of the target model — for 340k+ users that's a full-table scan
18
- # with no semantic effect other than preserving orphaned links. The
19
- # `resource_type = ?` filter alone is enough to pin the DELETE to this
20
- # workplace's links of the given type, so we short-circuit the subquery in
21
- # that case. Orphaned links (whose resource row has since been deleted) are
22
- # deleted alongside live ones, which matches caller intent ("remove
23
- # everything matching the query") and is the correct thing to do with
24
- # dead references anyway.
25
- def unscoped_query?
26
- @query.where_clause.empty? &&
27
- @query.joins_values.empty? &&
28
- @query.left_outer_joins_values.empty? &&
29
- @query.group_values.empty? &&
30
- @query.having_clause.empty? &&
31
- @query.limit_value.nil? &&
32
- @query.offset_value.nil? &&
33
- @query.from_clause.value.nil? &&
34
- @query.with_values.empty?
35
- end
36
-
37
21
  def model_class
38
22
  query.klass
39
23
  end
@@ -1,3 +1,3 @@
1
1
  module WorkerPlugins
2
- VERSION = "0.0.15".freeze
2
+ VERSION = "0.0.17".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.15
4
+ version: 0.0.17
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-23 00:00:00.000000000 Z
11
+ date: 2026-04-25 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,8 @@ 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
30
+ - app/services/worker_plugins/delete_orphan_links.rb
29
31
  - app/services/worker_plugins/query_links_status.rb
30
32
  - app/services/worker_plugins/remove_query.rb
31
33
  - app/services/worker_plugins/select_column_with_type_cast.rb