Atomic Helpers
The atomic_helpers
plugin provides API for retrieving and
persisting attachments in a concurrency-safe way, which is especially useful
when using the backgrounding
plugin. The database plugins (activerecord
and sequel
) implement atomic promotion and atomic persistence on top of this
plugin.
plugin :atomic_helpers
Problem Statement
What happens if two different processors (web workers, background jobs, command-line executions, whatever) try to edit a shrine attachment concurrently? The kinds of edits typically made include: "promoting a file", moving it to a different storage and persisting that change in the model; adding or changing a derivative; adding or changing a metadata element.
There are two main categories of "race condition":
The file could be switched out from under you. If you were promoting a file, but some other process has changed the attachment, you don't want to overwrite it with the promomoted version of the prior attacchment. Likewise, if you were adding metadata or a derivative, they would be corresponding to a certain attachment, and you don't want to accidentally add them to a now changed attacchment for which they are inappropriate.
Overwriting each other's edits. Since all shrine (meta)data is stored in a single JSON hash, standard implementations will write the entire JSON hash at once to a rdbms column or other store. If two processes both read in the hash, make a change to different keys in it, and then write it back out, the second process to write will 'win' and overwrite changes made by the first.
The atomic helpers give you tools to avoid both of these sorts of race conditions, under conditions of concurrent editing.
High-level ORM helpers
If you are using the sequel
or activerecord
plugins, they give you two
higher-level helpers: atomic_persist
and atomic_promote
. See the
persistence documentation for more.
Retrieving
The Attacher.retrieve
method provided by the plugin instantiates an attacher
from a record instance, attachment name and attachment data, asserting that the
given attachment data matches the attached file on the record.
# with a model instance
Shrine::Attacher.retrieve(
model: photo,
name: :image,
file: { "id" => "abc123", "storage" => "cache" },
)
#=> #<Shrine::Attacher ...>
# with an entity instance
Shrine::Attacher.retrieve(
entity: photo,
name: :image,
file: { "id" => "abc123", "storage" => "cache" },
)
#=> #<Shrine::Attacher ...>
If the record has Shrine::Attachment
included, the #<name>_attacher
method
will be called on the record, which will return the correct attacher class.
class Photo
include ImageUploader::Attachment(:image)
end
Shrine::Attacher.retrieve(model: photo, name: :image, file: { ... })
#=> #<ImageUploader::Attacher ...>
Otherwise it will call Attacher.from_model
/Attacher.from_entity
from the
model
/entity
plugin, in which case you need to make sure to call
Attacher.retrieve
on the appropriate attacher class.
ImageUploader::Attacher.retrieve(entity: photo, name: :image, file: { ... })
#=> #<ImageUploader::Attacher ...>
If the attached file on the record doesn't match the provided attachment data,
a Shrine::AttachmentChanged
exception is raised. Note that metadata is
allowed to differ, Shrine will only compare location and storage of the file.
photo.image_data #=> '{"id":"foo","storage":"store","metadata":{...}}'
Shrine::Attacher.retrieve(
model: photo,
name: :image,
file: { "id" => "bar", "storage" => "store" },
)
# ~> Shrine::AttachmentChanged: attachment has changed
File data
The Attacher#file_data
method can be used for sending the attached file data
into a background job. It returns only location and storage of the attached
file, leaving out any metadata or derivatives data that Attacher#data
would
return. This way the background job payload is kept light.
attacher.file_data #=> { "id" => "abc123", "storage" => "store" }
This value can then be passed as the :file
argument to
Shrine::Attacher.retrieve
.
Promoting
The Attacher#abstract_atomic_promote
method provided by the plugin promotes
the cached file to permanent storage, reloads the record to check whether the
attachment hasn't changed, and if not persists the promoted file.
Internally it calls Attacher#abstract_atomic_persist
to do the persistence,
forwarding :reload
and :persist
options as well as a given block to it (see
the next section for more details).
# in the controller
attacher.attach_cached(io)
attacher.cached? #=> true
# in a background job
attacher.abstract_atomic_promote(reload: -> (&block) { ... }, persist: -> { ... })
attacher.stored? #=> true
If the attachment has changed during promotion, the promoted file is deleted and
a Shrine::AttachmentChanged
exception is raised.
If you want to execute some code after the attachment change check but before persistence, you can pass a block:
attacher.abstract_atomic_promote(**options) do |reloaded_attacher|
# this will be executed before persistence
end
Any additional options to Attacher#abstract_atomic_promote
are forwarded to
Attacher#promote
.
Persisting
The Attacher#abstract_atomic_persist
method reloads the record to check
whether the attachment hasn't changed, and if not persists the attachment.
It requires reloader and persister to be passed in, as they will be specific to the database library you're using. The reloader needs to call the given block with the reloaded record, while the persister needs to persist the promoted file.
attacher.abstract_atomic_persist(
reload: -> (&block) { ... }, # call the block with reloaded record
persist: -> { ... }, # persist promoted file
)
To illustrate, this is how the Attacher#atomic_promote
method provided by the
sequel
plugin is implemented:
attacher.abstract_atomic_persist(
reload: -> (&block) {
attacher.record.db.transaction do
block.call attacher.record.dup.lock! # the DB lock ensures no changes
end
},
persist: -> {
attacher.record.save_changes(validate: false)
}
)
By default, the file currently set on the attacher will be considered the original file, and will be compared to the reloaded record. You can specify a different original file:
original_file = attacher.file
attacher.set(new_file)
attacher.abstract_atomic_persist(original_file, **options)
If you want to execute some code after the attachment change check but before persistence, you can pass a block:
attacher.abstract_atomic_persist(**options) do |reloaded_attacher|
# this will be executed before persistence
end