···
"Coves/internal/core/posts"
"Coves/internal/core/users"
···
postRepo posts.Repository
communityRepo communities.Repository
userService users.UserService
23
+
db *sql.DB // Direct DB access for atomic count reconciliation
// NewPostEventConsumer creates a new Jetstream consumer for post events
···
postRepo posts.Repository,
communityRepo communities.Repository,
userService users.UserService,
return &PostEventConsumer{
communityRepo: communityRepo,
userService: userService,
···
130
-
// Index in AppView database (idempotent - safe for Jetstream replays)
131
-
err = c.postRepo.Create(ctx, post)
133
-
// Check if it already exists (idempotency)
134
-
if posts.IsConflict(err) {
135
-
log.Printf("Post already indexed: %s", uri)
138
-
return fmt.Errorf("failed to index post: %w", err)
134
+
// Atomically: Index post + Reconcile comment count for out-of-order arrivals
135
+
if err := c.indexPostAndReconcileCounts(ctx, post); err != nil {
136
+
return fmt.Errorf("failed to index post and reconcile counts: %w", err)
log.Printf("✓ Indexed post: %s (author: %s, community: %s, rkey: %s)",
uri, post.AuthorDID, post.CommunityDID, commit.RKey)
144
+
// indexPostAndReconcileCounts atomically indexes a post and reconciles comment counts
145
+
// This fixes the race condition where comments arrive before their parent post
146
+
func (c *PostEventConsumer) indexPostAndReconcileCounts(ctx context.Context, post *posts.Post) error {
147
+
tx, err := c.db.BeginTx(ctx, nil)
149
+
return fmt.Errorf("failed to begin transaction: %w", err)
152
+
if rollbackErr := tx.Rollback(); rollbackErr != nil && rollbackErr != sql.ErrTxDone {
153
+
log.Printf("Failed to rollback transaction: %v", rollbackErr)
157
+
// 1. Insert the post (idempotent with RETURNING clause)
158
+
var facetsJSON, embedJSON, labelsJSON sql.NullString
160
+
if post.ContentFacets != nil {
161
+
facetsJSON.String = *post.ContentFacets
162
+
facetsJSON.Valid = true
165
+
if post.Embed != nil {
166
+
embedJSON.String = *post.Embed
167
+
embedJSON.Valid = true
170
+
if post.ContentLabels != nil {
171
+
labelsJSON.String = *post.ContentLabels
172
+
labelsJSON.Valid = true
176
+
INSERT INTO posts (
177
+
uri, cid, rkey, author_did, community_did,
178
+
title, content, content_facets, embed, content_labels,
179
+
created_at, indexed_at
181
+
$1, $2, $3, $4, $5,
182
+
$6, $7, $8, $9, $10,
185
+
ON CONFLICT (uri) DO NOTHING
190
+
insertErr := tx.QueryRowContext(
192
+
post.URI, post.CID, post.RKey, post.AuthorDID, post.CommunityDID,
193
+
post.Title, post.Content, facetsJSON, embedJSON, labelsJSON,
197
+
// If no rows returned, post already exists (idempotent - OK for Jetstream replays)
198
+
if insertErr == sql.ErrNoRows {
199
+
log.Printf("Post already indexed: %s (idempotent)", post.URI)
200
+
if commitErr := tx.Commit(); commitErr != nil {
201
+
return fmt.Errorf("failed to commit transaction: %w", commitErr)
206
+
if insertErr != nil {
207
+
return fmt.Errorf("failed to insert post: %w", insertErr)
210
+
// 2. Reconcile comment_count for this newly inserted post
211
+
// In case any comments arrived out-of-order before this post was indexed
212
+
// This is the CRITICAL FIX for the race condition identified in the PR review
213
+
reconcileQuery := `
215
+
SET comment_count = (
218
+
WHERE c.parent_uri = $1 AND c.deleted_at IS NULL
222
+
_, reconcileErr := tx.ExecContext(ctx, reconcileQuery, post.URI, postID)
223
+
if reconcileErr != nil {
224
+
log.Printf("Warning: Failed to reconcile comment_count for %s: %v", post.URI, reconcileErr)
225
+
// Continue anyway - this is a best-effort reconciliation
228
+
// Commit transaction
229
+
if err := tx.Commit(); err != nil {
230
+
return fmt.Errorf("failed to commit transaction: %w", err)