cluster

Infrastructure files for Nordgedanken and Midnightthoughts.
git clone git://archive.git.mtrnord.blog/MTRNord/cluster.git
Log | Files | Refs | README

history.go (6943B)


      1 // history.go — room history pagination: forward incremental sync and DM
      2 // backward backfill.
      3 package main
      4 
      5 import (
      6 	"bytes"
      7 	"context"
      8 	"encoding/json"
      9 	"fmt"
     10 	"log/slog"
     11 
     12 	"maunium.net/go/mautrix"
     13 	"maunium.net/go/mautrix/event"
     14 	"maunium.net/go/mautrix/id"
     15 )
     16 
     17 // paginateRoom fetches room history and writes new events to a per-run
     18 // age-encrypted JSONL file in S3. For DMs on first run the full history is
     19 // fetched backwards from the current position before normal forward pagination.
     20 //
     21 // syncNextBatch is the nextBatch token from the current sync response. On first
     22 // run it is saved as the forward cursor so the next run only sees events that
     23 // arrive after this sync — avoiding re-fetching events already in the sync.
     24 func paginateRoom(
     25 	ctx context.Context,
     26 	client *mautrix.Client,
     27 	roomID id.RoomID,
     28 	prefix string,
     29 	isDM bool,
     30 	prevBatch string,
     31 	syncNextBatch string,
     32 	sessions megolmSessions,
     33 ) error {
     34 	safeKey := roomSafeKey(roomID)
     35 	cursorKey := prefix + "/history-cursor/" + safeKey + ".json"
     36 	dmHistoryDoneKey := prefix + "/history-cursor/" + safeKey + ".dm-done"
     37 
     38 	var cursor struct {
     39 		Token string `json:"token"`
     40 	}
     41 	if err := s3GetJSON(ctx, cursorKey, &cursor); err != nil {
     42 		return err
     43 	}
     44 
     45 	// fetchDMHistory does a full backward walk from fromToken and stores results.
     46 	// Used on first run for DMs and as catch-up when a room was initially missed.
     47 	fetchDMHistory := func(fromToken string) {
     48 		slog.Info("DM: fetching full history", "room_id", roomID, "from_token", fromToken)
     49 		var messages []historyEvent
     50 		token := fromToken
     51 		for {
     52 			resp, err := client.Messages(ctx, roomID, token, "", mautrix.DirectionBackward, nil, 100)
     53 			if err != nil {
     54 				slog.Warn("room_messages error (backward)", "room_id", roomID, "token", token, "error", err)
     55 				break
     56 			}
     57 			slog.Info("DM history page", "room_id", roomID, "chunk_size", len(resp.Chunk), "end", resp.End)
     58 			if len(resp.Chunk) == 0 {
     59 				break
     60 			}
     61 			for _, ev := range resp.Chunk {
     62 				wasEncrypted := ev.Type == event.EventEncrypted
     63 				ev = tryDecryptEvent(ev, sessions)
     64 				messages = append(messages, eventToRecord(ev, wasEncrypted))
     65 				processEvent(ctx, client, ev, roomID, prefix, true)
     66 			}
     67 			if resp.End == "" || resp.End == token {
     68 				break
     69 			}
     70 			token = resp.End
     71 		}
     72 		if len(messages) > 0 {
     73 			// Reverse so events are stored oldest-first.
     74 			for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
     75 				messages[i], messages[j] = messages[j], messages[i]
     76 			}
     77 			histKey := prefix + "/history/" + safeKey + "/" + runStr + "-backfill.jsonl"
     78 			var buf bytes.Buffer
     79 			for _, m := range messages {
     80 				line, err := json.Marshal(m)
     81 				if err != nil {
     82 					slog.Warn("Failed to marshal event, skipping", "event_id", m.EventID, "error", err)
     83 					continue
     84 				}
     85 				buf.Write(line)
     86 				buf.WriteByte('\n')
     87 			}
     88 			if err := s3PutAge(ctx, histKey, buf.Bytes()); err != nil {
     89 				slog.Warn("Failed to upload DM history", "room_id", roomID, "error", err)
     90 			} else {
     91 				slog.Info("DM history stored", "room_id", roomID, "events", len(messages))
     92 				if err := s3Put(ctx, dmHistoryDoneKey, []byte("done"), "text/plain"); err != nil {
     93 					slog.Warn("Failed to write dm-done marker", "room_id", roomID, "error", err)
     94 				}
     95 			}
     96 		} else {
     97 			// Nothing to backfill — mark done so we don't retry every run.
     98 			slog.Info("DM history: no messages found, marking done", "room_id", roomID)
     99 			if err := s3Put(ctx, dmHistoryDoneKey, []byte("done"), "text/plain"); err != nil {
    100 				slog.Warn("Failed to write dm-done marker", "room_id", roomID, "error", err)
    101 			}
    102 		}
    103 	}
    104 
    105 	if cursor.Token == "" {
    106 		// First time we've seen this room. For DMs, walk all the way back through
    107 		// history so we have a complete record from day one.
    108 		if isDM && prevBatch != "" {
    109 			fetchDMHistory(prevBatch)
    110 		}
    111 		// Save the sync's nextBatch as the forward cursor so the next run only
    112 		// picks up genuinely new events.
    113 		if syncNextBatch == "" {
    114 			syncNextBatch = prevBatch
    115 		}
    116 		if syncNextBatch != "" {
    117 			cursorJSON, err := json.Marshal(map[string]string{"token": syncNextBatch})
    118 			if err == nil {
    119 				if err := s3Put(ctx, cursorKey, cursorJSON, "application/json"); err != nil {
    120 					slog.Warn("Failed to save cursor (first run)", "room_id", roomID, "error", err)
    121 				}
    122 			}
    123 		}
    124 		return nil
    125 	}
    126 
    127 	// Incremental run: if this is a DM but we never completed a backward history
    128 	// fetch (e.g. room was missed because it wasn't in m.direct on first run),
    129 	// do it now using the saved cursor as the backward starting point.
    130 	// Note: prevBatch may be empty for rooms with no new events in a full_state
    131 	// sync, so we use cursor.Token directly rather than gating on prevBatch.
    132 	if isDM {
    133 		dmDoneData, _ := s3Get(ctx, dmHistoryDoneKey)
    134 		if dmDoneData == nil {
    135 			slog.Info("DM catch-up: no history marker found, backfilling", "room_id", roomID)
    136 			fetchDMHistory(cursor.Token)
    137 		}
    138 	}
    139 
    140 	// Incremental forward pagination from the saved cursor.
    141 	var messages []historyEvent
    142 	nextToken := cursor.Token
    143 	lastGoodToken := cursor.Token
    144 	histKey := prefix + "/history/" + safeKey + "/" + runStr + ".jsonl"
    145 
    146 	const maxPages = 100
    147 	pages := 0
    148 	for range maxPages {
    149 		pages++
    150 		resp, err := client.Messages(ctx, roomID, nextToken, "", mautrix.DirectionForward, nil, 100)
    151 		if err != nil {
    152 			slog.Warn("room_messages error (forward)", "room_id", roomID, "error", err)
    153 			break
    154 		}
    155 		if len(resp.Chunk) == 0 {
    156 			break
    157 		}
    158 		for _, ev := range resp.Chunk {
    159 			wasEncrypted := ev.Type == event.EventEncrypted
    160 			ev = tryDecryptEvent(ev, sessions)
    161 			messages = append(messages, eventToRecord(ev, wasEncrypted))
    162 			processEvent(ctx, client, ev, roomID, prefix, isDM)
    163 		}
    164 		if resp.End == "" || resp.End == nextToken {
    165 			lastGoodToken = nextToken
    166 			nextToken = ""
    167 			break
    168 		}
    169 		lastGoodToken = resp.End
    170 		nextToken = resp.End
    171 	}
    172 	if pages == maxPages && nextToken != "" {
    173 		slog.Warn("Pagination cap reached — room has more events; next run will continue",
    174 			"room_id", roomID, "pages", maxPages)
    175 		lastGoodToken = nextToken
    176 	}
    177 
    178 	if len(messages) > 0 {
    179 		var buf bytes.Buffer
    180 		for _, m := range messages {
    181 			line, err := json.Marshal(m)
    182 			if err != nil {
    183 				slog.Warn("Failed to marshal event, skipping", "event_id", m.EventID, "error", err)
    184 				continue
    185 			}
    186 			buf.Write(line)
    187 			buf.WriteByte('\n')
    188 		}
    189 		if err := s3PutAge(ctx, histKey, buf.Bytes()); err != nil {
    190 			slog.Warn("Failed to upload history", "room_id", roomID, "error", err)
    191 		}
    192 		slog.Info("History updated", "room_id", roomID, "new_events", len(messages))
    193 	}
    194 
    195 	// Advance the cursor so the next run never re-processes stored events.
    196 	if lastGoodToken != cursor.Token {
    197 		cursorJSON, err := json.Marshal(map[string]string{"token": lastGoodToken})
    198 		if err != nil {
    199 			return fmt.Errorf("marshal cursor: %w", err)
    200 		}
    201 		if err := s3Put(ctx, cursorKey, cursorJSON, "application/json"); err != nil {
    202 			slog.Warn("Failed to advance cursor", "room_id", roomID, "error", err)
    203 		}
    204 	}
    205 	return nil
    206 }