mirror of
https://github.com/jeremyd/ergo.git
synced 2026-08-06 22:13:05 -07:00
e7558f292c
* default build includes everything * allow compiling out PRECIS and Skeleton (still included by default) * consistently use `postgresql` in identifiers
1201 lines
30 KiB
Go
1201 lines
30 KiB
Go
//go:build postgresql
|
|
|
|
// Copyright (c) 2020 Shivaram Lingamneni
|
|
// released under the MIT license
|
|
|
|
package postgresql
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"runtime/debug"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/ergochat/ergo/irc/history"
|
|
"github.com/ergochat/ergo/irc/logger"
|
|
"github.com/ergochat/ergo/irc/utils"
|
|
_ "github.com/jackc/pgx/v5/stdlib"
|
|
)
|
|
|
|
// Enabled is true when PostgreSQL support is compiled in
|
|
const Enabled = true
|
|
|
|
const (
|
|
// latest schema of the db
|
|
latestDbSchema = "2"
|
|
keySchemaVersion = "db.version"
|
|
// minor version indicates rollback-safe upgrades, i.e.,
|
|
// you can downgrade oragono and everything will work
|
|
latestDbMinorVersion = "2"
|
|
keySchemaMinorVersion = "db.minorversion"
|
|
cleanupRowLimit = 50
|
|
cleanupPauseTime = 10 * time.Minute
|
|
)
|
|
|
|
type e struct{}
|
|
|
|
type PostgreSQL struct {
|
|
db *sql.DB
|
|
logger *logger.Manager
|
|
|
|
insertHistory *sql.Stmt
|
|
insertSequence *sql.Stmt
|
|
insertConversation *sql.Stmt
|
|
insertCorrespondent *sql.Stmt
|
|
insertAccountMessage *sql.Stmt
|
|
|
|
stateMutex sync.Mutex
|
|
config Config
|
|
|
|
wakeForgetter chan e
|
|
|
|
timeout atomic.Uint64
|
|
trackAccountMessages atomic.Uint32
|
|
}
|
|
|
|
var _ history.Database = (*PostgreSQL)(nil)
|
|
|
|
func NewPostgreSQLDatabase(logger *logger.Manager, config Config) (*PostgreSQL, error) {
|
|
var pg PostgreSQL
|
|
|
|
pg.logger = logger
|
|
pg.wakeForgetter = make(chan e, 1)
|
|
pg.SetConfig(config)
|
|
|
|
return &pg, pg.open()
|
|
}
|
|
|
|
func (pg *PostgreSQL) SetConfig(config Config) {
|
|
pg.timeout.Store(uint64(config.Timeout))
|
|
var trackAccountMessages uint32
|
|
if config.TrackAccountMessages {
|
|
trackAccountMessages = 1
|
|
}
|
|
pg.trackAccountMessages.Store(trackAccountMessages)
|
|
pg.stateMutex.Lock()
|
|
pg.config = config
|
|
pg.stateMutex.Unlock()
|
|
}
|
|
|
|
func (pg *PostgreSQL) getExpireTime() (expireTime time.Duration) {
|
|
pg.stateMutex.Lock()
|
|
expireTime = pg.config.ExpireTime
|
|
pg.stateMutex.Unlock()
|
|
return
|
|
}
|
|
|
|
func (pg *PostgreSQL) open() (err error) {
|
|
// Build PostgreSQL connection string
|
|
uri := pg.config.URI
|
|
if uri != "" {
|
|
uri, err = pg.mungeURI(uri)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
uri, err = pg.config.buildURI()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
pg.db, err = sql.Open("pgx", uri)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if pg.config.MaxConns != 0 {
|
|
pg.db.SetMaxOpenConns(pg.config.MaxConns)
|
|
pg.db.SetMaxIdleConns(pg.config.MaxConns)
|
|
}
|
|
if pg.config.ConnMaxLifetime != 0 {
|
|
pg.db.SetConnMaxLifetime(pg.config.ConnMaxLifetime)
|
|
}
|
|
|
|
err = pg.fixSchemas()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = pg.prepareStatements()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
go pg.cleanupLoop()
|
|
go pg.forgetLoop()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (pg *PostgreSQL) mungeURI(uriStr string) (result string, err error) {
|
|
uri, err := url.Parse(uriStr)
|
|
if err != nil {
|
|
return "", fmt.Errorf("could not parse postgres URI: %w", err)
|
|
}
|
|
values := uri.Query()
|
|
if !values.Has("application_name") {
|
|
values.Set("application_name", pg.config.ApplicationName)
|
|
}
|
|
if !values.Has("connect_timeout") {
|
|
values.Set("connect_timeout", fmt.Sprintf("%d", int(pg.config.ConnectTimeout.Seconds())))
|
|
}
|
|
uri.RawQuery = values.Encode()
|
|
return uri.String(), nil
|
|
}
|
|
|
|
func (pg *PostgreSQL) fixSchemas() (err error) {
|
|
_, err = pg.db.Exec(`CREATE TABLE IF NOT EXISTS metadata (
|
|
key_name VARCHAR(32) PRIMARY KEY,
|
|
value VARCHAR(32) NOT NULL
|
|
);`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var schema string
|
|
err = pg.db.QueryRow(`SELECT value FROM metadata WHERE key_name = $1;`, keySchemaVersion).Scan(&schema)
|
|
if err == sql.ErrNoRows {
|
|
err = pg.createTables()
|
|
if err != nil {
|
|
return
|
|
}
|
|
_, err = pg.db.Exec(`INSERT INTO metadata (key_name, value) VALUES ($1, $2);`, keySchemaVersion, latestDbSchema)
|
|
if err != nil {
|
|
return
|
|
}
|
|
_, err = pg.db.Exec(`INSERT INTO metadata (key_name, value) VALUES ($1, $2);`, keySchemaMinorVersion, latestDbMinorVersion)
|
|
if err != nil {
|
|
return
|
|
}
|
|
return
|
|
} else if err == nil && schema != latestDbSchema {
|
|
// TODO figure out what to do about schema changes
|
|
return fmt.Errorf("incompatible schema: got %s, expected %s", schema, latestDbSchema)
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
|
|
var minorVersion string
|
|
err = pg.db.QueryRow(`SELECT value FROM metadata WHERE key_name = $1;`, keySchemaMinorVersion).Scan(&minorVersion)
|
|
if err == sql.ErrNoRows {
|
|
// impossible
|
|
} else if err == nil && minorVersion != latestDbMinorVersion {
|
|
// TODO: if minorVersion < latestDbMinorVersion, upgrade,
|
|
// if latestDbMinorVersion < minorVersion, ignore because backwards compatible
|
|
} else if err != nil {
|
|
return
|
|
}
|
|
return
|
|
}
|
|
|
|
func (pg *PostgreSQL) createTables() (err error) {
|
|
_, err = pg.db.Exec(`CREATE TABLE history (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
data BYTEA NOT NULL,
|
|
msgid BYTEA NOT NULL CHECK (octet_length(msgid) = 16)
|
|
);`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = pg.db.Exec(`CREATE INDEX idx_history_msgid ON history (msgid);`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = pg.db.Exec(fmt.Sprintf(`CREATE TABLE sequence (
|
|
history_id BIGINT NOT NULL PRIMARY KEY,
|
|
target BYTEA NOT NULL CHECK (octet_length(target) <= %[1]d),
|
|
nanotime BIGINT NOT NULL CHECK (nanotime >= 0)
|
|
);`, MaxTargetLength))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = pg.db.Exec(`CREATE INDEX idx_sequence_target_nanotime ON sequence (target, nanotime);`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = pg.db.Exec(fmt.Sprintf(`CREATE TABLE conversations (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
target BYTEA NOT NULL CHECK (octet_length(target) <= %[1]d),
|
|
correspondent BYTEA NOT NULL CHECK (octet_length(correspondent) <= %[1]d),
|
|
nanotime BIGINT NOT NULL CHECK (nanotime >= 0),
|
|
history_id BIGINT NOT NULL
|
|
);`, MaxTargetLength))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = pg.db.Exec(`CREATE INDEX idx_conversations_target_correspondent_nanotime ON conversations (target, correspondent, nanotime);`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = pg.db.Exec(`CREATE INDEX idx_conversations_history_id ON conversations (history_id);`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = pg.createCorrespondentsTable()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = pg.createComplianceTables()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (pg *PostgreSQL) createCorrespondentsTable() (err error) {
|
|
_, err = pg.db.Exec(fmt.Sprintf(`CREATE TABLE correspondents (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
target BYTEA NOT NULL CHECK (octet_length(target) <= %[1]d),
|
|
correspondent BYTEA NOT NULL CHECK (octet_length(correspondent) <= %[1]d),
|
|
nanotime BIGINT NOT NULL CHECK (nanotime >= 0),
|
|
UNIQUE (target, correspondent)
|
|
);`, MaxTargetLength))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = pg.db.Exec(`CREATE INDEX idx_correspondents_target_nanotime ON correspondents (target, nanotime);`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = pg.db.Exec(`CREATE INDEX idx_correspondents_nanotime ON correspondents (nanotime);`)
|
|
return
|
|
}
|
|
|
|
func (pg *PostgreSQL) createComplianceTables() (err error) {
|
|
_, err = pg.db.Exec(fmt.Sprintf(`CREATE TABLE account_messages (
|
|
history_id BIGINT NOT NULL PRIMARY KEY,
|
|
account BYTEA NOT NULL CHECK (octet_length(account) <= %[1]d)
|
|
);`, MaxTargetLength))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = pg.db.Exec(`CREATE INDEX idx_account_messages_account_history_id ON account_messages (account, history_id);`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = pg.db.Exec(fmt.Sprintf(`CREATE TABLE forget (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
account BYTEA NOT NULL CHECK (octet_length(account) <= %[1]d)
|
|
);`, MaxTargetLength))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (pg *PostgreSQL) cleanupLoop() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
pg.logger.Error("postgres",
|
|
fmt.Sprintf("Panic in cleanup routine: %v\n%s", r, debug.Stack()))
|
|
time.Sleep(cleanupPauseTime)
|
|
go pg.cleanupLoop()
|
|
}
|
|
}()
|
|
|
|
for {
|
|
expireTime := pg.getExpireTime()
|
|
if expireTime != 0 {
|
|
for {
|
|
startTime := time.Now()
|
|
rowsDeleted, err := pg.doCleanup(expireTime)
|
|
elapsed := time.Now().Sub(startTime)
|
|
pg.logError("error during row cleanup", err)
|
|
// keep going as long as we're accomplishing significant work
|
|
// (don't busy-wait on small numbers of rows expiring):
|
|
if rowsDeleted < (cleanupRowLimit / 10) {
|
|
break
|
|
}
|
|
// crude backpressure mechanism: if the database is slow,
|
|
// give it time to process other queries
|
|
time.Sleep(elapsed)
|
|
}
|
|
}
|
|
time.Sleep(cleanupPauseTime)
|
|
}
|
|
}
|
|
|
|
func (pg *PostgreSQL) doCleanup(age time.Duration) (count int, err error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), cleanupPauseTime)
|
|
defer cancel()
|
|
|
|
ids, maxNanotime, err := pg.selectCleanupIDs(ctx, age)
|
|
if len(ids) == 0 {
|
|
pg.logger.Debug("postgres", "found no rows to clean up")
|
|
return
|
|
}
|
|
|
|
pg.logger.Debug("postgres", fmt.Sprintf("deleting %d history rows, max age %s", len(ids), utils.NanoToTimestamp(maxNanotime)))
|
|
|
|
if maxNanotime != 0 {
|
|
pg.deleteCorrespondents(ctx, maxNanotime)
|
|
}
|
|
|
|
return len(ids), pg.deleteHistoryIDs(ctx, ids)
|
|
}
|
|
|
|
func (pg *PostgreSQL) deleteHistoryIDs(ctx context.Context, ids []uint64) (err error) {
|
|
// can't use ? binding for a variable number of arguments, build the IN clause manually
|
|
var inBuf strings.Builder
|
|
inBuf.WriteByte('(')
|
|
for i, id := range ids {
|
|
if i != 0 {
|
|
inBuf.WriteRune(',')
|
|
}
|
|
fmt.Fprintf(&inBuf, "%d", id)
|
|
}
|
|
inBuf.WriteRune(')')
|
|
inClause := inBuf.String()
|
|
|
|
_, err = pg.db.ExecContext(ctx, fmt.Sprintf(`DELETE FROM conversations WHERE history_id in %s;`, inClause))
|
|
if err != nil {
|
|
return
|
|
}
|
|
_, err = pg.db.ExecContext(ctx, fmt.Sprintf(`DELETE FROM sequence WHERE history_id in %s;`, inClause))
|
|
if err != nil {
|
|
return
|
|
}
|
|
if pg.isTrackingAccountMessages() {
|
|
_, err = pg.db.ExecContext(ctx, fmt.Sprintf(`DELETE FROM account_messages WHERE history_id in %s;`, inClause))
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
_, err = pg.db.ExecContext(ctx, fmt.Sprintf(`DELETE FROM history WHERE id in %s;`, inClause))
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func (pg *PostgreSQL) selectCleanupIDs(ctx context.Context, age time.Duration) (ids []uint64, maxNanotime int64, err error) {
|
|
rows, err := pg.db.QueryContext(ctx, `
|
|
SELECT history.id, sequence.nanotime, conversations.nanotime
|
|
FROM history
|
|
LEFT JOIN sequence ON history.id = sequence.history_id
|
|
LEFT JOIN conversations on history.id = conversations.history_id
|
|
ORDER BY history.id LIMIT $1;`, cleanupRowLimit)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
idset := make(map[uint64]struct{}, cleanupRowLimit)
|
|
threshold := time.Now().Add(-age).UnixNano()
|
|
for rows.Next() {
|
|
var id uint64
|
|
var seqNano, convNano sql.NullInt64
|
|
err = rows.Scan(&id, &seqNano, &convNano)
|
|
if err != nil {
|
|
return
|
|
}
|
|
nanotime := extractNanotime(seqNano, convNano)
|
|
// returns 0 if not found; in that case the data is inconsistent
|
|
// and we should delete the entry
|
|
if nanotime < threshold {
|
|
idset[id] = struct{}{}
|
|
if nanotime > maxNanotime {
|
|
maxNanotime = nanotime
|
|
}
|
|
}
|
|
}
|
|
ids = make([]uint64, len(idset))
|
|
i := 0
|
|
for id := range idset {
|
|
ids[i] = id
|
|
i++
|
|
}
|
|
return
|
|
}
|
|
|
|
func (pg *PostgreSQL) deleteCorrespondents(ctx context.Context, threshold int64) {
|
|
result, err := pg.db.ExecContext(ctx, `DELETE FROM correspondents WHERE nanotime <= $1;`, threshold)
|
|
if err != nil {
|
|
pg.logError("error deleting correspondents", err)
|
|
} else {
|
|
count, err := result.RowsAffected()
|
|
if !pg.logError("error deleting correspondents", err) {
|
|
pg.logger.Debug("postgres", fmt.Sprintf("deleted %d correspondents entries", count))
|
|
}
|
|
}
|
|
}
|
|
|
|
// wait for forget queue items and process them one by one
|
|
func (pg *PostgreSQL) forgetLoop() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
pg.logger.Error("postgres",
|
|
fmt.Sprintf("Panic in forget routine: %v\n%s", r, debug.Stack()))
|
|
time.Sleep(cleanupPauseTime)
|
|
go pg.forgetLoop()
|
|
}
|
|
}()
|
|
|
|
for {
|
|
for {
|
|
found, err := pg.doForget()
|
|
pg.logError("error processing forget", err)
|
|
if err != nil {
|
|
time.Sleep(cleanupPauseTime)
|
|
}
|
|
if !found {
|
|
break
|
|
}
|
|
}
|
|
|
|
<-pg.wakeForgetter
|
|
}
|
|
}
|
|
|
|
// dequeue an item from the forget queue and process it
|
|
func (pg *PostgreSQL) doForget() (found bool, err error) {
|
|
id, account, err := func() (id int64, account string, err error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), cleanupPauseTime)
|
|
defer cancel()
|
|
|
|
row := pg.db.QueryRowContext(ctx,
|
|
`SELECT forget.id, forget.account FROM forget LIMIT 1;`)
|
|
err = row.Scan(&id, &account)
|
|
if err == sql.ErrNoRows {
|
|
return 0, "", nil
|
|
}
|
|
return
|
|
}()
|
|
|
|
if err != nil || account == "" {
|
|
return false, err
|
|
}
|
|
|
|
found = true
|
|
|
|
var count int
|
|
for {
|
|
start := time.Now()
|
|
count, err = pg.doForgetIteration(account)
|
|
elapsed := time.Since(start)
|
|
if err != nil {
|
|
return true, err
|
|
}
|
|
if count == 0 {
|
|
break
|
|
}
|
|
time.Sleep(elapsed)
|
|
}
|
|
|
|
pg.logger.Debug("postgres", "forget complete for account", account)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), cleanupPauseTime)
|
|
defer cancel()
|
|
_, err = pg.db.ExecContext(ctx, `DELETE FROM forget WHERE id = $1;`, id)
|
|
return
|
|
}
|
|
|
|
func (pg *PostgreSQL) doForgetIteration(account string) (count int, err error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), cleanupPauseTime)
|
|
defer cancel()
|
|
|
|
rows, err := pg.db.QueryContext(ctx, `
|
|
SELECT account_messages.history_id
|
|
FROM account_messages
|
|
WHERE account_messages.account = $1
|
|
LIMIT $2;`, account, cleanupRowLimit)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var ids []uint64
|
|
for rows.Next() {
|
|
var id uint64
|
|
err = rows.Scan(&id)
|
|
if err != nil {
|
|
return
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
|
|
if len(ids) == 0 {
|
|
return
|
|
}
|
|
|
|
pg.logger.Debug("postgres", fmt.Sprintf("deleting %d history rows from account %s", len(ids), account))
|
|
err = pg.deleteHistoryIDs(ctx, ids)
|
|
return len(ids), err
|
|
}
|
|
|
|
func (pg *PostgreSQL) prepareStatements() (err error) {
|
|
pg.insertHistory, err = pg.db.Prepare(`INSERT INTO history
|
|
(data, msgid) VALUES ($1, $2) RETURNING id;`)
|
|
if err != nil {
|
|
return
|
|
}
|
|
pg.insertSequence, err = pg.db.Prepare(`INSERT INTO sequence
|
|
(target, nanotime, history_id) VALUES ($1, $2, $3);`)
|
|
if err != nil {
|
|
return
|
|
}
|
|
pg.insertConversation, err = pg.db.Prepare(`INSERT INTO conversations
|
|
(target, correspondent, nanotime, history_id) VALUES ($1, $2, $3, $4);`)
|
|
if err != nil {
|
|
return
|
|
}
|
|
pg.insertCorrespondent, err = pg.db.Prepare(`INSERT INTO correspondents
|
|
(target, correspondent, nanotime) VALUES ($1, $2, $3)
|
|
ON CONFLICT (target, correspondent)
|
|
DO UPDATE SET nanotime = GREATEST(correspondents.nanotime, $3);`)
|
|
if err != nil {
|
|
return
|
|
}
|
|
pg.insertAccountMessage, err = pg.db.Prepare(`INSERT INTO account_messages
|
|
(history_id, account) VALUES ($1, $2);`)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func (pg *PostgreSQL) getTimeout() time.Duration {
|
|
return time.Duration(pg.timeout.Load())
|
|
}
|
|
|
|
func (pg *PostgreSQL) isTrackingAccountMessages() bool {
|
|
return pg.trackAccountMessages.Load() != 0
|
|
}
|
|
|
|
func (pg *PostgreSQL) logError(context string, err error) (quit bool) {
|
|
if err != nil {
|
|
pg.logger.Error("postgres", context, err.Error())
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (pg *PostgreSQL) Forget(account string) {
|
|
if pg.db == nil || account == "" {
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), pg.getTimeout())
|
|
defer cancel()
|
|
|
|
_, err := pg.db.ExecContext(ctx, `INSERT INTO forget (account) VALUES ($1);`, account)
|
|
if pg.logError("can't insert into forget table", err) {
|
|
return
|
|
}
|
|
|
|
// wake up the forget goroutine if it's blocked:
|
|
select {
|
|
case pg.wakeForgetter <- e{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (pg *PostgreSQL) AddChannelItem(target string, item history.Item, account string) (err error) {
|
|
if pg.db == nil {
|
|
return
|
|
}
|
|
|
|
if target == "" {
|
|
return utils.ErrInvalidParams
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), pg.getTimeout())
|
|
defer cancel()
|
|
|
|
id, err := pg.insertBase(ctx, item)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
err = pg.insertSequenceEntry(ctx, target, item.Message.Time.UnixNano(), id)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
err = pg.insertAccountMessageEntry(ctx, id, account)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func (pg *PostgreSQL) insertSequenceEntry(ctx context.Context, target string, messageTime int64, id int64) (err error) {
|
|
_, err = pg.insertSequence.ExecContext(ctx, target, messageTime, id)
|
|
if err != nil {
|
|
return fmt.Errorf("could not insert sequence entry: %w", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
func (pg *PostgreSQL) insertConversationEntry(ctx context.Context, target, correspondent string, messageTime int64, id int64) (err error) {
|
|
_, err = pg.insertConversation.ExecContext(ctx, target, correspondent, messageTime, id)
|
|
if err != nil {
|
|
return fmt.Errorf("could not insert conversations entry: %w", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
func (pg *PostgreSQL) insertCorrespondentsEntry(ctx context.Context, target, correspondent string, messageTime int64, historyId int64) (err error) {
|
|
_, err = pg.insertCorrespondent.ExecContext(ctx, target, correspondent, messageTime)
|
|
if err != nil {
|
|
return fmt.Errorf("could not insert correspondents entry: %w", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
func (pg *PostgreSQL) insertBase(ctx context.Context, item history.Item) (id int64, err error) {
|
|
value, err := history.MarshalItem(&item)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("could not marshal item: %w", err)
|
|
}
|
|
|
|
msgidBytes, err := utils.DecodeSecretToken(item.Message.Msgid)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("could not decode msgid: %w", err)
|
|
}
|
|
|
|
// Use RETURNING clause to get the ID in a single round-trip
|
|
err = pg.insertHistory.QueryRowContext(ctx, value, msgidBytes).Scan(&id)
|
|
if pg.logError("could not insert item", err) {
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func (pg *PostgreSQL) insertAccountMessageEntry(ctx context.Context, id int64, account string) (err error) {
|
|
if account == "" || !pg.isTrackingAccountMessages() {
|
|
return
|
|
}
|
|
_, err = pg.insertAccountMessage.ExecContext(ctx, id, account)
|
|
if err != nil {
|
|
return fmt.Errorf("could not insert account-message entry: %w", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
func (pg *PostgreSQL) AddDirectMessage(sender, senderAccount, recipient, recipientAccount string, item history.Item) (err error) {
|
|
if pg.db == nil {
|
|
return
|
|
}
|
|
|
|
if senderAccount == "" && recipientAccount == "" {
|
|
return
|
|
}
|
|
|
|
if sender == "" || recipient == "" {
|
|
return utils.ErrInvalidParams
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), pg.getTimeout())
|
|
defer cancel()
|
|
|
|
id, err := pg.insertBase(ctx, item)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
nanotime := item.Message.Time.UnixNano()
|
|
|
|
if senderAccount != "" {
|
|
err = pg.insertConversationEntry(ctx, senderAccount, recipient, nanotime, id)
|
|
if err != nil {
|
|
return
|
|
}
|
|
err = pg.insertCorrespondentsEntry(ctx, senderAccount, recipient, nanotime, id)
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
|
|
if recipientAccount != "" && sender != recipient {
|
|
err = pg.insertConversationEntry(ctx, recipientAccount, sender, nanotime, id)
|
|
if err != nil {
|
|
return
|
|
}
|
|
err = pg.insertCorrespondentsEntry(ctx, recipientAccount, sender, nanotime, id)
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
|
|
err = pg.insertAccountMessageEntry(ctx, id, senderAccount)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// note that accountName is the unfolded name
|
|
func (pg *PostgreSQL) DeleteMsgid(msgid, accountName string) (err error) {
|
|
if pg.db == nil {
|
|
return nil
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), pg.getTimeout())
|
|
defer cancel()
|
|
|
|
_, id, data, err := pg.lookupMsgid(ctx, msgid, true)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return history.ErrNotFound
|
|
}
|
|
return
|
|
}
|
|
|
|
if accountName != "*" {
|
|
var item history.Item
|
|
err = history.UnmarshalItem(data, &item)
|
|
// delete if the entry is corrupt
|
|
if err == nil && item.AccountName != accountName {
|
|
return history.ErrDisallowed
|
|
}
|
|
}
|
|
|
|
err = pg.deleteHistoryIDs(ctx, []uint64{id})
|
|
if err != nil {
|
|
return fmt.Errorf("couldn't delete msgid: %w", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
func (pg *PostgreSQL) Export(account string, writer io.Writer) {
|
|
if pg.db == nil {
|
|
return
|
|
}
|
|
|
|
var err error
|
|
var lastSeen uint64
|
|
for {
|
|
rows := func() (count int) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), cleanupPauseTime)
|
|
defer cancel()
|
|
|
|
rows, rowsErr := pg.db.QueryContext(ctx, `
|
|
SELECT account_messages.history_id, history.data, sequence.target FROM account_messages
|
|
INNER JOIN history ON history.id = account_messages.history_id
|
|
INNER JOIN sequence ON account_messages.history_id = sequence.history_id
|
|
WHERE account_messages.account = $1 AND account_messages.history_id > $2
|
|
LIMIT $3`, account, lastSeen, cleanupRowLimit)
|
|
if rowsErr != nil {
|
|
err = rowsErr
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var id uint64
|
|
var blob, jsonBlob []byte
|
|
var target string
|
|
var item history.Item
|
|
err = rows.Scan(&id, &blob, &target)
|
|
if err != nil {
|
|
return
|
|
}
|
|
err = history.UnmarshalItem(blob, &item)
|
|
if err != nil {
|
|
return
|
|
}
|
|
item.CfCorrespondent = target
|
|
jsonBlob, err = json.Marshal(item)
|
|
if err != nil {
|
|
return
|
|
}
|
|
count++
|
|
if lastSeen < id {
|
|
lastSeen = id
|
|
}
|
|
writer.Write(jsonBlob)
|
|
writer.Write([]byte{'\n'})
|
|
}
|
|
return
|
|
}()
|
|
if rows == 0 || err != nil {
|
|
break
|
|
}
|
|
}
|
|
|
|
pg.logError("could not export history", err)
|
|
return
|
|
}
|
|
|
|
func (pg *PostgreSQL) lookupMsgid(ctx context.Context, msgid string, includeData bool) (result time.Time, id uint64, data []byte, err error) {
|
|
decoded, err := utils.DecodeSecretToken(msgid)
|
|
if err != nil {
|
|
// use sql.ErrNoRows internally for consistency, translate to history.ErrNotFound
|
|
// at the package boundary if necessary
|
|
err = sql.ErrNoRows
|
|
return
|
|
}
|
|
cols := `sequence.nanotime, conversations.nanotime`
|
|
if includeData {
|
|
cols = `sequence.nanotime, conversations.nanotime, history.id, history.data`
|
|
}
|
|
row := pg.db.QueryRowContext(ctx, fmt.Sprintf(`
|
|
SELECT %s FROM history
|
|
LEFT JOIN sequence ON history.id = sequence.history_id
|
|
LEFT JOIN conversations ON history.id = conversations.history_id
|
|
WHERE history.msgid = $1 LIMIT 1;`, cols), decoded)
|
|
var nanoSeq, nanoConv sql.NullInt64
|
|
if !includeData {
|
|
err = row.Scan(&nanoSeq, &nanoConv)
|
|
} else {
|
|
err = row.Scan(&nanoSeq, &nanoConv, &id, &data)
|
|
}
|
|
if err != nil {
|
|
if err != sql.ErrNoRows {
|
|
err = fmt.Errorf("could not resolve msgid to time: %w", err)
|
|
}
|
|
return
|
|
}
|
|
nanotime := extractNanotime(nanoSeq, nanoConv)
|
|
if nanotime == 0 {
|
|
err = sql.ErrNoRows
|
|
return
|
|
}
|
|
result = time.Unix(0, nanotime).UTC()
|
|
return
|
|
}
|
|
|
|
func extractNanotime(seq, conv sql.NullInt64) (result int64) {
|
|
if seq.Valid {
|
|
return seq.Int64
|
|
} else if conv.Valid {
|
|
return conv.Int64
|
|
}
|
|
return
|
|
}
|
|
|
|
func (pg *PostgreSQL) selectItems(ctx context.Context, query string, args ...interface{}) (results []history.Item, err error) {
|
|
rows, err := pg.db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not select history items: %w", err)
|
|
}
|
|
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var blob []byte
|
|
var item history.Item
|
|
err = rows.Scan(&blob)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not scan history item: %w", err)
|
|
}
|
|
err = history.UnmarshalItem(blob, &item)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not unmarshal history item: %w", err)
|
|
}
|
|
results = append(results, item)
|
|
}
|
|
return
|
|
}
|
|
|
|
func (pg *PostgreSQL) betweenTimestamps(ctx context.Context, target, correspondent string, after, before, cutoff time.Time, limit int) (results []history.Item, err error) {
|
|
useSequence := correspondent == ""
|
|
table := "sequence"
|
|
if !useSequence {
|
|
table = "conversations"
|
|
}
|
|
|
|
after, before, ascending := history.MinMaxAsc(after, before, cutoff)
|
|
direction := "ASC"
|
|
if !ascending {
|
|
direction = "DESC"
|
|
}
|
|
|
|
var queryBuf strings.Builder
|
|
|
|
args := make([]interface{}, 0, 6)
|
|
paramNum := 1
|
|
fmt.Fprintf(&queryBuf,
|
|
"SELECT history.data from history INNER JOIN %[1]s ON history.id = %[1]s.history_id WHERE", table)
|
|
if useSequence {
|
|
fmt.Fprintf(&queryBuf, " sequence.target = $%d", paramNum)
|
|
args = append(args, target)
|
|
paramNum++
|
|
} else {
|
|
fmt.Fprintf(&queryBuf, " conversations.target = $%d AND conversations.correspondent = $%d", paramNum, paramNum+1)
|
|
args = append(args, target)
|
|
args = append(args, correspondent)
|
|
paramNum += 2
|
|
}
|
|
if !after.IsZero() {
|
|
fmt.Fprintf(&queryBuf, " AND %s.nanotime > $%d", table, paramNum)
|
|
args = append(args, after.UnixNano())
|
|
paramNum++
|
|
}
|
|
if !before.IsZero() {
|
|
fmt.Fprintf(&queryBuf, " AND %s.nanotime < $%d", table, paramNum)
|
|
args = append(args, before.UnixNano())
|
|
paramNum++
|
|
}
|
|
fmt.Fprintf(&queryBuf, " ORDER BY %[1]s.nanotime %[2]s LIMIT $%[3]d;", table, direction, paramNum)
|
|
args = append(args, limit)
|
|
|
|
results, err = pg.selectItems(ctx, queryBuf.String(), args...)
|
|
if err == nil && !ascending {
|
|
slices.Reverse(results)
|
|
}
|
|
return
|
|
}
|
|
|
|
func (pg *PostgreSQL) listCorrespondentsInternal(ctx context.Context, target string, after, before, cutoff time.Time, limit int) (results []history.TargetListing, err error) {
|
|
after, before, ascending := history.MinMaxAsc(after, before, cutoff)
|
|
direction := "ASC"
|
|
if !ascending {
|
|
direction = "DESC"
|
|
}
|
|
|
|
var queryBuf strings.Builder
|
|
args := make([]interface{}, 0, 4)
|
|
paramNum := 1
|
|
fmt.Fprintf(&queryBuf, `SELECT correspondents.correspondent, correspondents.nanotime from correspondents
|
|
WHERE target = $%d`, paramNum)
|
|
args = append(args, target)
|
|
paramNum++
|
|
if !after.IsZero() {
|
|
fmt.Fprintf(&queryBuf, " AND correspondents.nanotime > $%d", paramNum)
|
|
args = append(args, after.UnixNano())
|
|
paramNum++
|
|
}
|
|
if !before.IsZero() {
|
|
fmt.Fprintf(&queryBuf, " AND correspondents.nanotime < $%d", paramNum)
|
|
args = append(args, before.UnixNano())
|
|
paramNum++
|
|
}
|
|
fmt.Fprintf(&queryBuf, " ORDER BY correspondents.nanotime %s LIMIT $%d;", direction, paramNum)
|
|
args = append(args, limit)
|
|
query := queryBuf.String()
|
|
|
|
rows, err := pg.db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not query correspondents: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var correspondent string
|
|
var nanotime int64
|
|
for rows.Next() {
|
|
err = rows.Scan(&correspondent, &nanotime)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not scan correspondents: %w", err)
|
|
}
|
|
results = append(results, history.TargetListing{
|
|
CfName: correspondent,
|
|
Time: time.Unix(0, nanotime).UTC(),
|
|
})
|
|
}
|
|
|
|
if !ascending {
|
|
slices.Reverse(results)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func (pg *PostgreSQL) ListCorrespondents(cftarget string, start, end time.Time, limit int) (results []history.TargetListing, err error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), pg.getTimeout())
|
|
defer cancel()
|
|
|
|
// TODO accept msgids here?
|
|
|
|
results, err = pg.listCorrespondentsInternal(ctx, cftarget, start, end, time.Time{}, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not read correspondents: %w", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
func (pg *PostgreSQL) ListChannels(cfchannels []string) (results []history.TargetListing, err error) {
|
|
if pg.db == nil {
|
|
return
|
|
}
|
|
|
|
if len(cfchannels) == 0 {
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), pg.getTimeout())
|
|
defer cancel()
|
|
|
|
var queryBuf strings.Builder
|
|
args := make([]interface{}, 0, len(cfchannels))
|
|
// PostgreSQL optimizes DISTINCT ON better than GROUP BY MAX for this pattern
|
|
queryBuf.WriteString(`SELECT DISTINCT ON (sequence.target) sequence.target, sequence.nanotime
|
|
FROM sequence
|
|
WHERE sequence.target IN (`)
|
|
for i, chname := range cfchannels {
|
|
if i != 0 {
|
|
queryBuf.WriteString(", ")
|
|
}
|
|
fmt.Fprintf(&queryBuf, "$%d", i+1)
|
|
args = append(args, chname)
|
|
}
|
|
queryBuf.WriteString(`) ORDER BY sequence.target, sequence.nanotime DESC;`)
|
|
|
|
rows, err := pg.db.QueryContext(ctx, queryBuf.String(), args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not query channel listings: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var target string
|
|
var nanotime int64
|
|
for rows.Next() {
|
|
err = rows.Scan(&target, &nanotime)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not scan channel listings: %w", err)
|
|
}
|
|
results = append(results, history.TargetListing{
|
|
CfName: target,
|
|
Time: time.Unix(0, nanotime).UTC(),
|
|
})
|
|
}
|
|
return
|
|
}
|
|
|
|
func (pg *PostgreSQL) Close() (err error) {
|
|
// closing the database will close our prepared statements as well
|
|
if pg.db != nil {
|
|
err = pg.db.Close()
|
|
}
|
|
pg.db = nil
|
|
return
|
|
}
|
|
|
|
// implements history.Sequence, emulating a single history buffer (for a channel,
|
|
// a single user's DMs, or a DM conversation)
|
|
type postgreSQLHistorySequence struct {
|
|
pg *PostgreSQL
|
|
target string
|
|
correspondent string
|
|
cutoff time.Time
|
|
}
|
|
|
|
func (s *postgreSQLHistorySequence) Between(start, end history.Selector, limit int) (results []history.Item, err error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), s.pg.getTimeout())
|
|
defer cancel()
|
|
|
|
startTime := start.Time
|
|
if start.Msgid != "" {
|
|
startTime, _, _, err = s.pg.lookupMsgid(ctx, start.Msgid, false)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
} else {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
endTime := end.Time
|
|
if end.Msgid != "" {
|
|
endTime, _, _, err = s.pg.lookupMsgid(ctx, end.Msgid, false)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
} else {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
|
|
results, err = s.pg.betweenTimestamps(ctx, s.target, s.correspondent, startTime, endTime, s.cutoff, limit)
|
|
return results, err
|
|
}
|
|
|
|
func (s *postgreSQLHistorySequence) Around(start history.Selector, limit int) (results []history.Item, err error) {
|
|
return history.GenericAround(s, start, limit)
|
|
}
|
|
|
|
func (seq *postgreSQLHistorySequence) Cutoff() time.Time {
|
|
return seq.cutoff
|
|
}
|
|
|
|
func (seq *postgreSQLHistorySequence) Ephemeral() bool {
|
|
return false
|
|
}
|
|
|
|
func (pg *PostgreSQL) MakeSequence(target, correspondent string, cutoff time.Time) history.Sequence {
|
|
return &postgreSQLHistorySequence{
|
|
target: target,
|
|
correspondent: correspondent,
|
|
pg: pg,
|
|
cutoff: cutoff,
|
|
}
|
|
}
|
|
|
|
func (config *Config) buildURI() (string, error) {
|
|
u := &url.URL{
|
|
Scheme: "postgresql",
|
|
Path: "/" + config.HistoryDatabase,
|
|
}
|
|
|
|
q := url.Values{}
|
|
|
|
if config.SocketPath != "" {
|
|
// For Unix sockets, pgx uses host as a query parameter
|
|
q.Set("host", config.SocketPath)
|
|
if config.User != "" || config.Password != "" {
|
|
u.User = url.UserPassword(config.User, config.Password)
|
|
}
|
|
} else {
|
|
// TCP connection
|
|
port := config.Port
|
|
if port == 0 {
|
|
port = 5432
|
|
}
|
|
host := config.Host
|
|
if host == "" {
|
|
host = "localhost"
|
|
}
|
|
u.Host = fmt.Sprintf("%s:%d", host, port)
|
|
if config.User != "" || config.Password != "" {
|
|
u.User = url.UserPassword(config.User, config.Password)
|
|
}
|
|
|
|
sslMode := config.SSLMode
|
|
if sslMode == "" {
|
|
sslMode = "disable"
|
|
}
|
|
q.Set("sslmode", sslMode)
|
|
|
|
if config.SSLCert != "" {
|
|
q.Set("sslcert", config.SSLCert)
|
|
}
|
|
if config.SSLKey != "" {
|
|
q.Set("sslkey", config.SSLKey)
|
|
}
|
|
if config.SSLRootCert != "" {
|
|
q.Set("sslrootcert", config.SSLRootCert)
|
|
}
|
|
}
|
|
|
|
if config.ApplicationName != "" {
|
|
q.Set("application_name", config.ApplicationName)
|
|
}
|
|
if config.ConnectTimeout != 0 {
|
|
q.Set("connect_timeout", fmt.Sprintf("%d", int(config.ConnectTimeout.Seconds())))
|
|
}
|
|
|
|
u.RawQuery = q.Encode()
|
|
return u.String(), nil
|
|
}
|