Discourse Onboarding LabChapter III — ActiveRecord Models: Where Business Logic Lives0/6Contents
Chapter III

ActiveRecord Models: Where Business Logic Lives

Topic and Post are the two central ActiveRecord models in Discourse: Topic owns the thread (title, category, status, thumbnails) and Post owns each reply (cooking raw text to HTML, revisions, moderation). Their before_save/after_save/after_commit callbacks are the wiring that turns a plain database write into async side effects — enqueuing Sidekiq jobs, reindexing search, and publishing MessageBus events that push live updates to connected browsers. Because nearly every controller, serializer, job, and plugin touches these two models directly, understanding their callback chains is the fastest way to understand how the rest of the system reacts to a single save.


walkthrough

Post#trigger_post_process: handing work off to a background job

app/models/post.rb · lines 9821003
lines 982–988

Called after a post is created or rebaked. Options let the caller tune the job: whether to bypass topic bumping, run at a lower priority, mark it as a brand-new post, or skip pulling hotlinked images.

lines 989–995

Builds the payload that will be serialized and handed to the job queue — note it only carries the post_id, not the model itself, since the job runs in a separate process/worker.

lines 1001–1002

Jobs.enqueue hands the payload to Sidekiq — the actual cooking/onebox/thumbnail work happens later, asynchronously. DiscourseEvent.trigger runs synchronously in-process right here, giving plugins a hook without waiting on the job queue.

  # Enqueue post processing for this post  def trigger_post_process(    bypass_bump: false,    priority: :normal,    new_post: false,    skip_pull_hotlinked_images: false  )    args = {      bypass_bump: bypass_bump,      cooking_options: cooking_options,      new_post: new_post,      post_id: id,      skip_pull_hotlinked_images: skip_pull_hotlinked_images,    }    args[:image_sizes] = image_sizes if image_sizes.present?    args[:invalidate_oneboxes] = true if invalidate_oneboxes.present?    args[:queue] = priority.to_s if priority && priority != :normal    Jobs.enqueue(:process_post, args)    DiscourseEvent.trigger(:after_trigger_post_process, self)  end
step 1 of 3
  def publish_change_to_clients!(type, opts = {})    # special failsafe for posts missing topics consistency checks should fix,    # but message is safe to skip    return unless topic    skip_topic_stats = opts.delete(:skip_topic_stats)    message = {      id: id,      post_number: post_number,      updated_at: Time.now,      user_id: user_id,      last_editor_id: last_editor_id,      type: type,      version: version,    }.merge(opts)    publish_message!("/topic/#{topic_id}", message)    Topic.publish_stats_to_clients!(topic.id, type) unless skip_topic_stats  end  def publish_message!(channel, message, opts = {})    return unless topic    if Topic.visible_post_types.include?(post_type)      opts.merge!(topic.secure_audience_publish_messages)    else      opts[:user_ids] = User.human_users.where("admin OR moderator OR id = ?", user_id).pluck(:id)    end    MessageBus.publish(channel, message, opts) if opts[:user_ids] != [] && opts[:group_ids] != []  end
app/models/post.rb · lines 222253
Figure I · This is the live-update path: whenever a post is created, edited, or rebaked, publish_change_to_clients! builds a small message and hands it to publish_message!, which restricts the audience (regular topic vs. whisper/staff-only) before pushing it over MessageBus — this is what makes new replies and edits appear in open browser tabs without a refresh.
walkthrough

Topic's after_save: reindexing and tag bookkeeping

app/models/topic.rb · lines 425444
lines 425–430

If the topic just became (or stopped being) a banner, the cached banner JSON is invalidated so the next page load picks up the change.

lines 432–441

Title, category, or tag changes queue a search reindex, and if tags changed, TagUser watch/track state is recalculated for users who follow those tags — the tags_changed flag is a manual attr_accessor set elsewhere and reset here.

line 443

Every save re-indexes the topic for search regardless of which attribute changed — a simple but broad-brush way to keep search data fresh.

  after_save do    banner = "banner"    if archetype_before_last_save == banner || archetype == banner      ApplicationLayoutPreloader.banner_json_cache.clear    end    if tags_changed || saved_change_to_attribute?(:category_id) ||         saved_change_to_attribute?(:title)      SearchIndexer.queue_post_reindex(id)      if tags_changed        TagUser.auto_watch(topic_id: id)        TagUser.auto_track(topic_id: id)        self.tags_changed = false      end    end    SearchIndexer.index(self)  end
step 1 of 3
How a model save fans out
Topicapp/models/topic.rb
PostCreatorapp/models/topic.rb
SearchIndexerapp/models/topic.rb
TopicStatusUpdaterapp/models/topic.rb
Postapp/models/post.rb
Jobs (Sidekiq)app/models/post.rb
MessageBusapp/models/post.rb
Figure II · Click a node to see what it does, where it lives in the code, and its labeled connections — the annotation opens beside it.
checkpoint

Check your understanding

Answered in place — nothing is graded, everything is explained. 0 / 3 passed

What does Post#trigger_post_process actually do?

Why does Topic#changed_to_category wrap its Jobs.enqueue calls in DB.after_commit blocks?

Which call is responsible for the live, real-time push to browsers viewing a topic when a post changes?