parse-stack-next 5.7.0 → 5.7.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 +81 -1
- data/lib/parse/cache/invalidation.rb +102 -50
- data/lib/parse/model/associations/belongs_to.rb +4 -0
- data/lib/parse/model/associations/collection_proxy.rb +3 -0
- data/lib/parse/model/associations/has_many.rb +4 -0
- data/lib/parse/model/core/actions.rb +294 -123
- data/lib/parse/model/core/fetching.rb +4 -0
- data/lib/parse/model/core/properties.rb +4 -0
- data/lib/parse/model/object.rb +4 -0
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/webhooks.rb +98 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7cb6a2c813f0a1b2d624852680651ed289f7aac282fa24e246b28d0ed9e69383
|
|
4
|
+
data.tar.gz: a3984ae30555af28eadfdc19fa936103bb252bad332b0ddb8ead163277ae4bde
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d19428000255676b8a5f10ee0087bf3b6d21216c499f56d895d1ee50a646985a4297b725807b2b19878a8576415c8e1551471d34fa236e9bf90170e6e13abed5
|
|
7
|
+
data.tar.gz: 5db3159d3da214300df9e21760ae871f070f6a79d961fe9e082b5160dd0bd2c07cba5d64656791b961ab2c347e9ebf5151eb607fcf0a3768587a3c382476000e
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,85 @@
|
|
|
1
1
|
## parse-stack-next Changelog
|
|
2
2
|
|
|
3
|
+
### 5.7.1
|
|
4
|
+
|
|
5
|
+
#### Cache-invalidation webhooks no longer break every application hook for the same trigger
|
|
6
|
+
|
|
7
|
+
- **FIXED**: `Parse::Cache::Invalidation` raised `NoMethodError: undefined
|
|
8
|
+
method 'guard'` on every `_User`, `_Role`, and `_Session` trigger it
|
|
9
|
+
registered, and the failure was not contained. A webhook handler block is
|
|
10
|
+
bound to the payload before it runs, so the bare `guard` / `bump_subject` /
|
|
11
|
+
`subject_id` calls in the handler bodies resolved against
|
|
12
|
+
`Parse::Webhooks::Payload`, which defines none of them. `guard`'s own
|
|
13
|
+
`rescue` never ran, because it lives inside the body that was never entered,
|
|
14
|
+
so the error escaped into the route dispatcher. The dispatcher folds a
|
|
15
|
+
trigger's handlers with `Array#map`, which abandons the collection on the
|
|
16
|
+
first raise, and these triggers install during `Parse.setup` and therefore
|
|
17
|
+
sit ahead of every application handler for the same trigger. One
|
|
18
|
+
unresolvable method name silently prevented an application's own
|
|
19
|
+
`after_save "_User"` hook from running at all, so any work downstream of it
|
|
20
|
+
stopped without a visible cause. The handlers now call the module on an
|
|
21
|
+
explicit receiver captured in a local, which is independent of whatever
|
|
22
|
+
`self` the dispatcher binds. Applications that set
|
|
23
|
+
`cache_invalidation_hooks: false` to work around this can remove it.
|
|
24
|
+
- **FIXED**: The invalidation tests dispatched handlers with a plain
|
|
25
|
+
`Proc#call`, which leaves `self` bound to the module the block closed over,
|
|
26
|
+
so every one of them passed against handlers that could not run in
|
|
27
|
+
production. They now dispatch through the real handler invocation and fail
|
|
28
|
+
against the broken code.
|
|
29
|
+
|
|
30
|
+
#### Whether one failing `after_*` handler stops the rest is now a setting
|
|
31
|
+
|
|
32
|
+
- **NEW**: `Parse::Webhooks.abort_after_callbacks_on_error` decides whether an
|
|
33
|
+
exception raised by one `after_*` handler prevents the remaining handlers for
|
|
34
|
+
that trigger from running. It defaults to `true`, which is the existing
|
|
35
|
+
behavior, so nothing changes on upgrade. Set it to `false` to isolate
|
|
36
|
+
handlers from each other: each one runs regardless of what an earlier one
|
|
37
|
+
raised, and the failure is reported with a warning and a
|
|
38
|
+
`parse.webhooks.handler_error` notification instead of being swallowed
|
|
39
|
+
silently. This was previously not a decision at all but a side effect of
|
|
40
|
+
folding handlers with `Array#map`, which abandons the collection on the
|
|
41
|
+
first raise. Registration order is not fully under an application's control,
|
|
42
|
+
since the SDK's own cache-invalidation triggers install during `Parse.setup`
|
|
43
|
+
and therefore sit ahead of handlers registered by application files loaded
|
|
44
|
+
later, so a single raising handler could silently prevent an application's
|
|
45
|
+
own hooks from running with nothing logged to say they had been skipped.
|
|
46
|
+
- **BEHAVIOR**: The setting governs only whether later handlers still run.
|
|
47
|
+
Neither mode reverts anything: an `after_*` trigger fires once the write has
|
|
48
|
+
already committed, so there is no version of this setting that can undo a
|
|
49
|
+
save. `before_*` dispatch is untouched, and only the accumulating,
|
|
50
|
+
non-rejectable triggers (`after_save`, `after_delete`, `after_logout`) can
|
|
51
|
+
hold more than one handler in the first place. A rejectable `before_*`
|
|
52
|
+
trigger must deny if any handler denies, so its raise must continue to
|
|
53
|
+
abort.
|
|
54
|
+
|
|
55
|
+
#### Failed transactions restore the object they rolled back
|
|
56
|
+
|
|
57
|
+
- **FIXED**: A failed `Parse::Object.transaction` left the in-memory object
|
|
58
|
+
holding its modified values. The rollback snapshotted `Parse::Object#attributes`
|
|
59
|
+
and restored it, but that method returns a schema map of field name to type
|
|
60
|
+
symbol rather than values, so nothing was ever restored. Property values
|
|
61
|
+
live in `@<field>` instance variables; those are now what the rollback
|
|
62
|
+
captures and restores, including values nested inside arrays and hashes,
|
|
63
|
+
relation operation queues, and properties whose ivar did not exist before
|
|
64
|
+
the transaction. State is captured when the object first enters the
|
|
65
|
+
transaction rather than at `batch.add`, so the documented pattern of
|
|
66
|
+
mutating an object and adding it afterwards rolls back correctly.
|
|
67
|
+
- **FIXED**: A failed transaction also left the object with broken change
|
|
68
|
+
tracking. Restoring the schema map defined `@attributes`, which is the
|
|
69
|
+
instance variable `ActiveModel::Dirty` keys its behavior on: once defined it
|
|
70
|
+
builds an `AttributeMutationTracker` over that hash instead of the
|
|
71
|
+
`ForcedMutationTracker` a `Parse::Object` needs, and `clear_changes!` then
|
|
72
|
+
called `forgetting_assignment` on the `[key, value]` pairs `Hash#map`
|
|
73
|
+
yields. Both call sites rescue and warn, so every rollback quietly
|
|
74
|
+
downgraded the object rather than failing. The rollback no longer defines
|
|
75
|
+
`@attributes`.
|
|
76
|
+
- **FIXED**: The mongo-direct role-graph integration test gated on
|
|
77
|
+
`ANALYTICS_DATABASE_URI`, a production variable name the test stack never
|
|
78
|
+
sets, so both of its traversal assertions skipped on every run while the
|
|
79
|
+
per-file reporter still reported the file as passing. It now reads
|
|
80
|
+
`PARSE_TEST_MONGO_URI` like every other mongo-direct integration file and
|
|
81
|
+
still honors `ANALYTICS_DATABASE_URI` as an override.
|
|
82
|
+
|
|
3
83
|
### 5.7.0
|
|
4
84
|
|
|
5
85
|
#### Cache keys move into a reserved, app-scoped keyspace
|
|
@@ -205,7 +285,7 @@
|
|
|
205
285
|
Server's self-access rules, and role-only checks cannot claim a concrete
|
|
206
286
|
member's pointer or `_User` self permission. CLP cache entries are isolated
|
|
207
287
|
by Parse application so identically named classes cannot leak policy across
|
|
208
|
-
clients. These helpers are advisory
|
|
288
|
+
clients. These helpers are advisory: the eventual Parse Server request is
|
|
209
289
|
still authoritative.
|
|
210
290
|
|
|
211
291
|
#### Test infrastructure
|
|
@@ -52,26 +52,111 @@ module Parse
|
|
|
52
52
|
registered
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
# Run a role-trigger invalidation.
|
|
56
|
+
#
|
|
57
|
+
# Public because the registered webhook block calls it on an explicit
|
|
58
|
+
# receiver. See {handle_identity_trigger} for why that matters.
|
|
59
|
+
#
|
|
60
|
+
# @!visibility private
|
|
61
|
+
# @param cache [Parse::Cache::Redis] the keyspace-configured cache.
|
|
62
|
+
# @return [void]
|
|
63
|
+
def handle_role_trigger(cache)
|
|
64
|
+
guard do
|
|
65
|
+
# A role write does not say which users are affected: membership
|
|
66
|
+
# and hierarchy changes arrive as relation deltas on `users` and
|
|
67
|
+
# `roles`, and the cached value is a flattened transitive closure,
|
|
68
|
+
# so a parent-role change reaches the members of every child.
|
|
69
|
+
# Clearing the whole plane is both correct and cheap under a
|
|
70
|
+
# scoped SCAN. Parse Server does the same, for the same reason.
|
|
71
|
+
cache.roles.clear
|
|
72
|
+
# Stamp the epoch so a *foreign* role entry written before this
|
|
73
|
+
# moment is rejected on read. Parse Server does not clear its own
|
|
74
|
+
# role cache on a `_Role` delete, so without this the next read
|
|
75
|
+
# would take its stale entry back and our clear would shorten
|
|
76
|
+
# revocation by nothing.
|
|
77
|
+
cache.roles.touch_epoch
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Run an identity-trigger invalidation.
|
|
82
|
+
#
|
|
83
|
+
# Public, and invoked on an explicit receiver, because a webhook
|
|
84
|
+
# handler block does NOT run with `self` bound to the module that
|
|
85
|
+
# created it. {Parse::Webhooks.invoke_handler} binds the block to the
|
|
86
|
+
# payload (`payload.define_singleton_method(name, &block)`), and the
|
|
87
|
+
# historical `payload.instance_exec(payload, &block)` did the same.
|
|
88
|
+
# A block body calling a bare `guard` / `bump_subject` / `subject_id`
|
|
89
|
+
# therefore resolved against {Parse::Webhooks::Payload}, which defines
|
|
90
|
+
# none of them, and raised `NoMethodError` on every `_User`, `_Role`,
|
|
91
|
+
# and `_Session` trigger.
|
|
92
|
+
#
|
|
93
|
+
# That failure was not contained. `guard`'s `rescue` lives inside
|
|
94
|
+
# `guard`'s own body, which was never entered, so the error escaped
|
|
95
|
+
# into `call_route`'s `registry.map`. `Array#map` abandons the whole
|
|
96
|
+
# collection on the first raise, and these triggers register at
|
|
97
|
+
# `Parse.setup` and therefore sit AHEAD of any application handler for
|
|
98
|
+
# the same trigger. One unresolvable method name silently prevented
|
|
99
|
+
# every application `after_save "_User"` hook from running at all.
|
|
100
|
+
#
|
|
101
|
+
# Capturing the module in a local and calling it explicitly is what
|
|
102
|
+
# makes the handler independent of whatever `self` the dispatcher
|
|
103
|
+
# binds.
|
|
104
|
+
#
|
|
105
|
+
# @!visibility private
|
|
106
|
+
# @param cache [Parse::Cache::Redis] the keyspace-configured cache.
|
|
107
|
+
# @param type [Symbol] the trigger being handled.
|
|
108
|
+
# @param payload [Parse::Webhooks::Payload] the incoming payload.
|
|
109
|
+
# @return [void]
|
|
110
|
+
def handle_identity_trigger(cache, type, payload)
|
|
111
|
+
guard do
|
|
112
|
+
case type
|
|
113
|
+
when :after_logout
|
|
114
|
+
# The only trigger Parse Server permits on `_Session`. The
|
|
115
|
+
# object's own sessionToken is scrubbed from the payload, but
|
|
116
|
+
# the token is captured from the requesting user before
|
|
117
|
+
# scrubbing, and for a logout that user *is* the session being
|
|
118
|
+
# ended. A master-key logout carries no user, so fall back to
|
|
119
|
+
# the generation bump.
|
|
120
|
+
#
|
|
121
|
+
# Pass the RAW token, not a pre-hashed digest.
|
|
122
|
+
# `Parse::Cache::SubCache#invalidate` hashes its `key`
|
|
123
|
+
# argument internally for the `:idn` family (see
|
|
124
|
+
# `SubCache#logical_key`), the same way `#get` / `#set` do.
|
|
125
|
+
# That is what makes a `set(raw_token, ...)` /
|
|
126
|
+
# `get(raw_token)` pair round-trip. Hashing here first and
|
|
127
|
+
# handing SubCache an already-hashed value made it hash the
|
|
128
|
+
# digest a second time, landing on a key nothing had ever
|
|
129
|
+
# written to, so logout silently failed to evict the entry.
|
|
130
|
+
token = payload.respond_to?(:session_token) ? payload.session_token : nil
|
|
131
|
+
if token && !token.to_s.empty?
|
|
132
|
+
cache.identity.invalidate(token.to_s)
|
|
133
|
+
else
|
|
134
|
+
bump_subject(cache, subject_id(payload))
|
|
135
|
+
end
|
|
136
|
+
else
|
|
137
|
+
# A `_User` write gives a user id, but identity entries are
|
|
138
|
+
# keyed by session token and no reverse map exists. Bumping a
|
|
139
|
+
# per-user generation invalidates every one of that user's
|
|
140
|
+
# entries in O(1), including tokens this process has never
|
|
141
|
+
# resolved, and without Parse Server's master-key `_Session`
|
|
142
|
+
# query.
|
|
143
|
+
bump_subject(cache, subject_id(payload))
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
55
148
|
private
|
|
56
149
|
|
|
57
150
|
def install_role_triggers!(cache)
|
|
151
|
+
# `invalidator` is a captured LOCAL, not `self`. The registered block
|
|
152
|
+
# runs with `self` rebound to the payload, so a bare method call in
|
|
153
|
+
# its body would resolve against `Parse::Webhooks::Payload`. A local
|
|
154
|
+
# closes over correctly regardless of the receiver the dispatcher
|
|
155
|
+
# binds.
|
|
156
|
+
invalidator = self
|
|
58
157
|
TRIGGERS[:role].map do |(type, class_name)|
|
|
59
158
|
Parse::Webhooks.route(type, class_name) do |payload|
|
|
60
|
-
|
|
61
|
-
# A role write does not say which users are affected: membership
|
|
62
|
-
# and hierarchy changes arrive as relation deltas on `users` and
|
|
63
|
-
# `roles`, and the cached value is a flattened transitive closure,
|
|
64
|
-
# so a parent-role change reaches the members of every child.
|
|
65
|
-
# Clearing the whole plane is both correct and cheap under a
|
|
66
|
-
# scoped SCAN. Parse Server does the same, for the same reason.
|
|
67
|
-
cache.roles.clear
|
|
68
|
-
# Stamp the epoch so a *foreign* role entry written before this
|
|
69
|
-
# moment is rejected on read. Parse Server does not clear its own
|
|
70
|
-
# role cache on a `_Role` delete, so without this the next read
|
|
71
|
-
# would take its stale entry back and our clear would shorten
|
|
72
|
-
# revocation by nothing.
|
|
73
|
-
cache.roles.touch_epoch
|
|
74
|
-
end
|
|
159
|
+
invalidator.handle_role_trigger(cache)
|
|
75
160
|
true
|
|
76
161
|
end
|
|
77
162
|
[type, class_name]
|
|
@@ -79,43 +164,10 @@ module Parse
|
|
|
79
164
|
end
|
|
80
165
|
|
|
81
166
|
def install_identity_triggers!(cache)
|
|
167
|
+
invalidator = self
|
|
82
168
|
TRIGGERS[:identity].map do |(type, class_name)|
|
|
83
169
|
Parse::Webhooks.route(type, class_name) do |payload|
|
|
84
|
-
|
|
85
|
-
case type
|
|
86
|
-
when :after_logout
|
|
87
|
-
# The only trigger Parse Server permits on `_Session`. The
|
|
88
|
-
# object's own sessionToken is scrubbed from the payload, but
|
|
89
|
-
# the token is captured from the requesting user before
|
|
90
|
-
# scrubbing, and for a logout that user *is* the session being
|
|
91
|
-
# ended. A master-key logout carries no user, so fall back to
|
|
92
|
-
# the generation bump.
|
|
93
|
-
#
|
|
94
|
-
# Pass the RAW token, not a pre-hashed digest.
|
|
95
|
-
# `Parse::Cache::SubCache#invalidate` hashes its `key`
|
|
96
|
-
# argument internally for the `:idn` family (see
|
|
97
|
-
# `SubCache#logical_key`), the same way `#get` / `#set` do —
|
|
98
|
-
# that is what makes a `set(raw_token, ...)` /
|
|
99
|
-
# `get(raw_token)` pair round-trip. Hashing here first and
|
|
100
|
-
# handing SubCache an already-hashed value made it hash the
|
|
101
|
-
# digest a second time, landing on a key nothing had ever
|
|
102
|
-
# written to, so logout silently failed to evict the entry.
|
|
103
|
-
token = payload.respond_to?(:session_token) ? payload.session_token : nil
|
|
104
|
-
if token && !token.to_s.empty?
|
|
105
|
-
cache.identity.invalidate(token.to_s)
|
|
106
|
-
else
|
|
107
|
-
bump_subject(cache, subject_id(payload))
|
|
108
|
-
end
|
|
109
|
-
else
|
|
110
|
-
# A `_User` write gives a user id, but identity entries are
|
|
111
|
-
# keyed by session token and no reverse map exists. Bumping a
|
|
112
|
-
# per-user generation invalidates every one of that user's
|
|
113
|
-
# entries in O(1), including tokens this process has never
|
|
114
|
-
# resolved, and without Parse Server's master-key `_Session`
|
|
115
|
-
# query.
|
|
116
|
-
bump_subject(cache, subject_id(payload))
|
|
117
|
-
end
|
|
118
|
-
end
|
|
170
|
+
invalidator.handle_identity_trigger(cache, type, payload)
|
|
119
171
|
true
|
|
120
172
|
end
|
|
121
173
|
[type, class_name]
|
|
@@ -243,6 +243,10 @@ module Parse
|
|
|
243
243
|
instance_variable_set ivar, val
|
|
244
244
|
end
|
|
245
245
|
|
|
246
|
+
# Capture after lazy hydration, before a caller can mutate an
|
|
247
|
+
# object returned by this association getter.
|
|
248
|
+
send(:_capture_transaction_state!) if respond_to?(:_capture_transaction_state!, true)
|
|
249
|
+
|
|
246
250
|
# Track association source for N+1 detection when returning an unfetched pointer
|
|
247
251
|
# Uses a registry instead of setting instance variables on the pointer object
|
|
248
252
|
if val.is_a?(Parse::Pointer) && val.pointer? && Parse.warn_on_n_plus_one
|
|
@@ -359,6 +359,9 @@ module Parse
|
|
|
359
359
|
|
|
360
360
|
# Notifies the delegate that the collection changed.
|
|
361
361
|
def notify_will_change!
|
|
362
|
+
if @delegate && @delegate.respond_to?(:_capture_transaction_state!, true)
|
|
363
|
+
@delegate.send(:_capture_transaction_state!)
|
|
364
|
+
end
|
|
362
365
|
collection_will_change!
|
|
363
366
|
forward "#{@key}_will_change!"
|
|
364
367
|
end
|
|
@@ -502,6 +502,10 @@ module Parse
|
|
|
502
502
|
val = instance_variable_get ivar
|
|
503
503
|
end
|
|
504
504
|
|
|
505
|
+
# Capture before this getter materializes or returns a mutable
|
|
506
|
+
# proxy that can be changed in place.
|
|
507
|
+
send(:_capture_transaction_state!) if respond_to?(:_capture_transaction_state!, true)
|
|
508
|
+
|
|
505
509
|
# if the result is not a collection proxy, then create a new one.
|
|
506
510
|
unless val.is_a?(Parse::PointerCollectionProxy)
|
|
507
511
|
results = []
|
|
@@ -94,6 +94,28 @@ module Parse
|
|
|
94
94
|
module Core
|
|
95
95
|
# Defines some of the save, update and destroy operations for Parse objects.
|
|
96
96
|
module Actions
|
|
97
|
+
# Fiber-local context used to capture an object's state before its first
|
|
98
|
+
# mutation inside a transaction block. `batch.add` is intentionally too
|
|
99
|
+
# late for this: the public API documents mutating an object and adding it
|
|
100
|
+
# afterwards.
|
|
101
|
+
TRANSACTION_CONTEXT_KEY = :__parse_transaction_context__
|
|
102
|
+
|
|
103
|
+
# Distinguishes a property whose ivar did not exist from one explicitly
|
|
104
|
+
# set to nil. Rollback removes the former instead of defining it as nil.
|
|
105
|
+
UNDEFINED_PROPERTY = Object.new.freeze
|
|
106
|
+
|
|
107
|
+
# Non-property state that can change while building/submitting a batch
|
|
108
|
+
# and must be restored along with the `@<field>` property ivars.
|
|
109
|
+
ROLLBACK_STATE_IVARS = %i[
|
|
110
|
+
@changed_attributes
|
|
111
|
+
@id
|
|
112
|
+
@mutations_from_database
|
|
113
|
+
@mutations_before_last_save
|
|
114
|
+
@_acl_snapshot_before_change
|
|
115
|
+
@_acl_pristine
|
|
116
|
+
@_authorization_acl_state
|
|
117
|
+
].freeze
|
|
118
|
+
|
|
97
119
|
# @!visibility private
|
|
98
120
|
def self.included(base)
|
|
99
121
|
base.extend(ClassMethods)
|
|
@@ -133,10 +155,16 @@ module Parse
|
|
|
133
155
|
# cannot corrupt the saved copy.
|
|
134
156
|
def self.snapshot_property_values(obj)
|
|
135
157
|
fields = obj.class.respond_to?(:fields) ? obj.class.fields.keys : []
|
|
136
|
-
|
|
158
|
+
relations = obj.class.respond_to?(:relations) ? obj.class.relations.keys : []
|
|
159
|
+
property_keys = (fields + relations).uniq
|
|
160
|
+
seen = {}
|
|
161
|
+
property_keys.each_with_object({}) do |key, snapshot|
|
|
137
162
|
ivar = :"@#{key}"
|
|
138
|
-
|
|
139
|
-
|
|
163
|
+
snapshot[ivar] = if obj.instance_variable_defined?(ivar)
|
|
164
|
+
dup_for_snapshot(obj.instance_variable_get(ivar), seen)
|
|
165
|
+
else
|
|
166
|
+
UNDEFINED_PROPERTY
|
|
167
|
+
end
|
|
140
168
|
end
|
|
141
169
|
end
|
|
142
170
|
|
|
@@ -147,25 +175,144 @@ module Parse
|
|
|
147
175
|
# `:array` and association properties hold a {Parse::CollectionProxy},
|
|
148
176
|
# and duplicating the proxy still shares the underlying `@collection`
|
|
149
177
|
# array, so `widget.tags << "b"` would mutate the snapshot too. The
|
|
150
|
-
# proxy's
|
|
178
|
+
# proxy's mutable backing arrays and nested values are duplicated as
|
|
179
|
+
# well. Parse objects nested inside values remain references: a pointer
|
|
180
|
+
# property should restore the same object, not manufacture a clone.
|
|
151
181
|
#
|
|
152
182
|
# @param value [Object] the live property value.
|
|
153
183
|
# @return [Object] a copy safe to hold across the transaction.
|
|
154
|
-
def self.dup_for_snapshot(value)
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
184
|
+
def self.dup_for_snapshot(value, seen = {})
|
|
185
|
+
return value if value.nil? || value == true || value == false || value.is_a?(Symbol) || value.is_a?(Numeric)
|
|
186
|
+
return value if defined?(Parse::Pointer) && value.is_a?(Parse::Pointer)
|
|
187
|
+
|
|
188
|
+
object_id = value.object_id
|
|
189
|
+
return seen[object_id] if seen.key?(object_id)
|
|
190
|
+
|
|
191
|
+
case value
|
|
192
|
+
when Array
|
|
193
|
+
copy = value.dup
|
|
194
|
+
copy.clear
|
|
195
|
+
seen[object_id] = copy
|
|
196
|
+
value.each { |item| copy << dup_for_snapshot(item, seen) }
|
|
197
|
+
copy
|
|
198
|
+
when Hash
|
|
199
|
+
copy = value.dup
|
|
200
|
+
copy.clear
|
|
201
|
+
seen[object_id] = copy
|
|
202
|
+
value.each do |key, item|
|
|
203
|
+
copy[dup_for_snapshot(key, seen)] = dup_for_snapshot(item, seen)
|
|
204
|
+
end
|
|
205
|
+
copy
|
|
206
|
+
when Parse::CollectionProxy
|
|
207
|
+
copy = value.dup
|
|
208
|
+
seen[object_id] = copy
|
|
209
|
+
%i[@collection @additions @removals @changed_attributes].each do |ivar|
|
|
210
|
+
next unless value.instance_variable_defined?(ivar)
|
|
211
|
+
copy.instance_variable_set(ivar, dup_for_snapshot(value.instance_variable_get(ivar), seen))
|
|
212
|
+
end
|
|
213
|
+
%i[@mutations_from_database @mutations_before_last_save].each do |ivar|
|
|
214
|
+
next unless value.instance_variable_defined?(ivar)
|
|
215
|
+
tracker = value.instance_variable_get(ivar)
|
|
216
|
+
copy.instance_variable_set(ivar, dup_mutation_tracker(tracker, copy, seen))
|
|
162
217
|
end
|
|
218
|
+
copy
|
|
219
|
+
when Parse::ACL
|
|
220
|
+
copy = Parse::ACL.new(dup_for_snapshot(value.as_json, seen), owner: value.delegate)
|
|
221
|
+
seen[object_id] = copy
|
|
222
|
+
copy
|
|
223
|
+
else
|
|
224
|
+
copy = begin
|
|
225
|
+
value.dup
|
|
226
|
+
rescue TypeError
|
|
227
|
+
# Immutable values are safe to share with the snapshot.
|
|
228
|
+
return value
|
|
229
|
+
end
|
|
230
|
+
seen[object_id] = copy
|
|
231
|
+
copy
|
|
232
|
+
end
|
|
233
|
+
end
|
|
163
234
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
235
|
+
# Duplicate ActiveModel's mutation tracker without sharing its mutable
|
|
236
|
+
# forced/finalized change hashes. Forced trackers retain their owning
|
|
237
|
+
# Parse object (or copied collection proxy) so dirty reads keep working.
|
|
238
|
+
#
|
|
239
|
+
# @param tracker [Object, nil] ActiveModel mutation tracker.
|
|
240
|
+
# @param owner [Object] object whose attributes the copy should read.
|
|
241
|
+
# @param seen [Hash] identity map used by {dup_for_snapshot}.
|
|
242
|
+
# @return [Object, nil] isolated tracker copy.
|
|
243
|
+
def self.dup_mutation_tracker(tracker, owner, seen = {})
|
|
244
|
+
return nil if tracker.nil?
|
|
245
|
+
return tracker if defined?(ActiveModel::NullMutationTracker) && tracker.is_a?(ActiveModel::NullMutationTracker)
|
|
246
|
+
return seen[tracker.object_id] if seen.key?(tracker.object_id)
|
|
247
|
+
|
|
248
|
+
copy = tracker.dup
|
|
249
|
+
seen[tracker.object_id] = copy
|
|
250
|
+
if defined?(ActiveModel::ForcedMutationTracker) && tracker.is_a?(ActiveModel::ForcedMutationTracker)
|
|
251
|
+
copy.instance_variable_set(:@attributes, owner)
|
|
252
|
+
end
|
|
253
|
+
%i[@forced_changes @finalized_changes].each do |ivar|
|
|
254
|
+
next unless tracker.instance_variable_defined?(ivar)
|
|
255
|
+
copy.instance_variable_set(ivar, dup_for_snapshot(tracker.instance_variable_get(ivar), seen))
|
|
167
256
|
end
|
|
168
257
|
copy
|
|
258
|
+
rescue TypeError
|
|
259
|
+
tracker
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Capture all local state needed to restore an object after a failed
|
|
263
|
+
# transaction. This is separate from `#attributes`, which is a schema.
|
|
264
|
+
#
|
|
265
|
+
# @param obj [Parse::Object] object being transacted.
|
|
266
|
+
# @return [Hash] rollback state.
|
|
267
|
+
def self.snapshot_object_state(obj)
|
|
268
|
+
seen = {}
|
|
269
|
+
instance_variables = ROLLBACK_STATE_IVARS.each_with_object({}) do |ivar, snapshot|
|
|
270
|
+
snapshot[ivar] = if obj.instance_variable_defined?(ivar)
|
|
271
|
+
value = obj.instance_variable_get(ivar)
|
|
272
|
+
if %i[@mutations_from_database @mutations_before_last_save].include?(ivar)
|
|
273
|
+
dup_mutation_tracker(value, obj, seen)
|
|
274
|
+
else
|
|
275
|
+
dup_for_snapshot(value, seen)
|
|
276
|
+
end
|
|
277
|
+
else
|
|
278
|
+
UNDEFINED_PROPERTY
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
{
|
|
283
|
+
object: obj,
|
|
284
|
+
property_values: snapshot_property_values(obj),
|
|
285
|
+
instance_variables: instance_variables,
|
|
286
|
+
}
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Record that an object was initialized inside the active transaction.
|
|
290
|
+
# Its first rollback snapshot is intentionally taken when it is added to
|
|
291
|
+
# the batch, after initialization, so a failed create stays usable as an
|
|
292
|
+
# initialized unsaved object.
|
|
293
|
+
#
|
|
294
|
+
# @param obj [Parse::Object] newly initialized object.
|
|
295
|
+
# @return [void]
|
|
296
|
+
def self.mark_transaction_object_created(obj)
|
|
297
|
+
context = Fiber[TRANSACTION_CONTEXT_KEY]
|
|
298
|
+
return unless context
|
|
299
|
+
context[:created_objects][obj.object_id] = true
|
|
300
|
+
context[:snapshots].delete(obj.object_id)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Capture an object's state once, before its first transaction mutation.
|
|
304
|
+
# Objects created inside the transaction defer capture until `batch.add`.
|
|
305
|
+
#
|
|
306
|
+
# @param obj [Parse::Object] object whose state should be captured.
|
|
307
|
+
# @param context [Hash, nil] transaction context; defaults to the current fiber.
|
|
308
|
+
# @param include_created [Boolean] capture a newly created object at add time.
|
|
309
|
+
# @return [Hash, nil] the object's rollback state.
|
|
310
|
+
def self.capture_transaction_state(obj, context = Fiber[TRANSACTION_CONTEXT_KEY], include_created: false)
|
|
311
|
+
return unless context && obj
|
|
312
|
+
|
|
313
|
+
object_id = obj.object_id
|
|
314
|
+
return if context[:created_objects].key?(object_id) && !include_created
|
|
315
|
+
context[:snapshots][object_id] ||= snapshot_object_state(obj)
|
|
169
316
|
end
|
|
170
317
|
|
|
171
318
|
# Restore the values captured by {snapshot_property_values}.
|
|
@@ -180,6 +327,25 @@ module Parse
|
|
|
180
327
|
def self.restore_property_values(obj, snapshot)
|
|
181
328
|
return unless snapshot.is_a?(Hash)
|
|
182
329
|
snapshot.each do |ivar, value|
|
|
330
|
+
if value.equal?(UNDEFINED_PROPERTY)
|
|
331
|
+
obj.remove_instance_variable(ivar) if obj.instance_variable_defined?(ivar)
|
|
332
|
+
else
|
|
333
|
+
obj.instance_variable_set(ivar, value)
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Restore one instance variable while preserving whether it originally
|
|
339
|
+
# existed. Used for dirty/ACL bookkeeping outside the property schema.
|
|
340
|
+
#
|
|
341
|
+
# @param obj [Object] target object.
|
|
342
|
+
# @param ivar [Symbol] instance variable name.
|
|
343
|
+
# @param value [Object] snapshotted value or {UNDEFINED_PROPERTY}.
|
|
344
|
+
# @return [void]
|
|
345
|
+
def self.restore_instance_variable(obj, ivar, value)
|
|
346
|
+
if value.equal?(UNDEFINED_PROPERTY)
|
|
347
|
+
obj.remove_instance_variable(ivar) if obj.instance_variable_defined?(ivar)
|
|
348
|
+
else
|
|
183
349
|
obj.instance_variable_set(ivar, value)
|
|
184
350
|
end
|
|
185
351
|
end
|
|
@@ -193,16 +359,35 @@ module Parse
|
|
|
193
359
|
obj = state[:object]
|
|
194
360
|
return if obj.nil?
|
|
195
361
|
restore_property_values(obj, state[:property_values])
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
362
|
+
if state[:instance_variables]
|
|
363
|
+
state[:instance_variables].each do |ivar, value|
|
|
364
|
+
restore_instance_variable(obj, ivar, value)
|
|
365
|
+
end
|
|
366
|
+
else
|
|
367
|
+
# Compatibility with rollback states produced before the complete
|
|
368
|
+
# snapshot format was introduced.
|
|
369
|
+
obj.instance_variable_set(:@changed_attributes, state[:changed_attributes])
|
|
370
|
+
obj.instance_variable_set(:@id, state[:id])
|
|
371
|
+
obj.instance_variable_set(:@mutations_from_database, state[:mutations_from_database])
|
|
372
|
+
obj.instance_variable_set(:@mutations_before_last_save, state[:mutations_before_last_save])
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Hook used by generated property accessors and collection proxies.
|
|
377
|
+
# @api private
|
|
378
|
+
def _capture_transaction_state!
|
|
379
|
+
Parse::Core::Actions.capture_transaction_state(self)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# ActiveModel calls this immediately after `<field>_will_change!`; the
|
|
383
|
+
# hook also covers callers that explicitly mark a mutable value dirty.
|
|
384
|
+
def _read_attribute(attr_name)
|
|
385
|
+
_capture_transaction_state!
|
|
386
|
+
super
|
|
204
387
|
end
|
|
205
388
|
|
|
389
|
+
private :_capture_transaction_state!, :_read_attribute
|
|
390
|
+
|
|
206
391
|
# Class methods applied to Parse::Object subclasses.
|
|
207
392
|
module ClassMethods
|
|
208
393
|
|
|
@@ -238,123 +423,109 @@ module Parse
|
|
|
238
423
|
def transaction(retries: 5, &block)
|
|
239
424
|
raise ArgumentError, "Block required for transaction" unless block_given?
|
|
240
425
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
426
|
+
previous_context = Fiber[TRANSACTION_CONTEXT_KEY]
|
|
427
|
+
transaction_context = { snapshots: {}, created_objects: {} }
|
|
428
|
+
Fiber[TRANSACTION_CONTEXT_KEY] = transaction_context
|
|
244
429
|
original_states = {}
|
|
245
430
|
tracked_objects = []
|
|
246
431
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
432
|
+
begin
|
|
433
|
+
batch = Parse::BatchOperation.new(nil, transaction: true)
|
|
434
|
+
|
|
435
|
+
# Wrap the batch to associate the pre-mutation snapshot with each
|
|
436
|
+
# object that actually participates in the transaction.
|
|
437
|
+
batch_wrapper = Object.new
|
|
438
|
+
batch_wrapper.define_singleton_method(:is_a?) do |klass|
|
|
439
|
+
klass == Parse::BatchOperation || super(klass)
|
|
440
|
+
end
|
|
441
|
+
batch_wrapper.define_singleton_method(:kind_of?) do |klass|
|
|
442
|
+
klass == Parse::BatchOperation || super(klass)
|
|
443
|
+
end
|
|
444
|
+
batch_wrapper.define_singleton_method(:instance_of?) do |klass|
|
|
445
|
+
klass == Parse::BatchOperation
|
|
446
|
+
end
|
|
447
|
+
batch_wrapper.define_singleton_method(:add) do |obj|
|
|
448
|
+
# Ruby identity is required because all unsaved Parse objects
|
|
449
|
+
# compare equal while their ids are nil.
|
|
450
|
+
if obj.respond_to?(:attributes) && obj.respond_to?(:id) && !original_states.key?(obj.object_id)
|
|
451
|
+
original_states[obj.object_id] = Parse::Core::Actions.capture_transaction_state(
|
|
452
|
+
obj,
|
|
453
|
+
transaction_context,
|
|
454
|
+
include_created: true,
|
|
455
|
+
)
|
|
456
|
+
tracked_objects << obj
|
|
457
|
+
end
|
|
458
|
+
batch.add(obj)
|
|
273
459
|
end
|
|
274
|
-
batch.add(obj)
|
|
275
|
-
end
|
|
276
460
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
461
|
+
# Forward other methods to the real batch.
|
|
462
|
+
batch_wrapper.define_singleton_method(:method_missing) do |method, *args, &method_block|
|
|
463
|
+
batch.send(method, *args, &method_block)
|
|
464
|
+
end
|
|
465
|
+
batch_wrapper.define_singleton_method(:respond_to_missing?) do |method, include_private = false|
|
|
466
|
+
batch.respond_to?(method, include_private)
|
|
467
|
+
end
|
|
281
468
|
|
|
282
|
-
|
|
469
|
+
result = yield(batch_wrapper)
|
|
283
470
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
471
|
+
# If block returns objects, add them to batch.
|
|
472
|
+
if result.respond_to?(:change_requests)
|
|
473
|
+
batch_wrapper.add(result)
|
|
474
|
+
elsif result.is_a?(Array)
|
|
475
|
+
result.each { |obj| batch_wrapper.add(obj) if obj.respond_to?(:change_requests) }
|
|
476
|
+
end
|
|
290
477
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
obj.instance_variable_set(:@updated_at, Parse::Date.parse(result["updatedAt"]))
|
|
322
|
-
elsif result["createdAt"]
|
|
323
|
-
obj.instance_variable_set(:@updated_at, Parse::Date.parse(result["createdAt"]))
|
|
478
|
+
# Submit with retry logic for transaction conflicts.
|
|
479
|
+
attempts = 0
|
|
480
|
+
begin
|
|
481
|
+
attempts += 1
|
|
482
|
+
responses = batch.submit
|
|
483
|
+
|
|
484
|
+
if responses.all?(&:success?)
|
|
485
|
+
# Match responses to objects using the request tag (Ruby object_id).
|
|
486
|
+
objects_by_id = tracked_objects.each_with_object({}) { |o, h| h[o.object_id] = o }
|
|
487
|
+
batch.requests.zip(responses).each do |request, response|
|
|
488
|
+
next unless request && response && response.success?
|
|
489
|
+
result = response.result
|
|
490
|
+
next unless result.is_a?(Hash)
|
|
491
|
+
|
|
492
|
+
obj = objects_by_id[request.tag]
|
|
493
|
+
next unless obj
|
|
494
|
+
|
|
495
|
+
obj.instance_variable_set(:@id, result["objectId"]) if result["objectId"]
|
|
496
|
+
if result["createdAt"]
|
|
497
|
+
obj.instance_variable_set(:@created_at, Parse::Date.parse(result["createdAt"]))
|
|
498
|
+
end
|
|
499
|
+
if result["updatedAt"]
|
|
500
|
+
obj.instance_variable_set(:@updated_at, Parse::Date.parse(result["updatedAt"]))
|
|
501
|
+
elsif result["createdAt"]
|
|
502
|
+
obj.instance_variable_set(:@updated_at, Parse::Date.parse(result["createdAt"]))
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# Apply any additional attributes returned by beforeSave hooks.
|
|
506
|
+
obj.set_attributes!(result) if obj.respond_to?(:set_attributes!)
|
|
507
|
+
obj.send(:clear_changes!) if obj.respond_to?(:clear_changes!, true)
|
|
324
508
|
end
|
|
325
509
|
|
|
326
|
-
|
|
327
|
-
obj.set_attributes!(result) if obj.respond_to?(:set_attributes!)
|
|
328
|
-
|
|
329
|
-
# Clear change tracking since save was successful
|
|
330
|
-
obj.send(:clear_changes!) if obj.respond_to?(:clear_changes!, true)
|
|
331
|
-
end
|
|
332
|
-
|
|
333
|
-
return responses
|
|
334
|
-
else
|
|
335
|
-
# Find first error
|
|
336
|
-
error_response = responses.find { |r| !r.success? }
|
|
337
|
-
|
|
338
|
-
# Rollback local object states
|
|
339
|
-
original_states.each_value do |state|
|
|
340
|
-
Parse::Core::Actions.rollback_object_state(state)
|
|
510
|
+
return responses
|
|
341
511
|
end
|
|
342
512
|
|
|
513
|
+
error_response = responses.find { |response| !response.success? }
|
|
343
514
|
raise Parse::Error, "Transaction failed: #{error_response.error}"
|
|
515
|
+
rescue Parse::Error => e
|
|
516
|
+
if e.message.include?("251") && attempts < retries
|
|
517
|
+
sleep(0.1 * attempts)
|
|
518
|
+
retry
|
|
519
|
+
end
|
|
520
|
+
raise
|
|
344
521
|
end
|
|
345
|
-
rescue
|
|
346
|
-
# Retry on transaction conflict (error code 251)
|
|
347
|
-
if e.message.include?("251") && attempts < retries
|
|
348
|
-
sleep(0.1 * attempts) # Exponential backoff
|
|
349
|
-
retry
|
|
350
|
-
end
|
|
351
|
-
|
|
352
|
-
# Rollback local object states on final failure
|
|
522
|
+
rescue StandardError
|
|
353
523
|
original_states.each_value do |state|
|
|
354
524
|
Parse::Core::Actions.rollback_object_state(state)
|
|
355
525
|
end
|
|
356
|
-
|
|
357
|
-
|
|
526
|
+
raise
|
|
527
|
+
ensure
|
|
528
|
+
Fiber[TRANSACTION_CONTEXT_KEY] = previous_context
|
|
358
529
|
end
|
|
359
530
|
end
|
|
360
531
|
|
|
@@ -512,6 +512,10 @@ module Parse
|
|
|
512
512
|
@_fetched_keys ||= []
|
|
513
513
|
@_fetched_keys << key unless @_fetched_keys.include?(key)
|
|
514
514
|
end
|
|
515
|
+
|
|
516
|
+
# A transaction must capture the value before the setter marks or
|
|
517
|
+
# replaces it. `batch.add` happens after mutation in the public API.
|
|
518
|
+
send(:_capture_transaction_state!) if respond_to?(:_capture_transaction_state!, true)
|
|
515
519
|
end
|
|
516
520
|
end
|
|
517
521
|
end
|
|
@@ -573,6 +573,10 @@ module Parse
|
|
|
573
573
|
value = instance_variable_get ivar
|
|
574
574
|
end
|
|
575
575
|
|
|
576
|
+
# Capture after any implicit fetch, but before a default value or
|
|
577
|
+
# mutable collection is materialized by this getter.
|
|
578
|
+
send(:_capture_transaction_state!) if respond_to?(:_capture_transaction_state!, true)
|
|
579
|
+
|
|
576
580
|
# if value is nil (even after fetching), then lets see if the developer
|
|
577
581
|
# set a default value for this attribute.
|
|
578
582
|
if value.nil? && respond_to?("#{key}_default")
|
data/lib/parse/model/object.rb
CHANGED
|
@@ -1337,6 +1337,8 @@ module Parse
|
|
|
1337
1337
|
# for trusted hydration from server JSON; it bypasses the filter.
|
|
1338
1338
|
# @return [Parse::Object] a the corresponding Parse::Object or subclass.
|
|
1339
1339
|
def initialize(opts = {})
|
|
1340
|
+
Parse::Core::Actions.mark_transaction_object_created(self)
|
|
1341
|
+
|
|
1340
1342
|
# Trusted hydration is signalled by the `@_trusted_init` instance
|
|
1341
1343
|
# variable rather than by a `trusted:` keyword argument. Using a
|
|
1342
1344
|
# keyword would break subclasses that override `initialize(*args)`
|
|
@@ -2042,6 +2044,8 @@ module Parse
|
|
|
2042
2044
|
# caller intent to override.
|
|
2043
2045
|
# @api private
|
|
2044
2046
|
def acl_will_change!
|
|
2047
|
+
_capture_transaction_state!
|
|
2048
|
+
|
|
2045
2049
|
# Only capture snapshot on the first change (before any modifications)
|
|
2046
2050
|
unless defined?(@_acl_snapshot_before_change) && @_acl_snapshot_before_change
|
|
2047
2051
|
# Deep copy the ACL by creating a new one from its JSON representation
|
data/lib/parse/stack/version.rb
CHANGED
data/lib/parse/webhooks.rb
CHANGED
|
@@ -132,6 +132,46 @@ module Parse
|
|
|
132
132
|
|
|
133
133
|
class << self
|
|
134
134
|
|
|
135
|
+
# Whether an exception raised by one `after_*` handler prevents the
|
|
136
|
+
# remaining handlers for that same trigger from running.
|
|
137
|
+
#
|
|
138
|
+
# Only the accumulating, non-rejectable `after_*` triggers can have more
|
|
139
|
+
# than one handler (see {Parse::Webhooks::Registration#route}), so this
|
|
140
|
+
# governs `after_save`, `after_delete`, and `after_logout` and nothing
|
|
141
|
+
# else. `before_*` dispatch is untouched: a raise there is how a handler
|
|
142
|
+
# denies an operation, and it must continue to abort.
|
|
143
|
+
#
|
|
144
|
+
# Defaults to `true`, which is the historical behavior. Handlers are
|
|
145
|
+
# folded with `Array#map`, and `map` abandons the collection on the first
|
|
146
|
+
# raise, so a handler that raises silently prevents every handler
|
|
147
|
+
# registered after it from running. That ordering is not something an
|
|
148
|
+
# application fully controls: the SDK's own cache-invalidation triggers
|
|
149
|
+
# install during `Parse.setup` and therefore sit ahead of handlers
|
|
150
|
+
# registered by application files loaded later.
|
|
151
|
+
#
|
|
152
|
+
# Set to `false` to isolate handlers from each other, so that each one
|
|
153
|
+
# runs regardless of what an earlier one raised. The error is reported
|
|
154
|
+
# (a warning plus a `parse.webhooks.handler_error` notification) and
|
|
155
|
+
# dispatch continues.
|
|
156
|
+
#
|
|
157
|
+
# Either way, nothing is reverted. An `after_*` trigger fires once the
|
|
158
|
+
# write has already committed, so there is no version of this setting
|
|
159
|
+
# that can undo the save; the only question it answers is whether the
|
|
160
|
+
# remaining handlers still get to run.
|
|
161
|
+
#
|
|
162
|
+
# @example Keep one failing handler from starving the others
|
|
163
|
+
# Parse::Webhooks.abort_after_callbacks_on_error = false
|
|
164
|
+
#
|
|
165
|
+
# @return [Boolean]
|
|
166
|
+
attr_writer :abort_after_callbacks_on_error
|
|
167
|
+
|
|
168
|
+
# (see #abort_after_callbacks_on_error=)
|
|
169
|
+
# @return [Boolean]
|
|
170
|
+
def abort_after_callbacks_on_error
|
|
171
|
+
return @abort_after_callbacks_on_error unless @abort_after_callbacks_on_error.nil?
|
|
172
|
+
true
|
|
173
|
+
end
|
|
174
|
+
|
|
135
175
|
# Allows support for web frameworks that support auto-reloading of source.
|
|
136
176
|
# @!visibility private
|
|
137
177
|
def reload!(args = {})
|
|
@@ -245,6 +285,60 @@ module Parse
|
|
|
245
285
|
# block that declares a parameter (`do |payload| ... end`) or a splat
|
|
246
286
|
# receives the payload.
|
|
247
287
|
#
|
|
288
|
+
# Run every handler registered for one accumulating `after_*` trigger.
|
|
289
|
+
#
|
|
290
|
+
# `.last` is preserved as the composed result because Parse Server
|
|
291
|
+
# ignores the response body for these triggers and {#call_route}
|
|
292
|
+
# normalizes it anyway, so which handler's value survives is not
|
|
293
|
+
# observable.
|
|
294
|
+
#
|
|
295
|
+
# When {abort_after_callbacks_on_error} is false, a handler that raises
|
|
296
|
+
# is reported and skipped rather than taking the rest of the trigger down
|
|
297
|
+
# with it. Nothing is reverted in either mode: the write these triggers
|
|
298
|
+
# fire on has already committed.
|
|
299
|
+
#
|
|
300
|
+
# @param payload [Parse::Webhooks::Payload] the request payload.
|
|
301
|
+
# @param registry [Array<Proc>] the handlers, in registration order.
|
|
302
|
+
# @param type [Symbol] the trigger being dispatched.
|
|
303
|
+
# @return [Object] the last handler result.
|
|
304
|
+
def dispatch_composed(payload, registry, type)
|
|
305
|
+
return registry.map { |hook| invoke_handler(payload, hook) }.last if
|
|
306
|
+
abort_after_callbacks_on_error
|
|
307
|
+
|
|
308
|
+
last = nil
|
|
309
|
+
registry.each do |hook|
|
|
310
|
+
begin
|
|
311
|
+
last = invoke_handler(payload, hook)
|
|
312
|
+
rescue StandardError => e
|
|
313
|
+
report_handler_error(type, e)
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
last
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Report a handler failure that was isolated rather than propagated.
|
|
320
|
+
#
|
|
321
|
+
# The message is included because an application's own handler raised it
|
|
322
|
+
# and the application needs it to debug; this is not the SDK's internal
|
|
323
|
+
# `guard`, which deliberately omits messages that can carry a cache key.
|
|
324
|
+
#
|
|
325
|
+
# @param type [Symbol] the trigger being dispatched.
|
|
326
|
+
# @param error [StandardError] the raised error.
|
|
327
|
+
# @return [void]
|
|
328
|
+
def report_handler_error(type, error)
|
|
329
|
+
warn "[Parse::Webhooks] #{type} handler raised #{error.class}: #{error.message}; " \
|
|
330
|
+
"continuing with the remaining handlers " \
|
|
331
|
+
"(Parse::Webhooks.abort_after_callbacks_on_error is false)"
|
|
332
|
+
return unless defined?(ActiveSupport::Notifications)
|
|
333
|
+
begin
|
|
334
|
+
ActiveSupport::Notifications.instrument(
|
|
335
|
+
"parse.webhooks.handler_error", trigger: type, error: error.class.name,
|
|
336
|
+
)
|
|
337
|
+
rescue StandardError
|
|
338
|
+
nil
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
248
342
|
# @param payload [Parse::Webhooks::Payload] the request payload (becomes `self`).
|
|
249
343
|
# @param block [Proc] the registered handler block.
|
|
250
344
|
# @return [Object] the handler's result value.
|
|
@@ -378,7 +472,10 @@ module Parse
|
|
|
378
472
|
end
|
|
379
473
|
|
|
380
474
|
if registry.is_a?(Array)
|
|
381
|
-
|
|
475
|
+
# An Array registry only ever exists for the accumulating,
|
|
476
|
+
# non-rejectable `after_*` triggers, so isolating handlers here
|
|
477
|
+
# cannot affect `before_*` rejection semantics.
|
|
478
|
+
result = dispatch_composed(payload, registry, type)
|
|
382
479
|
else
|
|
383
480
|
result = invoke_handler(payload, registry)
|
|
384
481
|
end
|