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 +4 -4
- data/README.md +40 -0
- data/app/services/worker_plugins/add_query.rb +36 -19
- data/app/services/worker_plugins/application_service.rb +18 -0
- data/app/services/worker_plugins/delete_old_workplaces.rb +48 -0
- data/app/services/worker_plugins/query_links_status.rb +44 -5
- data/app/services/worker_plugins/remove_query.rb +12 -8
- data/app/services/worker_plugins/select_column_with_type_cast.rb +14 -4
- data/app/services/worker_plugins/switch_query.rb +23 -13
- data/lib/worker_plugins/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 343e7440c6234a58f501c96d553f019c653c7eb32c8dc07847d4f02d9ee8020b
|
|
4
|
+
data.tar.gz: 5100e6c819ee4f818082e87ef515e7362523b7e1c5b0c25ca386741e07e9d8ec
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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.
|
|
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 ||=
|
|
37
|
+
@primary_key ||= model_class.primary_key
|
|
44
38
|
end
|
|
45
39
|
|
|
46
40
|
def resources_to_add
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8
|
-
succeed!(destroyed:, mode: :destroyed)
|
|
5
|
+
succeed!(affected_count: links_scope.delete_all)
|
|
9
6
|
end
|
|
10
7
|
|
|
11
|
-
def
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
63
|
+
!numeric_type?(column_to_select.type) && !numeric_type?(column_to_compare_with.type)
|
|
64
|
+
end
|
|
56
65
|
|
|
57
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
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.
|
|
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-
|
|
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
|