FEATURE: unconditionally update Topic updated_at when posts change in topic

Previously we would bypass touching `Topic.updated_at` for whispers and post
recovery / deletions.

This meant that certain types of caching can not be done where we rely on
this information for cache accuracy.

For example if we know we have zero unread topics as of yesterday and whisper
is made I need to bump this date so the cache remains accurate

This is only half of a larger change but provides the groundwork.

Confirmed none of our serializers leak out Topic.updated_at so this is safe
spot for this info

At the moment edits still do not change this but it is not relevant for the
unread cache.

This commit also cleans up some specs to use the new `eq_time` matcher for
millisecond fidelity comparison of times

Previously `freeze_time` would fudge this which is not that clean.
This commit is contained in:
Sam Saffron 2019-03-28 17:28:01 +11:00
parent 5d134a6982
commit 9ebabc1de8
13 changed files with 96 additions and 15 deletions

View File

@ -419,16 +419,18 @@ class PostCreator
end end
def update_topic_stats def update_topic_stats
attrs = { updated_at: Time.now }
if @post.post_type != Post.types[:whisper] if @post.post_type != Post.types[:whisper]
attrs = {}
attrs[:last_posted_at] = @post.created_at attrs[:last_posted_at] = @post.created_at
attrs[:last_post_user_id] = @post.user_id attrs[:last_post_user_id] = @post.user_id
attrs[:word_count] = (@topic.word_count || 0) + @post.word_count attrs[:word_count] = (@topic.word_count || 0) + @post.word_count
attrs[:excerpt] = @post.excerpt_for_topic if new_topic? attrs[:excerpt] = @post.excerpt_for_topic if new_topic?
attrs[:bumped_at] = @post.created_at unless @post.no_bump attrs[:bumped_at] = @post.created_at unless @post.no_bump
attrs[:updated_at] = Time.now
@topic.update_columns(attrs) @topic.update_columns(attrs)
end end
@topic.update_columns(attrs)
end end
def update_topic_auto_close def update_topic_auto_close

View File

@ -89,6 +89,8 @@ class PostDestroyer
def staff_recovered def staff_recovered
@post.recover! @post.recover!
mark_topic_changed
if @post.topic && !@post.topic.private_message? if @post.topic && !@post.topic.private_message?
if author = @post.user if author = @post.user
if @post.is_first_post? if @post.is_first_post?
@ -121,6 +123,7 @@ class PostDestroyer
@post.trash!(@user) @post.trash!(@user)
if @post.topic if @post.topic
make_previous_post_the_last_one make_previous_post_the_last_one
mark_topic_changed
clear_user_posted_flag clear_user_posted_flag
Topic.reset_highest(@post.topic_id) Topic.reset_highest(@post.topic_id)
end end
@ -184,13 +187,33 @@ class PostDestroyer
private private
# we need topics to change if ever a post in them is deleted or created
# this ensures users relying on this information can keep unread tracking
# working as desired
def mark_topic_changed
# make this as fast as possible, can bypass everything
DB.exec(<<~SQL, updated_at: Time.now, id: @post.topic_id)
UPDATE topics
SET updated_at = :updated_at
WHERE id = :id
SQL
end
def make_previous_post_the_last_one def make_previous_post_the_last_one
last_post = Post.where("topic_id = ? and id <> ?", @post.topic_id, @post.id).order('created_at desc').limit(1).first last_post = Post
.select(:created_at, :user_id, :post_number)
.where("topic_id = ? and id <> ?", @post.topic_id, @post.id)
.order('created_at desc')
.limit(1)
.first
if last_post.present? && @post.topic.present? if last_post.present? && @post.topic.present?
topic = @post.topic topic = @post.topic
topic.last_posted_at = last_post.created_at topic.last_posted_at = last_post.created_at
topic.last_post_user_id = last_post.user_id topic.last_post_user_id = last_post.user_id
topic.highest_post_number = last_post.post_number topic.highest_post_number = last_post.post_number
# we go via save here cause we need to run hooks
topic.save!(validate: false) topic.save!(validate: false)
end end
end end

View File

@ -392,7 +392,7 @@ describe Auth::DefaultCurrentUserProvider do
provider2 = provider("/", "HTTP_COOKIE" => "_t=#{unhashed_token}") provider2 = provider("/", "HTTP_COOKIE" => "_t=#{unhashed_token}")
u = provider2.current_user u = provider2.current_user
u.reload u.reload
expect(u.last_seen_at).to eq(Time.now) expect(u.last_seen_at).to eq_time(Time.now)
freeze_time 20.minutes.from_now freeze_time 20.minutes.from_now

View File

@ -53,7 +53,7 @@ RSpec.describe SecondFactorManager do
token = user.totp.now token = user.totp.now
expect(user.authenticate_totp(token)).to eq(true) expect(user.authenticate_totp(token)).to eq(true)
expect(user.user_second_factors.totp.last_used).to eq(DateTime.now) expect(user.user_second_factors.totp.last_used).to eq_time(DateTime.now)
expect(user.authenticate_totp(token)).to eq(false) expect(user.authenticate_totp(token)).to eq(false)
end end
end end

View File

@ -374,8 +374,8 @@ describe PostCreator do
topic_timer.reload topic_timer.reload
expect(topic_timer.execute_at).to eq(Time.zone.now + 12.hours) expect(topic_timer.execute_at).to eq_time(Time.zone.now + 12.hours)
expect(topic_timer.created_at).to eq(Time.zone.now) expect(topic_timer.created_at).to eq_time(Time.zone.now)
end end
describe "when auto_close_topics_post_count has been reached" do describe "when auto_close_topics_post_count has been reached" do
@ -401,7 +401,7 @@ describe PostCreator do
)) ))
expect(topic.closed).to eq(true) expect(topic.closed).to eq(true)
expect(topic_timer.reload.deleted_at).to eq(Time.zone.now) expect(topic_timer.reload.deleted_at).to eq_time(Time.zone.now)
end end
end end
end end

View File

@ -11,6 +11,6 @@ describe 'user api keys integration' do
get '/session/current.json', headers: { get '/session/current.json', headers: {
HTTP_USER_API_KEY: user_api_key.key, HTTP_USER_API_KEY: user_api_key.key,
} }
expect(user_api_key.reload.last_used_at).to eq(Time.zone.now) expect(user_api_key.reload.last_used_at).to eq_time(Time.zone.now)
end end
end end

View File

@ -64,7 +64,7 @@ describe Jobs::ToggleTopicClosed do
topic_timer = topic.public_topic_timer topic_timer = topic.public_topic_timer
expect(topic_timer.status_type).to eq(TopicTimer.types[:close]) expect(topic_timer.status_type).to eq(TopicTimer.types[:close])
expect(topic_timer.execute_at).to eq(5.hours.from_now) expect(topic_timer.execute_at).to eq_time(5.hours.from_now)
end end
end end
end end

View File

@ -215,7 +215,9 @@ shared_examples "remote backup store" do
describe "#upload_file" do describe "#upload_file" do
def upload_file def upload_file
freeze_time # time has fidelity issues freeze a time that is not going to be prone
# to that
freeze_time(Time.now.to_s)
backup = BackupFile.new( backup = BackupFile.new(
filename: "foo.tar.gz", filename: "foo.tar.gz",

View File

@ -946,7 +946,7 @@ describe PostAction do
expect(topic.reload.closed).to eq(true) expect(topic.reload.closed).to eq(true)
timer = TopicTimer.last timer = TopicTimer.last
expect(timer.execute_at).to eq(1.hour.from_now) expect(timer.execute_at).to eq_time(1.hour.from_now)
freeze_time timer.execute_at freeze_time timer.execute_at
Jobs.expects(:enqueue_in).with( Jobs.expects(:enqueue_in).with(

View File

@ -1311,4 +1311,40 @@ describe Post do
end end
end end
context 'topic updated_at' do
let :topic do
create_post.topic
end
def updates_topic_updated_at
freeze_time 1.day.from_now
time = Time.now
result = yield
topic.reload
expect(topic.updated_at).to eq_time(time)
result
end
it "will update topic updated_at for all topic related events" do
SiteSetting.enable_whispers = true
post = updates_topic_updated_at do
create_post(topic_id: topic.id, post_type: Post.types[:whisper])
end
updates_topic_updated_at do
PostDestroyer.new(Discourse.system_user, post).destroy
end
updates_topic_updated_at do
PostDestroyer.new(Discourse.system_user, post).recover
end
end
end
end end

View File

@ -1582,7 +1582,7 @@ describe Topic do
closing_topic.set_or_create_timer(TopicTimer.types[:open], nil) closing_topic.set_or_create_timer(TopicTimer.types[:open], nil)
topic_timer = closing_topic.public_topic_timer topic_timer = closing_topic.public_topic_timer
expect(topic_timer.execute_at).to eq(5.hours.from_now) expect(topic_timer.execute_at).to eq_time(5.hours.from_now)
expect(topic_timer.status_type).to eq(TopicTimer.types[:close]) expect(topic_timer.status_type).to eq(TopicTimer.types[:close])
end end

View File

@ -278,8 +278,17 @@ def set_cdn_url(cdn_url)
end end
def freeze_time(now = Time.now) def freeze_time(now = Time.now)
datetime = DateTime.parse(now.to_s) time = now
time = Time.parse(now.to_s) datetime = now
if Time === now
datetime = now.to_datetime
elsif DateTime === now
time = now.to_time
else
datetime = DateTime.parse(now.to_s)
time = Time.parse(now.to_s)
end
if block_given? if block_given?
raise "nested freeze time not supported" if TrackTimeStub.stubbed raise "nested freeze time not supported" if TrackTimeStub.stubbed

View File

@ -6,3 +6,12 @@ RSpec::Matchers.define :be_within_one_second_of do |expected_time|
"#{actual_time} is not within 1 second of #{expected_time}" "#{actual_time} is not within 1 second of #{expected_time}"
end end
end end
RSpec::Matchers.define :eq_time do |expected_time|
match do |actual_time|
(actual_time - expected_time).abs < 0.001
end
failure_message do |actual_time|
"#{actual_time} is not within 1 millisecond of #{expected_time}"
end
end