mirror of
https://github.com/binwiederhier/ntfy.git
synced 2026-11-10 14:44:04 +08:00
169 lines
5.8 KiB
Go
169 lines
5.8 KiB
Go
package message
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"heckel.io/ntfy/v2/model"
|
|
)
|
|
|
|
const (
|
|
tailPageSize = 1000 // Rows per query
|
|
tailMaxPages = 10 // Queries per read at most, so a read drains a backlog of up to 10,000 rows
|
|
tailHoleTimeout = time.Minute // How long a skipped row id is re-checked before it counts as never committed (rolled back)
|
|
tailMaxHoles = 10000 // Bounds the skipped row ids re-checked on every call
|
|
)
|
|
|
|
// Tail reads the messages written to the cache in write order (by row id), so a node can find
|
|
// what it never received live. Row ids are handed out on insert but only become visible on commit,
|
|
// and concurrent writers (cluster nodes) commit out of order: a row id the tail had to skip is a
|
|
// hole, and is re-checked until its row shows up or the hole times out.
|
|
type Tail struct {
|
|
cache *Cache
|
|
cursor int64 // Highest row id read so far
|
|
holes map[int64]time.Time // Skipped row ids below the cursor, and when they were skipped
|
|
lastRead atomic.Int64 // Unix nanoseconds of the last successful read (see LastRead); 0 before the first
|
|
done chan struct{} // Closed by Close, to stop the loop calling Next
|
|
closed sync.Once // Guards closing done
|
|
}
|
|
|
|
// Next returns the published messages written since the last call, in write order, reading
|
|
// pages until it is caught up or has read tailMaxPages. It reads the primary: a replica may lag,
|
|
// and what it does not show yet would be skipped as a hole.
|
|
func (t *Tail) Next() ([]*model.Message, error) {
|
|
defer t.expireHoles()
|
|
messages := make([]*model.Message, 0)
|
|
for i := 0; i < tailMaxPages; i++ {
|
|
page, full, err := t.nextPage()
|
|
if err != nil {
|
|
if len(messages) > 0 {
|
|
t.lastRead.Store(time.Now().UnixNano())
|
|
return messages, nil // The pages read so far are done; the failed one is retried next time
|
|
}
|
|
return nil, err
|
|
}
|
|
messages = append(messages, page...)
|
|
if !full {
|
|
break
|
|
}
|
|
}
|
|
t.lastRead.Store(time.Now().UnixNano())
|
|
return messages, nil
|
|
}
|
|
|
|
// nextPage reads one page of rows: those after the cursor, plus the holes (row ids skipped
|
|
// earlier because they were not committed yet). It returns the published messages among them, and
|
|
// true if the page was full, so more may be waiting. For each row:
|
|
// - a hole that is now visible is "filled": it is removed from the holes
|
|
// - a row after the cursor moves the cursor to it; the ids it jumped over are "skipped", and
|
|
// become holes
|
|
//
|
|
// Example: cursor 103, holes {104}; the page returns rows 104, 105 and 108. Row 104 fills its
|
|
// hole, 105 moves the cursor, and 108 moves it again, skipping 106 and 107. Afterwards: cursor
|
|
// 108, holes {106, 107}. Nothing changes if the read fails part way, so it is retried whole.
|
|
func (t *Tail) nextPage() ([]*model.Message, bool, error) {
|
|
holes := make([]int64, 0, len(t.holes))
|
|
for id := range t.holes {
|
|
holes = append(holes, id)
|
|
}
|
|
holesJSON, err := json.Marshal(holes) // One parameter for any number of holes, so the statement text never changes
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
rows, err := t.cache.db.Primary().Query(t.cache.queries.selectMessagesAfterRowID, t.cursor, string(holesJSON), tailPageSize)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
defer rows.Close()
|
|
cursor, count := t.cursor, 0 // Applied to t only once the whole page was read
|
|
filled, skipped := make([]int64, 0), make([]int64, 0) // Holes now visible; row ids jumped over (new holes)
|
|
messages := make([]*model.Message, 0) // Published messages, in row id order
|
|
for rows.Next() {
|
|
m, rowID, published, err := readTailRow(rows)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
count++
|
|
if _, ok := t.holes[rowID]; ok {
|
|
filled = append(filled, rowID)
|
|
} else if rowID > cursor {
|
|
// Row ids between the cursor and this one are not visible (yet)
|
|
for id := cursor + 1; id < rowID && len(skipped) < tailMaxHoles; id++ {
|
|
skipped = append(skipped, id)
|
|
}
|
|
cursor = rowID
|
|
}
|
|
if published { // Scheduled messages go out through the delayed sender
|
|
messages = append(messages, m)
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, false, err
|
|
}
|
|
// Only now: a read that fails part way is retried whole, from the same position
|
|
now := time.Now()
|
|
for _, id := range filled {
|
|
delete(t.holes, id)
|
|
}
|
|
for _, id := range skipped {
|
|
if len(t.holes) >= tailMaxHoles {
|
|
break
|
|
}
|
|
t.holes[id] = now
|
|
}
|
|
t.cursor = cursor
|
|
return messages, count == tailPageSize, nil
|
|
}
|
|
|
|
// readTailRow reads a row of selectMessagesAfterRowID: the message, then its row id and whether
|
|
// it is published (false for a scheduled message)
|
|
func readTailRow(rows *sql.Rows) (*model.Message, int64, bool, error) {
|
|
var rowID int64
|
|
var published bool
|
|
m, err := readMessage(rows, &rowID, &published)
|
|
if err != nil {
|
|
return nil, 0, false, err
|
|
}
|
|
return m, rowID, published, nil
|
|
}
|
|
|
|
func (t *Tail) expireHoles() {
|
|
for id, skipped := range t.holes {
|
|
if time.Since(skipped) > tailHoleTimeout {
|
|
delete(t.holes, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// LastRead returns when the tail last read the database successfully; zero before the first
|
|
// read. Unlike Next, it may be called from any goroutine.
|
|
func (t *Tail) LastRead() time.Time {
|
|
nanos := t.lastRead.Load()
|
|
if nanos == 0 {
|
|
return time.Time{}
|
|
}
|
|
return time.Unix(0, nanos)
|
|
}
|
|
|
|
// Done returns a channel that is closed when the tail is closed, so the loop calling Next can stop
|
|
func (t *Tail) Done() <-chan struct{} {
|
|
return t.done
|
|
}
|
|
|
|
// Close tells the loop calling Next to stop (see Done); it may be called more than once
|
|
func (t *Tail) Close() {
|
|
t.closed.Do(func() {
|
|
close(t.done)
|
|
})
|
|
}
|
|
|
|
// addHoles records the row ids between from and to (both exclusive) as holes
|
|
func (t *Tail) addHoles(from, to int64, now time.Time) {
|
|
for id := from + 1; id < to && len(t.holes) < tailMaxHoles; id++ {
|
|
t.holes[id] = now
|
|
}
|
|
}
|