discourse/app/models/draft.rb
Sam Saffron 34ede90927 FIX: under rare conditions saving a new draft could error temporarily
Under rare conditions due to bad HTTP timing and so on a draft could be
set at the exact same time from 2 unicorn workers.

When this happened and all the stars aligned, one of the sets would win
and the other would raise an error.

This transparently handles the situation without adding any cost to the
draft system.

The alternative is to add a distributed mutex, tricky DB transaction or handle
the error in the controller. However this seems like a reasonable way to
work around a pretty big edge case.
2020-01-02 11:38:14 +11:00

298 lines
8.1 KiB
Ruby

# frozen_string_literal: true
class Draft < ActiveRecord::Base
NEW_TOPIC ||= 'new_topic'
NEW_PRIVATE_MESSAGE ||= 'new_private_message'
EXISTING_TOPIC ||= 'topic_'
class OutOfSequence < StandardError; end
def self.set(user, key, sequence, data, owner = nil, retry_not_unique: true)
if SiteSetting.backup_drafts_to_pm_length > 0 && SiteSetting.backup_drafts_to_pm_length < data.length
backup_draft(user, key, sequence, data)
end
# this is called a lot so we should micro optimize here
draft_id, current_owner, current_sequence = DB.query_single(<<~SQL, user_id: user.id, key: key)
WITH draft AS (
SELECT id, owner FROM drafts
WHERE
user_id = :user_id AND
draft_key = :key
),
draft_sequence AS (
SELECT sequence
FROM draft_sequences
WHERE
user_id = :user_id AND
draft_key = :key
)
SELECT
(SELECT id FROM draft),
(SELECT owner FROM draft),
(SELECT sequence FROM draft_sequence)
SQL
current_sequence ||= 0
if draft_id
if current_sequence != sequence
raise Draft::OutOfSequence
end
if owner && current_owner && current_owner != owner
sequence += 1
DraftSequence.upsert({
sequence: sequence,
draft_key: key,
user_id: user.id,
},
unique_by: [:user_id, :draft_key]
)
end
DB.exec(<<~SQL, id: draft_id, sequence: sequence, data: data, owner: owner || current_owner)
UPDATE drafts
SET sequence = :sequence
, data = :data
, revisions = revisions + 1
, owner = :owner
WHERE id = :id
SQL
elsif sequence != current_sequence
raise Draft::OutOfSequence
else
begin
Draft.create!(
user_id: user.id,
draft_key: key,
data: data,
sequence: sequence,
owner: owner
)
rescue ActiveRecord::RecordNotUnique => e
# we need this to be fast and with minimal locking, in some cases we can have a race condition
# around 2 controller actions calling for draft creation at the exact same time
# to avoid complex locking and a distributed mutex, since this is so rare, simply add a single retry
if retry_not_unique
set(user, key, sequence, data, owenr, retry_not_unique: false)
else
raise e
end
end
end
sequence
end
def self.get(user, key, sequence)
opts = {
user_id: user.id,
draft_key: key,
sequence: sequence
}
current_sequence, data, draft_sequence = DB.query_single(<<~SQL, opts)
WITH draft AS (
SELECT data, sequence
FROM drafts
WHERE draft_key = :draft_key AND user_id = :user_id
),
draft_sequence AS (
SELECT sequence
FROM draft_sequences
WHERE draft_key = :draft_key AND user_id = :user_id
)
SELECT
( SELECT sequence FROM draft_sequence) ,
( SELECT data FROM draft ),
( SELECT sequence FROM draft )
SQL
current_sequence ||= 0
if sequence != current_sequence
raise Draft::OutOfSequence
end
data if current_sequence == draft_sequence
end
def self.clear(user, key, sequence)
current_sequence = DraftSequence.current(user, key)
# bad caller is a reason to complain
if sequence != current_sequence
raise Draft::OutOfSequence
end
# corrupt data is not a reason not to leave data
Draft.where(user_id: user.id, draft_key: key).destroy_all
end
def self.stream(opts = nil)
opts ||= {}
user_id = opts[:user].id
offset = (opts[:offset] || 0).to_i
limit = (opts[:limit] || 30).to_i
# JOIN of topics table based on manipulating draft_key seems imperfect
builder = DB.build <<~SQL
SELECT
d.*, t.title, t.id topic_id, t.archetype,
t.category_id, t.closed topic_closed, t.archived topic_archived,
pu.username, pu.name, pu.id user_id, pu.uploaded_avatar_id, pu.username_lower,
du.username draft_username, NULL as raw, NULL as cooked, NULL as post_number
FROM drafts d
LEFT JOIN LATERAL json_extract_path_text (d.data::json, 'postId') postId ON TRUE
LEFT JOIN posts p ON postId :: BIGINT = p.id
LEFT JOIN topics t ON
CASE
WHEN d.draft_key LIKE '%' || '#{EXISTING_TOPIC}' || '%'
THEN CAST(replace(d.draft_key, '#{EXISTING_TOPIC}', '') AS INT)
ELSE 0
END = t.id
JOIN users pu on pu.id = COALESCE(p.user_id, t.user_id, d.user_id)
JOIN users du on du.id = #{user_id}
/*where*/
/*order_by*/
/*offset*/
/*limit*/
SQL
builder
.where('d.user_id = :user_id', user_id: user_id.to_i)
.order_by('d.updated_at desc')
.offset(offset)
.limit(limit)
.query
end
def self.cleanup!
DB.exec(<<~SQL)
DELETE FROM drafts
WHERE sequence < (
SELECT MAX(s.sequence)
FROM draft_sequences s
WHERE s.draft_key = drafts.draft_key
AND s.user_id = drafts.user_id
)
SQL
# remove old drafts
delete_drafts_older_than_n_days = SiteSetting.delete_drafts_older_than_n_days.days.ago
Draft.where("updated_at < ?", delete_drafts_older_than_n_days).destroy_all
end
def self.backup_draft(user, key, sequence, data)
reply = JSON.parse(data)["reply"] || ""
return if reply.length < SiteSetting.backup_drafts_to_pm_length
post_id = BackupDraftPost.where(user_id: user.id, key: key).pluck_first(:post_id)
post = Post.where(id: post_id).first if post_id
if post_id && !post
BackupDraftPost.where(user_id: user.id, key: key).delete_all
end
indented_reply = reply.split("\n").map! do |l|
" #{l}"
end
draft_body = <<~MD
#{indented_reply.join("\n")}
```text
seq: #{sequence}
key: #{key}
```
MD
return if post && post.raw == draft_body
if !post
topic = ensure_draft_topic!(user)
Post.transaction do
post = PostCreator.new(
user,
raw: draft_body,
skip_jobs: true,
skip_validations: true,
topic_id: topic.id,
).create
BackupDraftPost.create!(user_id: user.id, key: key, post_id: post.id)
end
elsif post.last_version_at > 5.minutes.ago
# bypass all validations here to maximize speed
post.update_columns(
raw: draft_body,
cooked: PrettyText.cook(draft_body),
updated_at: Time.zone.now
)
else
revisor = PostRevisor.new(post, post.topic)
revisor.revise!(user, { raw: draft_body },
bypass_bump: true,
skip_validations: true,
skip_staff_log: true,
bypass_rate_limiter: true
)
end
rescue => e
Discourse.warn_exception(e, message: "Failed to backup draft")
end
def self.ensure_draft_topic!(user)
topic_id = BackupDraftTopic.where(user_id: user.id).pluck_first(:topic_id)
topic = Topic.find_by(id: topic_id) if topic_id
if topic_id && !topic
BackupDraftTopic.where(user_id: user.id).delete_all
end
if !topic
Topic.transaction do
creator = PostCreator.new(
user,
title: I18n.t("draft_backup.pm_title"),
archetype: Archetype.private_message,
raw: I18n.t("draft_backup.pm_body"),
skip_jobs: true,
skip_validations: true,
target_usernames: user.username
)
topic = creator.create.topic
BackupDraftTopic.create!(topic_id: topic.id, user_id: user.id)
end
end
topic
end
end
# == Schema Information
#
# Table name: drafts
#
# id :integer not null, primary key
# user_id :integer not null
# draft_key :string not null
# data :text not null
# created_at :datetime not null
# updated_at :datetime not null
# sequence :integer default(0), not null
# revisions :integer default(1), not null
# owner :string
#
# Indexes
#
# index_drafts_on_user_id_and_draft_key (user_id,draft_key) UNIQUE
#