organizations 0.8.0 → 0.8.1
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/CHANGELOG.md +17 -0
- data/README.md +26 -14
- data/app/controllers/organizations/public_invitations_controller.rb +14 -11
- data/config/locales/en.yml +1 -0
- data/config/locales/es.yml +1 -0
- data/lib/organizations/controller_helpers.rb +8 -0
- data/lib/organizations/invitation_acceptance_failure.rb +1 -0
- data/lib/organizations/models/invitation.rb +36 -2
- data/lib/organizations/models/join_code.rb +13 -4
- data/lib/organizations/models/join_request.rb +20 -1
- data/lib/organizations/row_locks.rb +61 -0
- data/lib/organizations/version.rb +1 -1
- data/lib/organizations.rb +1 -0
- 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: e3c621c2b6bb7bd32be611858d49af9cb12cf9fa80bc0309d1227eafc819f8d8
|
|
4
|
+
data.tar.gz: c6028a9e424ab92432c44ae45bcba367d2a70eab56e3901314b1d5ff9b9b102f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 267e7126501538f4461616e2be708f1b3696a016b428135337193dc8eaf24532a446a9c651b1989610862025298cbb4b9ab38b7f1d901b0fcb3f49f68d28ec56
|
|
7
|
+
data.tar.gz: 98d2dbd1b5369c247c6b8147c7472f0b53e74aa25c0c000b8458834ddff6e81f0ec6f3e599611f2ba8ef10b2dd88ddb5775fe15816cef13d57fb48a393ddce6a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.8.1] - 2026-10-25
|
|
4
|
+
|
|
5
|
+
**The joins take the lock order of the destroys.** A patch release with one fix. No migration is needed.
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **A join could deadlock with a deletion on PostgreSQL.** 0.8.0 made every destroy lock the user row, then the organization rows, then their child rows. Several joins locked their own row first, and the organization row (or a user row) only later, when they wrote. So a join and a deletion could each hold a row that the other waited for. PostgreSQL then aborted one of them with `ActiveRecord::Deadlocked`. These paths now take a guard on the user rows that their writes name (in id order), then lock the organization row, then their own row:
|
|
10
|
+
- `Invitation#accept!` (the accepting user and the inviter). Before, an acceptance deadlocked with the inviter's account deletion or with a destroy of the organization.
|
|
11
|
+
- `Invitation#revoke!` (no user row). Before, the revoke of an invitation with grants deadlocked with a destroy of its organization or with the inviter's account deletion.
|
|
12
|
+
- `JoinRequest#approve!` and the automatic approval after a verified email (the requester and the decider). Before, an approval deadlocked with the requester's account deletion.
|
|
13
|
+
- `JoinRequest#verify_email_code!` (the requester). Before, the check of an emailed code deadlocked with a destroy of the organization, which deletes the domain before the request. The requester's guard comes first, so the automatic approval inside a host transaction asks for no user row while it holds the organization row.
|
|
14
|
+
- `JoinCode#redeem!` (the redeeming user). Before, a redemption deadlocked with the account deletion of the code's creator or with a destroy of the organization.
|
|
15
|
+
- **The user guard is a key share lock** (`FOR KEY SHARE` on PostgreSQL, a shared lock on MySQL, nothing on SQLite). It conflicts with the deletion's `FOR UPDATE`, and with nothing else: an update of the user and the foreign key checks of other writers pass. So `reject!` (which names its decider), `withdraw!`, `resend!` and a grant on a pending invitation (which names its granter) keep their single row lock and do not wait for the guards.
|
|
16
|
+
- **An acceptance from a stale copy locks the current inviter.** A reissue of an expired invitation (`send_invite_to!`) names the new inviter. A loaded copy can still name the old one. The acceptance reads the row again under its lock. When the row names other ids than the ones locked, the acceptance releases its locks (a savepoint) and tries again with the current ids, up to three times. Then it raises `Invitation::InvitationChanged`, an `InvitationError`. `accept_pending_organization_invitation!` returns it as the failure `:invitation_changed` and keeps the session token. The engine's accept action answers with the localized message: HTML goes back to the invitation page, JSON gets a 409. Before, the acceptance could insert a membership that named an unlocked inviter, and deadlock with an ownership transfer to that inviter.
|
|
17
|
+
- Inside a host transaction, the order holds only when the locks that the host took earlier in that transaction keep it too.
|
|
18
|
+
- README: "Row-level locking" states the one lock order. The steps of the ownership transfer now start with the new owner's row, as the code has done since 0.7.0.
|
|
19
|
+
|
|
3
20
|
## [0.8.0] - 2026-10-24
|
|
4
21
|
|
|
5
22
|
**Hand a personal organization over, and keep organizations that hold records with a retention period.** An invoicing app on the fleet built both by hand and runs them in production. This release moves them into the gem, with their tests. Both are opt-in, and no migration is needed.
|
data/README.md
CHANGED
|
@@ -722,6 +722,10 @@ invitation.grant!(record, capabilities: %i[read])
|
|
|
722
722
|
# Actions
|
|
723
723
|
invitation.accept! # Accept (auto-infers Current.user)
|
|
724
724
|
invitation.accept!(user) # Accept with explicit user
|
|
725
|
+
# accept! raises Organizations::InvitationExpired, Organizations::InvitationAlreadyAccepted,
|
|
726
|
+
# Invitation::EmailMismatch, Invitation::CannotAcceptAsOwner, or Invitation::InvitationChanged:
|
|
727
|
+
# a reissue named another inviter at each of three tries to lock the rows. Nothing is
|
|
728
|
+
# written then, and the person can try again (organizations.errors.invitation_changed).
|
|
725
729
|
invitation.resend!(resent_by: current_user) # Send invitation email again
|
|
726
730
|
invitation.revoke!(revoked_by: current_user) # Delete it, so the link stops working
|
|
727
731
|
```
|
|
@@ -1325,6 +1329,8 @@ else
|
|
|
1325
1329
|
end
|
|
1326
1330
|
```
|
|
1327
1331
|
|
|
1332
|
+
The reasons: `:missing_user`, `:missing_token`, `:invitation_not_found`, `:invitation_expired`, `:email_mismatch`, `:already_accepted_without_membership` and `:invitation_changed`. `:invitation_changed` means that a reissue changed the invitation during the acceptance (`Invitation::InvitationChanged`). Nothing was written, and the session keeps the token, so the person can try again. The engine's accept action answers it with the localized message (`organizations.errors.invitation_changed`): HTML goes back to the invitation page, JSON gets a 409 with `{ "error": message }`.
|
|
1333
|
+
|
|
1328
1334
|
Configure redirects in your initializer:
|
|
1329
1335
|
|
|
1330
1336
|
```ruby
|
|
@@ -1604,7 +1610,7 @@ What the gem guarantees:
|
|
|
1604
1610
|
|
|
1605
1611
|
- It checks the complete owned set before it deletes anything. One blocker blocks the whole deletion.
|
|
1606
1612
|
- The organization deletion and the user deletion are one transaction, with its own savepoint, also inside a transaction of yours. A host `before_destroy` veto on the organization, or any later failure, rolls back everything, and the veto message is on `user.errors`.
|
|
1607
|
-
- Before its first write, `user.destroy` locks the user row, then the row of every organization in which it writes (memberships, sent invitations and their grants, join requests and the other records it nullifies), in id order. An organization's destroy also locks its row before its cascades, so the two cannot deadlock. A concurrent join or organization creation for that user, a join, transfer or destroy in one of those organizations, and an ownership transfer to that user (`transfer_ownership_to!` locks the new owner's row) wait for the deletion, or finish first and are seen by it. The guard reads the database under its locks, not the query cache.
|
|
1613
|
+
- Before its first write, `user.destroy` locks the user row, then the row of every organization in which it writes (memberships, sent invitations and their grants, join requests and the other records it nullifies), in id order. An organization's destroy also locks its row before its cascades, so the two cannot deadlock. The joins take the same order (see "Row-level locking"). A concurrent join or organization creation for that user, a join, transfer or destroy in one of those organizations, and an ownership transfer to that user (`transfer_ownership_to!` locks the new owner's row) wait for the deletion, or finish first and are seen by it. The guard reads the database under its locks, not the query cache.
|
|
1608
1614
|
- The user's other memberships are deleted (`dependent: :destroy`), as before. Invitations they sent, join requests they decided, join codes they created and allowlist entries they claimed keep the record and lose the user reference.
|
|
1609
1615
|
|
|
1610
1616
|
### Organizations that must be kept
|
|
@@ -2705,7 +2711,9 @@ These constraints prevent duplicate data at the database level:
|
|
|
2705
2711
|
|
|
2706
2712
|
### Row-level locking
|
|
2707
2713
|
|
|
2708
|
-
The gem uses `SELECT ... FOR UPDATE` (row-level locks) to prevent race conditions
|
|
2714
|
+
The gem uses `SELECT ... FOR UPDATE` (row-level locks) to prevent race conditions.
|
|
2715
|
+
|
|
2716
|
+
The gem takes its row locks in one order: the user rows that a write names (in id order), then the organization row, then the rows of the organization. A user's deletion locks its user row `FOR UPDATE`. The joins take a key share guard on their user rows (`FOR KEY SHARE` on PostgreSQL): only a deletion of those users waits for it, and ordinary updates and foreign key checks pass. A user's deletion, an organization's destroy, an invitation acceptance or revoke, a join request approval, the check of an emailed code and a join code redemption all keep this order. So they wait for each other, and do not deadlock on PostgreSQL. Inside your own transaction, the order holds only when your earlier locks in that transaction keep it too. Keep the same order in your own code that writes in an organization.
|
|
2709
2717
|
|
|
2710
2718
|
**Invitation acceptance:**
|
|
2711
2719
|
```ruby
|
|
@@ -2713,11 +2721,14 @@ The gem uses `SELECT ... FOR UPDATE` (row-level locks) to prevent race condition
|
|
|
2713
2721
|
invitation.accept!
|
|
2714
2722
|
|
|
2715
2723
|
# Internally:
|
|
2716
|
-
# 1.
|
|
2717
|
-
# 2.
|
|
2718
|
-
# 3.
|
|
2719
|
-
#
|
|
2720
|
-
#
|
|
2724
|
+
# 1. Take the key share guard on the accepting user and the inviter
|
|
2725
|
+
# 2. Lock organization row
|
|
2726
|
+
# 3. Lock invitation row (when it now names another inviter, release the
|
|
2727
|
+
# locks and start again with the current one, up to three times)
|
|
2728
|
+
# 4. Check accepted_at is nil
|
|
2729
|
+
# 5. Create membership
|
|
2730
|
+
# 6. Set accepted_at
|
|
2731
|
+
# 7. Release locks
|
|
2721
2732
|
# Second request sees accepted_at is set, returns existing membership
|
|
2722
2733
|
```
|
|
2723
2734
|
|
|
@@ -2726,13 +2737,14 @@ invitation.accept!
|
|
|
2726
2737
|
org.transfer_ownership_to!(new_owner)
|
|
2727
2738
|
|
|
2728
2739
|
# Internally:
|
|
2729
|
-
# 1. Lock
|
|
2730
|
-
# 2. Lock
|
|
2731
|
-
# 3. Lock
|
|
2732
|
-
# 4.
|
|
2733
|
-
# 5.
|
|
2734
|
-
# 6.
|
|
2735
|
-
# 7.
|
|
2740
|
+
# 1. Lock the new owner's user row
|
|
2741
|
+
# 2. Lock organization row
|
|
2742
|
+
# 3. Lock old owner's membership
|
|
2743
|
+
# 4. Lock new owner's membership
|
|
2744
|
+
# 5. Verify new owner is a member
|
|
2745
|
+
# 6. Demote old owner to admin
|
|
2746
|
+
# 7. Promote new owner to owner
|
|
2747
|
+
# 8. Release locks
|
|
2736
2748
|
```
|
|
2737
2749
|
|
|
2738
2750
|
**Last admin/owner protection:**
|
|
@@ -66,18 +66,13 @@ module Organizations
|
|
|
66
66
|
|
|
67
67
|
def respond_invitation_acceptance_failure(failure)
|
|
68
68
|
case failure.failure_reason
|
|
69
|
-
when :email_mismatch
|
|
70
|
-
|
|
71
|
-
when :
|
|
72
|
-
|
|
69
|
+
when :email_mismatch then respond_invitation_email_mismatch
|
|
70
|
+
when :invitation_expired then respond_invitation_expired
|
|
71
|
+
when :invitation_changed then respond_invitation_changed(failure.invitation || @invitation)
|
|
72
|
+
else
|
|
73
|
+
respond_invitation_error(html_path: main_app.root_path, alert: "Unable to accept this invitation.",
|
|
74
|
+
json_error: "Acceptance failed", status: 422)
|
|
73
75
|
end
|
|
74
|
-
|
|
75
|
-
respond_invitation_error(
|
|
76
|
-
html_path: main_app.root_path,
|
|
77
|
-
alert: "Unable to accept this invitation.",
|
|
78
|
-
json_error: "Acceptance failed",
|
|
79
|
-
status: 422
|
|
80
|
-
)
|
|
81
76
|
end
|
|
82
77
|
|
|
83
78
|
def respond_invitation_email_mismatch
|
|
@@ -98,6 +93,14 @@ module Organizations
|
|
|
98
93
|
)
|
|
99
94
|
end
|
|
100
95
|
|
|
96
|
+
# A reissue changed the invitation during the acceptance, and nothing was
|
|
97
|
+
# written. Send the person back to it (its current link) to try again.
|
|
98
|
+
def respond_invitation_changed(invitation)
|
|
99
|
+
message = Organizations.t(:"errors.invitation_changed", organization: invitation.organization)
|
|
100
|
+
respond_invitation_error(html_path: invitation_path(invitation.token), alert: message, json_error: message,
|
|
101
|
+
status: 409)
|
|
102
|
+
end
|
|
103
|
+
|
|
101
104
|
def respond_invitation_acceptance_success(result)
|
|
102
105
|
after_path = redirect_path_after_invitation_accepted(result.invitation, user: current_user)
|
|
103
106
|
payload, status = invitation_acceptance_json_response(result)
|
data/config/locales/en.yml
CHANGED
|
@@ -72,6 +72,7 @@ en:
|
|
|
72
72
|
# — Invitations —
|
|
73
73
|
invitation_expired: "This invitation has expired"
|
|
74
74
|
invitation_already_accepted: "This invitation has already been accepted"
|
|
75
|
+
invitation_changed: "This invitation changed while it was being accepted. Try again."
|
|
75
76
|
invitation_cannot_resend_accepted: "Cannot resend an accepted invitation"
|
|
76
77
|
invitation_email_mismatch: "This invitation was sent to a different email address"
|
|
77
78
|
invitation_accept_as_owner: "Cannot accept invitation as owner. Invite as admin, then use transfer_ownership_to! after joining."
|
data/config/locales/es.yml
CHANGED
|
@@ -54,6 +54,7 @@ es:
|
|
|
54
54
|
|
|
55
55
|
invitation_expired: "Esta invitación ha caducado"
|
|
56
56
|
invitation_already_accepted: "Esta invitación ya ha sido aceptada"
|
|
57
|
+
invitation_changed: "Esta invitación cambió mientras se aceptaba. Inténtalo de nuevo."
|
|
57
58
|
invitation_cannot_resend_accepted: "No se puede reenviar una invitación ya aceptada"
|
|
58
59
|
invitation_email_mismatch: "Esta invitación se envió a otra dirección de email"
|
|
59
60
|
invitation_accept_as_owner: "No se puede aceptar una invitación como propietario. Invita como administrador y transfiere la propiedad después."
|
|
@@ -290,6 +290,14 @@ module Organizations
|
|
|
290
290
|
return_failure: return_failure,
|
|
291
291
|
invitation: invitation
|
|
292
292
|
)
|
|
293
|
+
rescue Organizations::Invitation::InvitationChanged
|
|
294
|
+
# A reissue changed the invitation at each try to lock it. Nothing
|
|
295
|
+
# was written, and the person can try again: keep the session token.
|
|
296
|
+
return invitation_acceptance_failure(
|
|
297
|
+
:invitation_changed,
|
|
298
|
+
return_failure: return_failure,
|
|
299
|
+
invitation: invitation
|
|
300
|
+
)
|
|
293
301
|
rescue Organizations::InvitationAlreadyAccepted
|
|
294
302
|
# Check if user is actually a member
|
|
295
303
|
membership = Organizations::Membership.find_by(
|
|
@@ -124,6 +124,12 @@ module Organizations
|
|
|
124
124
|
# Error raised when trying to accept an invitation with owner role
|
|
125
125
|
class CannotAcceptAsOwner < Organizations::InvitationError; end
|
|
126
126
|
|
|
127
|
+
# The invitation named another inviter at each of ACCEPT_LOCK_ATTEMPTS
|
|
128
|
+
# tries to lock its rows (it was reissued meanwhile).
|
|
129
|
+
class InvitationChanged < Organizations::InvitationError; end
|
|
130
|
+
|
|
131
|
+
ACCEPT_LOCK_ATTEMPTS = 3
|
|
132
|
+
|
|
127
133
|
# Accept the invitation and create membership
|
|
128
134
|
# Uses row-level locking to prevent race conditions
|
|
129
135
|
# @param user [User, nil] The user accepting (uses Current.user if not provided)
|
|
@@ -149,8 +155,7 @@ module Organizations
|
|
|
149
155
|
membership = nil
|
|
150
156
|
|
|
151
157
|
ActiveRecord::Base.transaction do
|
|
152
|
-
|
|
153
|
-
lock!
|
|
158
|
+
lock_rows_for_acceptance!(accepting_user)
|
|
154
159
|
|
|
155
160
|
# Re-check status after acquiring lock
|
|
156
161
|
if accepted?
|
|
@@ -231,6 +236,10 @@ module Organizations
|
|
|
231
236
|
# @raise [Organizations::NotAuthorized] if the role ranks above revoked_by's own
|
|
232
237
|
def revoke!(revoked_by: nil)
|
|
233
238
|
ActiveRecord::Base.transaction do
|
|
239
|
+
# The organization row first (RowLocks), as an organization destroy
|
|
240
|
+
# and the inviter's deletion lock it before this invitation and its
|
|
241
|
+
# grants.
|
|
242
|
+
RowLocks.lock_organization!(organization_id)
|
|
234
243
|
lock!
|
|
235
244
|
authorize_management!(revoked_by)
|
|
236
245
|
destroy!
|
|
@@ -257,6 +266,31 @@ module Organizations
|
|
|
257
266
|
|
|
258
267
|
private
|
|
259
268
|
|
|
269
|
+
# The gem's lock order (RowLocks): the users that the membership names
|
|
270
|
+
# (the accepting user and the inviter), then the organization row, then
|
|
271
|
+
# the invitation row. A deletion of the inviter or of the organization
|
|
272
|
+
# takes the same order, so it waits for this acceptance, or this one for
|
|
273
|
+
# it. This copy can name a stale inviter: a reissue of the expired row
|
|
274
|
+
# (send_invite_to!) names the new one. The row lock reads the row again.
|
|
275
|
+
# When it names other ids than the ones locked, the savepoint rolls back,
|
|
276
|
+
# which releases its locks, and the next attempt locks the current ids.
|
|
277
|
+
def lock_rows_for_acceptance!(accepting_user)
|
|
278
|
+
ACCEPT_LOCK_ATTEMPTS.times do
|
|
279
|
+
locked = [invited_by_id, organization_id]
|
|
280
|
+
held = ActiveRecord::Base.transaction(requires_new: true) do
|
|
281
|
+
RowLocks.lock_users!(accepting_user.id, invited_by_id)
|
|
282
|
+
RowLocks.lock_organization!(organization_id)
|
|
283
|
+
lock!
|
|
284
|
+
raise ActiveRecord::Rollback unless [invited_by_id, organization_id] == locked
|
|
285
|
+
|
|
286
|
+
true
|
|
287
|
+
end
|
|
288
|
+
return if held
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
raise InvitationChanged, Organizations.t(:"errors.invitation_changed", organization: organization)
|
|
292
|
+
end
|
|
293
|
+
|
|
260
294
|
# The invitation's grants become the membership's (a grant on the same
|
|
261
295
|
# resource takes the invitation's capabilities). The inviter was
|
|
262
296
|
# checked when the invitation was sent.
|
|
@@ -152,10 +152,7 @@ module Organizations
|
|
|
152
152
|
outcome = nil
|
|
153
153
|
|
|
154
154
|
ActiveRecord::Base.transaction do
|
|
155
|
-
|
|
156
|
-
# be race-safe (two concurrent redemptions of a max_uses: 1 code must
|
|
157
|
-
# yield exactly one success).
|
|
158
|
-
lock!
|
|
155
|
+
lock_rows_for_redemption!(user)
|
|
159
156
|
ensure_redeemable!
|
|
160
157
|
|
|
161
158
|
# Already a member — idempotent no-op that does NOT consume a use.
|
|
@@ -188,6 +185,18 @@ module Organizations
|
|
|
188
185
|
|
|
189
186
|
private
|
|
190
187
|
|
|
188
|
+
# The gem's lock order (RowLocks): the redeeming user, then the
|
|
189
|
+
# organization row, then the code row. The deletion of the code's
|
|
190
|
+
# creator and an organization destroy take the organization row before
|
|
191
|
+
# the code row. The code row lock makes the uses_count accounting and
|
|
192
|
+
# the max_uses cap race-safe (two concurrent redemptions of a max_uses: 1
|
|
193
|
+
# code must yield exactly one success).
|
|
194
|
+
def lock_rows_for_redemption!(user)
|
|
195
|
+
RowLocks.lock_users!(user.id)
|
|
196
|
+
RowLocks.lock_organization!(organization_id)
|
|
197
|
+
lock!
|
|
198
|
+
end
|
|
199
|
+
|
|
191
200
|
def ensure_redeemable!
|
|
192
201
|
raise JoinCodeInvalid, Organizations.t(:"errors.join_code_invalid") if revoked? || expired?
|
|
193
202
|
raise JoinCodeExhausted, Organizations.t(:"errors.join_code_exhausted") if exhausted?
|
|
@@ -180,7 +180,7 @@ module Organizations
|
|
|
180
180
|
failure = nil
|
|
181
181
|
|
|
182
182
|
ActiveRecord::Base.transaction do
|
|
183
|
-
|
|
183
|
+
lock_rows_for_verification!
|
|
184
184
|
ensure_open!
|
|
185
185
|
ensure_active_challenge!
|
|
186
186
|
# The domain or roster entry may have been removed, revoked or
|
|
@@ -319,11 +319,30 @@ module Organizations
|
|
|
319
319
|
|
|
320
320
|
private
|
|
321
321
|
|
|
322
|
+
# The gem's lock order (RowLocks): the requester, then the organization
|
|
323
|
+
# row, then this request. An organization destroy deletes the domains
|
|
324
|
+
# and allowlist entries before this request, and the check locks one of
|
|
325
|
+
# them after this request. The automatic approval takes the requester's
|
|
326
|
+
# guard again: inside a host transaction, this one already holds it, so
|
|
327
|
+
# the approval does not ask for a user row while it holds the
|
|
328
|
+
# organization row.
|
|
329
|
+
def lock_rows_for_verification!
|
|
330
|
+
RowLocks.lock_users!(user_id)
|
|
331
|
+
RowLocks.lock_organization!(organization_id)
|
|
332
|
+
lock!
|
|
333
|
+
end
|
|
334
|
+
|
|
322
335
|
def perform_approval!(decided_by:, recheck_instruments:)
|
|
323
336
|
membership = nil
|
|
324
337
|
reused_existing = false
|
|
325
338
|
|
|
326
339
|
ActiveRecord::Base.transaction do
|
|
340
|
+
# The gem's lock order (RowLocks): the requester and the decider,
|
|
341
|
+
# then the organization row, then this request. The requester's
|
|
342
|
+
# deletion locks the requester, then the organization, and deletes
|
|
343
|
+
# this request.
|
|
344
|
+
RowLocks.lock_users!(user_id, decided_by&.id)
|
|
345
|
+
RowLocks.lock_organization!(organization_id)
|
|
327
346
|
lock!
|
|
328
347
|
|
|
329
348
|
return approved_membership! if approved? # idempotent re-approval
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Organizations
|
|
4
|
+
# The one lock order of the gem: the user rows (in id order), then the
|
|
5
|
+
# organization row, then the rows of the organization. A user's deletion
|
|
6
|
+
# locks the user row, then the row of every organization in which it
|
|
7
|
+
# writes. An organization's destroy locks its row before its cascades. A
|
|
8
|
+
# path that writes in an organization takes the same order, or it can
|
|
9
|
+
# deadlock with them on PostgreSQL.
|
|
10
|
+
#
|
|
11
|
+
# The user rows are the ones that the path's writes reference: a row that
|
|
12
|
+
# names a user takes a key share lock on it when it is written, which
|
|
13
|
+
# waits for a deletion of that user. Lock them first, so that the wait
|
|
14
|
+
# comes before the organization row and not after it.
|
|
15
|
+
#
|
|
16
|
+
# The user guard is a key share lock (FOR KEY SHARE on PostgreSQL). It
|
|
17
|
+
# conflicts with the deletion's FOR UPDATE, which is its purpose, and with
|
|
18
|
+
# nothing else: an ordinary update of the user and the foreign key check
|
|
19
|
+
# of another writer pass. A FOR UPDATE guard would block those writers,
|
|
20
|
+
# and a writer that holds a row this path waits for would then close a
|
|
21
|
+
# cycle (a reject! that sets decided_by_id, a grant that names its
|
|
22
|
+
# granter).
|
|
23
|
+
#
|
|
24
|
+
# Inside a host transaction the order holds only when the host's earlier
|
|
25
|
+
# locks in that transaction keep it too.
|
|
26
|
+
#
|
|
27
|
+
# @api private
|
|
28
|
+
module RowLocks
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# Take the key share guard on these users' rows, in id order. Nil ids
|
|
32
|
+
# and users that are gone are skipped.
|
|
33
|
+
# @param ids [Array<Object>]
|
|
34
|
+
def lock_users!(*ids)
|
|
35
|
+
ids = ids.flatten.compact.uniq
|
|
36
|
+
return if ids.empty?
|
|
37
|
+
|
|
38
|
+
user_class = Organizations.user_class
|
|
39
|
+
key = user_class.primary_key
|
|
40
|
+
user_class.unscoped.where(key => ids).order(key).lock(key_share_clause(user_class.connection)).pluck(key)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# PostgreSQL has a lock that only a delete or a key change conflicts
|
|
44
|
+
# with. MySQL has a shared lock. SQLite has no row locks: Rails drops
|
|
45
|
+
# the clause there.
|
|
46
|
+
def key_share_clause(connection)
|
|
47
|
+
adapter = connection.adapter_name.downcase
|
|
48
|
+
return "FOR KEY SHARE" if adapter.include?("postg")
|
|
49
|
+
return "LOCK IN SHARE MODE" if adapter.include?("mysql") || adapter.include?("trilogy")
|
|
50
|
+
|
|
51
|
+
true
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Lock the organization row. A destroyed organization is skipped: the
|
|
55
|
+
# caller's own row lock then finds its row gone.
|
|
56
|
+
# @param id [Object]
|
|
57
|
+
def lock_organization!(id)
|
|
58
|
+
Organizations::Organization.unscoped.where(id: id).lock.pick(:id)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/organizations.rb
CHANGED
|
@@ -126,6 +126,7 @@ module Organizations
|
|
|
126
126
|
autoload :GrantAccess, "organizations/grant_access"
|
|
127
127
|
autoload :Grantable, "organizations/grantable"
|
|
128
128
|
autoload :AccountDeletionPlan, "organizations/account_deletion_plan"
|
|
129
|
+
autoload :RowLocks, "organizations/row_locks"
|
|
129
130
|
|
|
130
131
|
# Alias for README compatibility: `include Organizations::Controller`
|
|
131
132
|
Controller = ControllerHelpers
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: organizations
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.8.
|
|
4
|
+
version: 0.8.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-10-
|
|
10
|
+
date: 2026-10-25 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: railties
|
|
@@ -180,6 +180,7 @@ files:
|
|
|
180
180
|
- lib/organizations/public_email_domains.rb
|
|
181
181
|
- lib/organizations/resource.rb
|
|
182
182
|
- lib/organizations/roles.rb
|
|
183
|
+
- lib/organizations/row_locks.rb
|
|
183
184
|
- lib/organizations/test_helpers.rb
|
|
184
185
|
- lib/organizations/version.rb
|
|
185
186
|
- lib/organizations/view_helpers.rb
|