mirror of
https://github.com/discourse/discourse.git
synced 2024-11-24 19:34:09 +08:00
34ede90927
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.
298 lines
8.1 KiB
Ruby
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
|
|
#
|