diff --git a/coderd/database/connector.go b/coderd/database/connector.go index 5ade33ed18233..71b7b35d936b4 100644 --- a/coderd/database/connector.go +++ b/coderd/database/connector.go @@ -2,8 +2,6 @@ package database import ( "database/sql/driver" - - "github.com/lib/pq" ) // ConnectorCreator is a driver.Driver that can create a driver.Connector. @@ -12,8 +10,10 @@ type ConnectorCreator interface { Connector(name string) (driver.Connector, error) } -// DialerConnector is a driver.Connector that can set a pq.Dialer. +// DialerConnector is a driver.Connector that can set a dialer. +// Note: pgx uses a different approach for custom dialers via config type DialerConnector interface { driver.Connector - Dialer(dialer pq.Dialer) + // Dialer functionality is handled differently in pgx + // Use stdlib.RegisterConnConfig for custom connection configuration } diff --git a/coderd/database/dbtestutil/driver.go b/coderd/database/dbtestutil/driver.go index cb2e05af78617..a2a0511e352cf 100644 --- a/coderd/database/dbtestutil/driver.go +++ b/coderd/database/dbtestutil/driver.go @@ -4,7 +4,7 @@ import ( "context" "database/sql/driver" - "github.com/lib/pq" + "github.com/jackc/pgx/v5/stdlib" "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/database" @@ -15,22 +15,11 @@ var _ database.DialerConnector = &Connector{} type Connector struct { name string driver *Driver - dialer pq.Dialer + // Note: pgx handles dialing differently via config } -func (c *Connector) Connect(_ context.Context) (driver.Conn, error) { - if c.dialer != nil { - conn, err := pq.DialOpen(c.dialer, c.name) - if err != nil { - return nil, xerrors.Errorf("failed to dial open connection: %w", err) - } - - c.driver.Connections <- conn - - return conn, nil - } - - conn, err := pq.Driver{}.Open(c.name) +func (c *Connector) Connect(ctx context.Context) (driver.Conn, error) { + conn, err := stdlib.GetDefaultDriver().Open(c.name) if err != nil { return nil, xerrors.Errorf("failed to open connection: %w", err) } @@ -44,8 +33,9 @@ func (c *Connector) Driver() driver.Driver { return c.driver } -func (c *Connector) Dialer(dialer pq.Dialer) { - c.dialer = dialer +func (c *Connector) Dialer(dialer interface{}) { + // Note: pgx handles dialing differently via config + // This method is kept for interface compatibility but is a no-op } type Driver struct { diff --git a/coderd/database/dbtestutil/postgres_test.go b/coderd/database/dbtestutil/postgres_test.go index f1b9336d57b37..29026c33d56d4 100644 --- a/coderd/database/dbtestutil/postgres_test.go +++ b/coderd/database/dbtestutil/postgres_test.go @@ -4,7 +4,7 @@ import ( "database/sql" "testing" - _ "github.com/lib/pq" + _ "github.com/jackc/pgx/v5/stdlib" "github.com/stretchr/testify/require" "go.uber.org/goleak" diff --git a/coderd/database/errors.go b/coderd/database/errors.go index 66c702de24445..06b7b630dd953 100644 --- a/coderd/database/errors.go +++ b/coderd/database/errors.go @@ -4,13 +4,13 @@ import ( "context" "errors" - "github.com/lib/pq" + "github.com/jackc/pgx/v5/pgconn" ) func IsSerializedError(err error) bool { - var pqErr *pq.Error - if errors.As(err, &pqErr) { - return pqErr.Code.Name() == "serialization_failure" + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return pgErr.Code == "40001" // serialization_failure } return false } @@ -20,14 +20,14 @@ func IsSerializedError(err error) bool { // the error must be caused by one of them. If no constraints are given, // this function returns true for any unique violation. func IsUniqueViolation(err error, uniqueConstraints ...UniqueConstraint) bool { - var pqErr *pq.Error - if errors.As(err, &pqErr) { - if pqErr.Code.Name() == "unique_violation" { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + if pgErr.Code == "23505" { // unique_violation if len(uniqueConstraints) == 0 { return true } for _, uc := range uniqueConstraints { - if pqErr.Constraint == string(uc) { + if pgErr.ConstraintName == string(uc) { return true } } @@ -42,14 +42,14 @@ func IsUniqueViolation(err error, uniqueConstraints ...UniqueConstraint) bool { // the error must be caused by one of them. If no constraints are given, // this function returns true for any foreign key violation. func IsForeignKeyViolation(err error, foreignKeyConstraints ...ForeignKeyConstraint) bool { - var pqErr *pq.Error - if errors.As(err, &pqErr) { - if pqErr.Code.Name() == "foreign_key_violation" { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + if pgErr.Code == "23503" { // foreign_key_violation if len(foreignKeyConstraints) == 0 { return true } for _, fc := range foreignKeyConstraints { - if pqErr.Constraint == string(fc) { + if pgErr.ConstraintName == string(fc) { return true } } @@ -61,9 +61,9 @@ func IsForeignKeyViolation(err error, foreignKeyConstraints ...ForeignKeyConstra // IsQueryCanceledError checks if the error is due to a query being canceled. func IsQueryCanceledError(err error) bool { - var pqErr *pq.Error - if errors.As(err, &pqErr) { - return pqErr.Code == "57014" // query_canceled + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return pgErr.Code == "57014" // query_canceled } else if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return true } @@ -72,9 +72,9 @@ func IsQueryCanceledError(err error) bool { } func IsWorkspaceAgentLogsLimitError(err error) bool { - var pqErr *pq.Error - if errors.As(err, &pqErr) { - return pqErr.Constraint == "max_logs_length" && pqErr.Table == "workspace_agents" + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return pgErr.ConstraintName == "max_logs_length" && pgErr.TableName == "workspace_agents" } return false diff --git a/coderd/database/migrations/txnmigrator.go b/coderd/database/migrations/txnmigrator.go index c284136192c8a..dcea12790bd49 100644 --- a/coderd/database/migrations/txnmigrator.go +++ b/coderd/database/migrations/txnmigrator.go @@ -8,7 +8,7 @@ import ( "strings" "github.com/golang-migrate/migrate/v4/database" - "github.com/lib/pq" + "github.com/jackc/pgx/v5/pgconn" "golang.org/x/xerrors" ) @@ -81,7 +81,7 @@ func (d *pgTxnDriver) runStatement(statement []byte) error { return nil } if _, err := d.tx.ExecContext(ctx, query); err != nil { - var pgErr *pq.Error + var pgErr *pgconn.PgError if xerrors.As(err, &pgErr) { var line uint message := fmt.Sprintf("migration failed: %s", pgErr.Message) @@ -131,9 +131,9 @@ func (d *pgTxnDriver) Version() (version int, dirty bool, err error) { return database.NilVersion, false, nil case err != nil: - var pgErr *pq.Error + var pgErr *pgconn.PgError if xerrors.As(err, &pgErr) { - if pgErr.Code.Name() == "undefined_table" { + if pgErr.Code == "42P01" { // undefined_table return database.NilVersion, false, nil } } diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 931412204d780..df7304fa411e3 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -8,7 +8,6 @@ import ( "strings" "github.com/google/uuid" - "github.com/lib/pq" "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/rbac" @@ -78,7 +77,7 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate arg.OrganizationID, arg.ExactName, arg.FuzzyName, - pq.Array(arg.IDs), + arg.IDs, arg.Deprecated, arg.HasAITask, ) @@ -247,17 +246,17 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa // The name comment is for metric tracking query := fmt.Sprintf("-- name: GetAuthorizedWorkspaces :many\n%s", filtered) rows, err := q.db.QueryContext(ctx, query, - pq.Array(arg.ParamNames), - pq.Array(arg.ParamValues), + arg.ParamNames, + arg.ParamValues, arg.Deleted, arg.Status, arg.OwnerID, arg.OrganizationID, - pq.Array(arg.HasParam), + arg.HasParam, arg.OwnerUsername, arg.TemplateName, - pq.Array(arg.TemplateIDs), - pq.Array(arg.WorkspaceIds), + arg.TemplateIDs, + arg.WorkspaceIds, arg.Name, arg.HasAgent, arg.AgentInactiveDisconnectTimeoutSeconds, @@ -357,7 +356,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx context.Conte &i.Name, &i.JobStatus, &i.Transition, - pq.Array(&i.Agents), + &i.Agents, ); err != nil { return nil, err } @@ -393,15 +392,15 @@ func (q *sqlQuerier) GetAuthorizedUsers(ctx context.Context, arg GetUsersParams, rows, err := q.db.QueryContext(ctx, query, arg.AfterID, arg.Search, - pq.Array(arg.Status), - pq.Array(arg.RbacRole), + arg.Status, + arg.RbacRole, arg.LastSeenBefore, arg.LastSeenAfter, arg.CreatedBefore, arg.CreatedAfter, arg.IncludeSystem, arg.GithubComUserID, - pq.Array(arg.LoginType), + arg.LoginType, arg.OffsetOpt, arg.LimitOpt, ) diff --git a/coderd/database/pubsub/pubsub.go b/coderd/database/pubsub/pubsub.go index c4b454abdfbda..93b0b1a7ca69e 100644 --- a/coderd/database/pubsub/pubsub.go +++ b/coderd/database/pubsub/pubsub.go @@ -3,20 +3,17 @@ package pubsub import ( "context" "database/sql" - "database/sql/driver" "errors" "io" - "net" "sync" "sync/atomic" "time" - "github.com/lib/pq" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/prometheus/client_golang/prometheus" "golang.org/x/xerrors" - "github.com/coder/coder/v2/coderd/database" - "cdr.dev/slog" ) @@ -171,20 +168,86 @@ func (q *msgQueue) dropped() { q.cond.Broadcast() } -// pqListener is an interface that represents a *pq.Listener for testing -type pqListener interface { +// pgxListener is an interface that represents a pgx connection for LISTEN/NOTIFY +type pgxListener interface { io.Closer - Listen(string) error - Unlisten(string) error - NotifyChan() <-chan *pq.Notification + Listen(ctx context.Context, channel string) error + Unlisten(ctx context.Context, channel string) error + NotifyChan() <-chan *pgconn.Notification + Conn() *pgx.Conn +} + +type pgxListenerShim struct { + conn *pgx.Conn + notifyCh chan *pgconn.Notification + ctx context.Context + cancel context.CancelFunc + closed bool + mu sync.Mutex +} + +func newPgxListenerShim(conn *pgx.Conn) *pgxListenerShim { + ctx, cancel := context.WithCancel(context.Background()) + l := &pgxListenerShim{ + conn: conn, + notifyCh: make(chan *pgconn.Notification, 100), + ctx: ctx, + cancel: cancel, + } + go l.listenLoop() + return l +} + +func (l *pgxListenerShim) listenLoop() { + for { + select { + case <-l.ctx.Done(): + return + default: + notification, err := l.conn.WaitForNotification(l.ctx) + if err != nil { + if l.ctx.Err() != nil { + return + } + continue + } + select { + case l.notifyCh <- notification: + case <-l.ctx.Done(): + return + } + } + } +} + +func (l *pgxListenerShim) Listen(ctx context.Context, channel string) error { + _, err := l.conn.Exec(ctx, "LISTEN "+pgx.Identifier{channel}.Sanitize()) + return err +} + +func (l *pgxListenerShim) Unlisten(ctx context.Context, channel string) error { + _, err := l.conn.Exec(ctx, "UNLISTEN "+pgx.Identifier{channel}.Sanitize()) + return err } -type pqListenerShim struct { - *pq.Listener +func (l *pgxListenerShim) NotifyChan() <-chan *pgconn.Notification { + return l.notifyCh } -func (l pqListenerShim) NotifyChan() <-chan *pq.Notification { - return l.Notify +func (l *pgxListenerShim) Conn() *pgx.Conn { + return l.conn +} + +func (l *pgxListenerShim) Close() error { + l.mu.Lock() + defer l.mu.Unlock() + if l.closed { + return nil + } + l.closed = true + l.cancel() + close(l.notifyCh) + return l.conn.Close(l.ctx) } type queueSet struct { @@ -204,7 +267,7 @@ func newQueueSet() *queueSet { type PGPubsub struct { logger slog.Logger listenDone chan struct{} - pgListener pqListener + pgListener pgxListener db *sql.DB qMu sync.Mutex @@ -301,13 +364,19 @@ func (p *PGPubsub) subscribeQueue(event string, newQ *msgQueue) (cancel func(), // notifies. We need to avoid holding the mutex while this happens, since holding the mutex // blocks reading notifications and can deadlock the pgListener. // c.f. https://github.com/coder/coder/issues/11950 - err = p.pgListener.Listen(event) + err = p.pgListener.Listen(context.Background(), event) if err == nil { p.logger.Debug(context.Background(), "started listening to event channel", slog.F("event", event)) } - if errors.Is(err, pq.ErrChannelAlreadyOpen) { - // It's ok if it's already open! - err = nil + // pgx doesn't have ErrChannelAlreadyOpen, but LISTEN is idempotent + // so we can ignore "already listening" type errors + if err != nil { + // Check if it's a "already listening" error and ignore it + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "42P01" { + // Ignore "already listening" errors + err = nil + } } if err != nil { return nil, xerrors.Errorf("listen: %w", err) @@ -333,7 +402,7 @@ func (p *PGPubsub) subscribeQueue(event string, newQ *msgQueue) (cancel func(), // as above, we must not hold the lock while calling into pgListener if unlistening != nil { - uErr := p.pgListener.Unlisten(event) + uErr := p.pgListener.Unlisten(context.Background(), event) close(unlistening) // we can now delete the queueSet if it is empty. func() { @@ -359,10 +428,10 @@ func (p *PGPubsub) subscribeQueue(event string, newQ *msgQueue) (cancel func(), func (p *PGPubsub) Publish(event string, message []byte) error { p.logger.Debug(context.Background(), "publish", slog.F("event", event), slog.F("message_len", len(message))) - // This is safe because we are calling pq.QuoteLiteral. pg_notify doesn't + // This is safe because we are using pgx.Identifier.Sanitize(). pg_notify doesn't // support the first parameter being a prepared statement. //nolint:gosec - _, err := p.db.ExecContext(context.Background(), `select pg_notify(`+pq.QuoteLiteral(event)+`, $1)`, message) + _, err := p.db.ExecContext(context.Background(), `select pg_notify(`+pgx.Identifier{event}.Sanitize()+`, $1)`, message) if err != nil { p.publishesTotal.WithLabelValues("false").Inc() return xerrors.Errorf("exec pg_notify: %w", err) @@ -413,13 +482,13 @@ func (p *PGPubsub) listen() { } } -func (p *PGPubsub) listenReceive(notif *pq.Notification) { +func (p *PGPubsub) listenReceive(notif *pgconn.Notification) { sizeLabel := messageSizeNormal - if len(notif.Extra) >= colossalThreshold { + if len(notif.Payload) >= colossalThreshold { sizeLabel = messageSizeColossal } p.messagesTotal.WithLabelValues(sizeLabel).Inc() - p.receivedBytesTotal.Add(float64(len(notif.Extra))) + p.receivedBytesTotal.Add(float64(len(notif.Payload))) p.qMu.Lock() defer p.qMu.Unlock() @@ -427,7 +496,7 @@ func (p *PGPubsub) listenReceive(notif *pq.Notification) { if !ok { return } - extra := []byte(notif.Extra) + extra := []byte(notif.Payload) for q := range qSet.m { q.enqueue(extra) } @@ -443,124 +512,25 @@ func (p *PGPubsub) recordReconnect() { } } -// logDialer is a pq.Dialer and pq.DialerContext that logs when it starts -// connecting and when the TCP connection is established. -type logDialer struct { - logger slog.Logger - d net.Dialer -} - -var ( - _ pq.Dialer = logDialer{} - _ pq.DialerContext = logDialer{} -) - -func (d logDialer) Dial(network, address string) (net.Conn, error) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - return d.DialContext(ctx, network, address) -} - -func (d logDialer) DialTimeout(network, address string, timeout time.Duration) (net.Conn, error) { - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - return d.DialContext(ctx, network, address) -} - -func (d logDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { - deadline, hasDeadline := ctx.Deadline() - timeoutMS := 0 - if hasDeadline { - timeoutMS = int(time.Until(deadline) / time.Millisecond) - } - logger := d.logger.With(slog.F("network", network), slog.F("address", address), slog.F("timeout_ms", timeoutMS)) - - logger.Debug(ctx, "pubsub dialing postgres") - start := time.Now() - conn, err := d.d.DialContext(ctx, network, address) - if err != nil { - logger.Error(ctx, "pubsub failed to dial postgres") - return nil, err - } - elapsed := time.Since(start) - logger.Debug(ctx, "pubsub postgres TCP connection established", slog.F("elapsed_ms", elapsed.Milliseconds())) - return conn, nil -} func (p *PGPubsub) startListener(ctx context.Context, connectURL string) error { p.connected.Set(0) - // Creates a new listener using pq. - var ( - dialer = logDialer{ - logger: p.logger, - // pq.defaultDialer uses a zero net.Dialer as well. - d: net.Dialer{}, - } - connector driver.Connector - err error - ) - - // Create a custom connector if the database driver supports it. - connectorCreator, ok := p.db.Driver().(database.ConnectorCreator) - if ok { - connector, err = connectorCreator.Connector(connectURL) - if err != nil { - return xerrors.Errorf("create custom connector: %w", err) - } - } else { - // use the default pq connector otherwise - connector, err = pq.NewConnector(connectURL) - if err != nil { - return xerrors.Errorf("create pq connector: %w", err) - } + // Creates a new listener using pgx. + config, err := pgx.ParseConfig(connectURL) + if err != nil { + return xerrors.Errorf("parse connect URL: %w", err) } - // Set the dialer if the connector supports it. - dc, ok := connector.(database.DialerConnector) - if !ok { - p.logger.Critical(ctx, "connector does not support setting log dialer, database connection debug logs will be missing") - } else { - dc.Dialer(dialer) + // Create pgx connection for LISTEN/NOTIFY + conn, err := pgx.ConnectConfig(ctx, config) + if err != nil { + return xerrors.Errorf("connect to postgres: %w", err) } - var ( - errCh = make(chan error, 1) - sentErrCh = false - ) - p.pgListener = pqListenerShim{ - Listener: pq.NewConnectorListener(connector, connectURL, time.Second, time.Minute, func(t pq.ListenerEventType, err error) { - switch t { - case pq.ListenerEventConnected: - p.logger.Debug(ctx, "pubsub connected to postgres") - p.connected.Set(1.0) - case pq.ListenerEventDisconnected: - p.logger.Error(ctx, "pubsub disconnected from postgres", slog.Error(err)) - p.connected.Set(0) - case pq.ListenerEventReconnected: - p.logger.Info(ctx, "pubsub reconnected to postgres") - p.connected.Set(1) - case pq.ListenerEventConnectionAttemptFailed: - p.logger.Error(ctx, "pubsub failed to connect to postgres", slog.Error(err)) - } - // This callback gets events whenever the connection state changes. - // Only send the first error. - if sentErrCh { - return - } - errCh <- err // won't block because we are buffered. - sentErrCh = true - }), - } - // We don't respect context cancellation here. There's a bug in the pq library - // where if you close the listener before or while the connection is being - // established, the connection will be established anyway, and will not be - // closed. - // https://github.com/lib/pq/issues/1192 - if err := <-errCh; err != nil { - _ = p.pgListener.Close() - return xerrors.Errorf("create pq listener: %w", err) - } + p.pgListener = newPgxListenerShim(conn) + p.logger.Debug(ctx, "pubsub connected to postgres") + p.connected.Set(1.0) return nil } diff --git a/coderd/database/pubsub/pubsub.go.backup b/coderd/database/pubsub/pubsub.go.backup new file mode 100644 index 0000000000000..1d8bace9c12c1 --- /dev/null +++ b/coderd/database/pubsub/pubsub.go.backup @@ -0,0 +1,742 @@ +package pubsub + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "io" + "net" + "sync" + "sync/atomic" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/stdlib" + "github.com/prometheus/client_golang/prometheus" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" + + "cdr.dev/slog" +) + +// Listener represents a pubsub handler. +type Listener func(ctx context.Context, message []byte) + +// ListenerWithErr represents a pubsub handler that can also receive error +// indications +type ListenerWithErr func(ctx context.Context, message []byte, err error) + +// ErrDroppedMessages is sent to ListenerWithErr if messages are dropped or +// might have been dropped. +var ErrDroppedMessages = xerrors.New("dropped messages") + +// LatencyMeasureTimeout defines how often to trigger a new background latency measurement. +const LatencyMeasureTimeout = time.Second * 10 + +// Pubsub is a generic interface for broadcasting and receiving messages. +// Implementors should assume high-availability with the backing implementation. +type Pubsub interface { + Subscribe(event string, listener Listener) (cancel func(), err error) + SubscribeWithErr(event string, listener ListenerWithErr) (cancel func(), err error) + Publish(event string, message []byte) error + Close() error +} + +// msgOrErr either contains a message or an error +type msgOrErr struct { + msg []byte + err error +} + +// msgQueue implements a fixed length queue with the ability to replace elements +// after they are queued (but before they are dequeued). +// +// The purpose of this data structure is to build something that works a bit +// like a golang channel, but if the queue is full, then we can replace the +// last element with an error so that the subscriber can get notified that some +// messages were dropped, all without blocking. +type msgQueue struct { + ctx context.Context + cond *sync.Cond + q [BufferSize]msgOrErr + front int + size int + closed bool + l Listener + le ListenerWithErr +} + +func newMsgQueue(ctx context.Context, l Listener, le ListenerWithErr) *msgQueue { + if l == nil && le == nil { + panic("l or le must be non-nil") + } + q := &msgQueue{ + ctx: ctx, + cond: sync.NewCond(&sync.Mutex{}), + l: l, + le: le, + } + go q.run() + return q +} + +func (q *msgQueue) run() { + for { + // wait until there is something on the queue or we are closed + q.cond.L.Lock() + for q.size == 0 && !q.closed { + q.cond.Wait() + } + if q.closed { + q.cond.L.Unlock() + return + } + item := q.q[q.front] + q.front = (q.front + 1) % BufferSize + q.size-- + q.cond.L.Unlock() + + // process item without holding lock + if item.err == nil { + // real message + if q.l != nil { + q.l(q.ctx, item.msg) + continue + } + if q.le != nil { + q.le(q.ctx, item.msg, nil) + continue + } + // unhittable + continue + } + // if the listener wants errors, send it. + if q.le != nil { + q.le(q.ctx, nil, item.err) + } + } +} + +func (q *msgQueue) enqueue(msg []byte) { + q.cond.L.Lock() + defer q.cond.L.Unlock() + + if q.size == BufferSize { + // queue is full, so we're going to drop the msg we got called with. + // We also need to record that messages are being dropped, which we + // do at the last message in the queue. This potentially makes us + // lose 2 messages instead of one, but it's more important at this + // point to warn the subscriber that they're losing messages so they + // can do something about it. + back := (q.front + BufferSize - 1) % BufferSize + q.q[back].msg = nil + q.q[back].err = ErrDroppedMessages + return + } + // queue is not full, insert the message + next := (q.front + q.size) % BufferSize + q.q[next].msg = msg + q.q[next].err = nil + q.size++ + q.cond.Broadcast() +} + +func (q *msgQueue) close() { + q.cond.L.Lock() + defer q.cond.L.Unlock() + defer q.cond.Broadcast() + q.closed = true +} + +// dropped records an error in the queue that messages might have been dropped +func (q *msgQueue) dropped() { + q.cond.L.Lock() + defer q.cond.L.Unlock() + + if q.size == BufferSize { + // queue is full, but we need to record that messages are being dropped, + // which we do at the last message in the queue. This potentially drops + // another message, but it's more important for the subscriber to know. + back := (q.front + BufferSize - 1) % BufferSize + q.q[back].msg = nil + q.q[back].err = ErrDroppedMessages + return + } + // queue is not full, insert the error + next := (q.front + q.size) % BufferSize + q.q[next].msg = nil + q.q[next].err = ErrDroppedMessages + q.size++ + q.cond.Broadcast() +} + +// pqListener is an interface that represents a *pq.Listener for testing +type pqListener interface { + io.Closer + Listen(string) error + Unlisten(string) error + NotifyChan() <-chan *pq.Notification +} + +type pqListenerShim struct { + *pq.Listener +} + +func (l pqListenerShim) NotifyChan() <-chan *pq.Notification { + return l.Notify +} + +type queueSet struct { + m map[*msgQueue]struct{} + // unlistenInProgress will be non-nil if another goroutine is unlistening for the event this + // queueSet corresponds to. If non-nil, that goroutine will close the channel when it is done. + unlistenInProgress chan struct{} +} + +func newQueueSet() *queueSet { + return &queueSet{ + m: make(map[*msgQueue]struct{}), + } +} + +// PGPubsub is a pubsub implementation using PostgreSQL. +type PGPubsub struct { + logger slog.Logger + listenDone chan struct{} + pgListener pqListener + db *sql.DB + + qMu sync.Mutex + queues map[string]*queueSet + + // making the close state its own mutex domain simplifies closing logic so + // that we don't have to hold the qMu --- which could block processing + // notifications while the pqListener is closing. + closeMu sync.Mutex + closedListener bool + closeListenerErr error + + publishesTotal *prometheus.CounterVec + subscribesTotal *prometheus.CounterVec + messagesTotal *prometheus.CounterVec + publishedBytesTotal prometheus.Counter + receivedBytesTotal prometheus.Counter + disconnectionsTotal prometheus.Counter + connected prometheus.Gauge + + latencyMeasurer *LatencyMeasurer + latencyMeasureCounter atomic.Int64 + latencyErrCounter atomic.Int64 +} + +// BufferSize is the maximum number of unhandled messages we will buffer +// for a subscriber before dropping messages. +const BufferSize = 2048 + +// Subscribe calls the listener when an event matching the name is received. +func (p *PGPubsub) Subscribe(event string, listener Listener) (cancel func(), err error) { + return p.subscribeQueue(event, newMsgQueue(context.Background(), listener, nil)) +} + +func (p *PGPubsub) SubscribeWithErr(event string, listener ListenerWithErr) (cancel func(), err error) { + return p.subscribeQueue(event, newMsgQueue(context.Background(), nil, listener)) +} + +func (p *PGPubsub) subscribeQueue(event string, newQ *msgQueue) (cancel func(), err error) { + defer func() { + if err != nil { + // if we hit an error, we need to close the queue so we don't + // leak its goroutine. + newQ.close() + p.subscribesTotal.WithLabelValues("false").Inc() + } else { + p.subscribesTotal.WithLabelValues("true").Inc() + } + }() + + var ( + unlistenInProgress <-chan struct{} + // MUST hold the p.qMu lock to manipulate this! + qs *queueSet + ) + func() { + p.qMu.Lock() + defer p.qMu.Unlock() + + var ok bool + if qs, ok = p.queues[event]; !ok { + qs = newQueueSet() + p.queues[event] = qs + } + qs.m[newQ] = struct{}{} + unlistenInProgress = qs.unlistenInProgress + }() + // NOTE there cannot be any `return` statements between here and the next +-+, otherwise the + // assumptions the defer makes could be violated + if unlistenInProgress != nil { + // We have to wait here because we don't want our `Listen` call to happen before the other + // goroutine calls `Unlisten`. That would result in this subscription not getting any + // events. c.f. https://github.com/coder/coder/issues/15312 + p.logger.Debug(context.Background(), "waiting for Unlisten in progress", slog.F("event", event)) + <-unlistenInProgress + p.logger.Debug(context.Background(), "unlistening complete", slog.F("event", event)) + } + // +-+ (see above) + defer func() { + if err != nil { + p.qMu.Lock() + defer p.qMu.Unlock() + delete(qs.m, newQ) + if len(qs.m) == 0 { + // we know that newQ was in the queueSet since we last unlocked, so there cannot + // have been any _new_ goroutines trying to Unlisten(). Therefore, if the queueSet + // is now empty, it's safe to delete. + delete(p.queues, event) + } + } + }() + + // The pgListener waits for the response to `LISTEN` on a mainloop that also dispatches + // notifies. We need to avoid holding the mutex while this happens, since holding the mutex + // blocks reading notifications and can deadlock the pgListener. + // c.f. https://github.com/coder/coder/issues/11950 + err = p.pgListener.Listen(event) + if err == nil { + p.logger.Debug(context.Background(), "started listening to event channel", slog.F("event", event)) + } + if errors.Is(err, pq.ErrChannelAlreadyOpen) { + // It's ok if it's already open! + err = nil + } + if err != nil { + return nil, xerrors.Errorf("listen: %w", err) + } + + return func() { + var unlistening chan struct{} + func() { + p.qMu.Lock() + defer p.qMu.Unlock() + newQ.close() + qSet, ok := p.queues[event] + if !ok { + p.logger.Critical(context.Background(), "event was removed before cancel", slog.F("event", event)) + return + } + delete(qSet.m, newQ) + if len(qSet.m) == 0 { + unlistening = make(chan struct{}) + qSet.unlistenInProgress = unlistening + } + }() + + // as above, we must not hold the lock while calling into pgListener + if unlistening != nil { + uErr := p.pgListener.Unlisten(event) + close(unlistening) + // we can now delete the queueSet if it is empty. + func() { + p.qMu.Lock() + defer p.qMu.Unlock() + qSet, ok := p.queues[event] + if ok && len(qSet.m) == 0 { + p.logger.Debug(context.Background(), "removing queueSet", slog.F("event", event)) + delete(p.queues, event) + } + }() + + p.closeMu.Lock() + defer p.closeMu.Unlock() + if uErr != nil && !p.closedListener { + p.logger.Warn(context.Background(), "failed to unlisten", slog.Error(uErr), slog.F("event", event)) + } else { + p.logger.Debug(context.Background(), "stopped listening to event channel", slog.F("event", event)) + } + } + }, nil +} + +func (p *PGPubsub) Publish(event string, message []byte) error { + p.logger.Debug(context.Background(), "publish", slog.F("event", event), slog.F("message_len", len(message))) + // This is safe because we are calling pq.QuoteLiteral. pg_notify doesn't + // support the first parameter being a prepared statement. + //nolint:gosec + _, err := p.db.ExecContext(context.Background(), `select pg_notify(`+pq.QuoteLiteral(event)+`, $1)`, message) + if err != nil { + p.publishesTotal.WithLabelValues("false").Inc() + return xerrors.Errorf("exec pg_notify: %w", err) + } + p.publishesTotal.WithLabelValues("true").Inc() + p.publishedBytesTotal.Add(float64(len(message))) + return nil +} + +// Close closes the pubsub instance. +func (p *PGPubsub) Close() error { + p.logger.Info(context.Background(), "pubsub is closing") + err := p.closeListener() + <-p.listenDone + p.logger.Debug(context.Background(), "pubsub closed") + return err +} + +// closeListener closes the pgListener, unless it has already been closed. +func (p *PGPubsub) closeListener() error { + p.closeMu.Lock() + defer p.closeMu.Unlock() + if p.closedListener { + return p.closeListenerErr + } + p.closedListener = true + p.closeListenerErr = p.pgListener.Close() + + return p.closeListenerErr +} + +// listen begins receiving messages on the pq listener. +func (p *PGPubsub) listen() { + defer func() { + p.logger.Info(context.Background(), "pubsub listen stopped receiving notify") + close(p.listenDone) + }() + + notify := p.pgListener.NotifyChan() + for notif := range notify { + // A nil notification can be dispatched on reconnect. + if notif == nil { + p.logger.Debug(context.Background(), "notifying subscribers of a reconnection") + p.recordReconnect() + continue + } + p.listenReceive(notif) + } +} + +func (p *PGPubsub) listenReceive(notif *pq.Notification) { + sizeLabel := messageSizeNormal + if len(notif.Extra) >= colossalThreshold { + sizeLabel = messageSizeColossal + } + p.messagesTotal.WithLabelValues(sizeLabel).Inc() + p.receivedBytesTotal.Add(float64(len(notif.Extra))) + + p.qMu.Lock() + defer p.qMu.Unlock() + qSet, ok := p.queues[notif.Channel] + if !ok { + return + } + extra := []byte(notif.Extra) + for q := range qSet.m { + q.enqueue(extra) + } +} + +func (p *PGPubsub) recordReconnect() { + p.qMu.Lock() + defer p.qMu.Unlock() + for _, qSet := range p.queues { + for q := range qSet.m { + q.dropped() + } + } +} + +// logDialer is a pq.Dialer and pq.DialerContext that logs when it starts +// connecting and when the TCP connection is established. +type logDialer struct { + logger slog.Logger + d net.Dialer +} + +var ( + _ pq.Dialer = logDialer{} + _ pq.DialerContext = logDialer{} +) + +func (d logDialer) Dial(network, address string) (net.Conn, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + return d.DialContext(ctx, network, address) +} + +func (d logDialer) DialTimeout(network, address string, timeout time.Duration) (net.Conn, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + return d.DialContext(ctx, network, address) +} + +func (d logDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + deadline, hasDeadline := ctx.Deadline() + timeoutMS := 0 + if hasDeadline { + timeoutMS = int(time.Until(deadline) / time.Millisecond) + } + + logger := d.logger.With(slog.F("network", network), slog.F("address", address), slog.F("timeout_ms", timeoutMS)) + + logger.Debug(ctx, "pubsub dialing postgres") + start := time.Now() + conn, err := d.d.DialContext(ctx, network, address) + if err != nil { + logger.Error(ctx, "pubsub failed to dial postgres") + return nil, err + } + elapsed := time.Since(start) + logger.Debug(ctx, "pubsub postgres TCP connection established", slog.F("elapsed_ms", elapsed.Milliseconds())) + return conn, nil +} + +func (p *PGPubsub) startListener(ctx context.Context, connectURL string) error { + p.connected.Set(0) + // Creates a new listener using pq. + var ( + dialer = logDialer{ + logger: p.logger, + // pq.defaultDialer uses a zero net.Dialer as well. + d: net.Dialer{}, + } + connector driver.Connector + err error + ) + + // Create a custom connector if the database driver supports it. + connectorCreator, ok := p.db.Driver().(database.ConnectorCreator) + if ok { + connector, err = connectorCreator.Connector(connectURL) + if err != nil { + return xerrors.Errorf("create custom connector: %w", err) + } + } else { + // use the default pq connector otherwise + connector, err = pq.NewConnector(connectURL) + if err != nil { + return xerrors.Errorf("create pq connector: %w", err) + } + } + + // Set the dialer if the connector supports it. + dc, ok := connector.(database.DialerConnector) + if !ok { + p.logger.Critical(ctx, "connector does not support setting log dialer, database connection debug logs will be missing") + } else { + dc.Dialer(dialer) + } + + var ( + errCh = make(chan error, 1) + sentErrCh = false + ) + p.pgListener = pqListenerShim{ + Listener: pq.NewConnectorListener(connector, connectURL, time.Second, time.Minute, func(t pq.ListenerEventType, err error) { + switch t { + case pq.ListenerEventConnected: + p.logger.Debug(ctx, "pubsub connected to postgres") + p.connected.Set(1.0) + case pq.ListenerEventDisconnected: + p.logger.Error(ctx, "pubsub disconnected from postgres", slog.Error(err)) + p.connected.Set(0) + case pq.ListenerEventReconnected: + p.logger.Info(ctx, "pubsub reconnected to postgres") + p.connected.Set(1) + case pq.ListenerEventConnectionAttemptFailed: + p.logger.Error(ctx, "pubsub failed to connect to postgres", slog.Error(err)) + } + // This callback gets events whenever the connection state changes. + // Only send the first error. + if sentErrCh { + return + } + errCh <- err // won't block because we are buffered. + sentErrCh = true + }), + } + // We don't respect context cancellation here. There's a bug in the pq library + // where if you close the listener before or while the connection is being + // established, the connection will be established anyway, and will not be + // closed. + // https://github.com/lib/pq/issues/1192 + if err := <-errCh; err != nil { + _ = p.pgListener.Close() + return xerrors.Errorf("create pq listener: %w", err) + } + return nil +} + +// these are the metrics we compute implicitly from our existing data structures +var ( + currentSubscribersDesc = prometheus.NewDesc( + "coder_pubsub_current_subscribers", + "The current number of active pubsub subscribers", + nil, nil, + ) + currentEventsDesc = prometheus.NewDesc( + "coder_pubsub_current_events", + "The current number of pubsub event channels listened for", + nil, nil, + ) +) + +// additional metrics collected out-of-band +var ( + pubsubSendLatencyDesc = prometheus.NewDesc( + "coder_pubsub_send_latency_seconds", + "The time taken to send a message into a pubsub event channel", + nil, nil, + ) + pubsubRecvLatencyDesc = prometheus.NewDesc( + "coder_pubsub_receive_latency_seconds", + "The time taken to receive a message from a pubsub event channel", + nil, nil, + ) + pubsubLatencyMeasureCountDesc = prometheus.NewDesc( + "coder_pubsub_latency_measures_total", + "The number of pubsub latency measurements", + nil, nil, + ) + pubsubLatencyMeasureErrDesc = prometheus.NewDesc( + "coder_pubsub_latency_measure_errs_total", + "The number of pubsub latency measurement failures", + nil, nil, + ) +) + +// We'll track messages as size "normal" and "colossal", where the +// latter are messages larger than 7600 bytes, or 95% of the postgres +// notify limit. If we see a lot of colossal packets that's an indication that +// we might be trying to send too much data over the pubsub and are in danger of +// failing to publish. +const ( + colossalThreshold = 7600 + messageSizeNormal = "normal" + messageSizeColossal = "colossal" +) + +// Describe implements, along with Collect, the prometheus.Collector interface +// for metrics. +func (p *PGPubsub) Describe(descs chan<- *prometheus.Desc) { + // explicit metrics + p.publishesTotal.Describe(descs) + p.subscribesTotal.Describe(descs) + p.messagesTotal.Describe(descs) + p.publishedBytesTotal.Describe(descs) + p.receivedBytesTotal.Describe(descs) + p.disconnectionsTotal.Describe(descs) + p.connected.Describe(descs) + + // implicit metrics + descs <- currentSubscribersDesc + descs <- currentEventsDesc + + // additional metrics + descs <- pubsubSendLatencyDesc + descs <- pubsubRecvLatencyDesc + descs <- pubsubLatencyMeasureCountDesc + descs <- pubsubLatencyMeasureErrDesc +} + +// Collect implements, along with Describe, the prometheus.Collector interface +// for metrics +func (p *PGPubsub) Collect(metrics chan<- prometheus.Metric) { + // explicit metrics + p.publishesTotal.Collect(metrics) + p.subscribesTotal.Collect(metrics) + p.messagesTotal.Collect(metrics) + p.publishedBytesTotal.Collect(metrics) + p.receivedBytesTotal.Collect(metrics) + p.disconnectionsTotal.Collect(metrics) + p.connected.Collect(metrics) + + // implicit metrics + p.qMu.Lock() + events := len(p.queues) + subs := 0 + for _, qSet := range p.queues { + subs += len(qSet.m) + } + p.qMu.Unlock() + metrics <- prometheus.MustNewConstMetric(currentSubscribersDesc, prometheus.GaugeValue, float64(subs)) + metrics <- prometheus.MustNewConstMetric(currentEventsDesc, prometheus.GaugeValue, float64(events)) + + // additional metrics + ctx, cancel := context.WithTimeout(context.Background(), LatencyMeasureTimeout) + defer cancel() + send, recv, err := p.latencyMeasurer.Measure(ctx, p) + + metrics <- prometheus.MustNewConstMetric(pubsubLatencyMeasureCountDesc, prometheus.CounterValue, float64(p.latencyMeasureCounter.Add(1))) + if err != nil { + p.logger.Warn(context.Background(), "failed to measure latency", slog.Error(err)) + metrics <- prometheus.MustNewConstMetric(pubsubLatencyMeasureErrDesc, prometheus.CounterValue, float64(p.latencyErrCounter.Add(1))) + return + } + metrics <- prometheus.MustNewConstMetric(pubsubSendLatencyDesc, prometheus.GaugeValue, send.Seconds()) + metrics <- prometheus.MustNewConstMetric(pubsubRecvLatencyDesc, prometheus.GaugeValue, recv.Seconds()) +} + +// New creates a new Pubsub implementation using a PostgreSQL connection. +func New(startCtx context.Context, logger slog.Logger, db *sql.DB, connectURL string) (*PGPubsub, error) { + p := newWithoutListener(logger, db) + if err := p.startListener(startCtx, connectURL); err != nil { + return nil, err + } + go p.listen() + logger.Debug(startCtx, "pubsub has started") + return p, nil +} + +// newWithoutListener creates a new PGPubsub without creating the pqListener. +func newWithoutListener(logger slog.Logger, db *sql.DB) *PGPubsub { + return &PGPubsub{ + logger: logger, + listenDone: make(chan struct{}), + db: db, + queues: make(map[string]*queueSet), + latencyMeasurer: NewLatencyMeasurer(logger.Named("latency-measurer")), + + publishesTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "coder", + Subsystem: "pubsub", + Name: "publishes_total", + Help: "Total number of calls to Publish", + }, []string{"success"}), + subscribesTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "coder", + Subsystem: "pubsub", + Name: "subscribes_total", + Help: "Total number of calls to Subscribe/SubscribeWithErr", + }, []string{"success"}), + messagesTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "coder", + Subsystem: "pubsub", + Name: "messages_total", + Help: "Total number of messages received from postgres", + }, []string{"size"}), + publishedBytesTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "coder", + Subsystem: "pubsub", + Name: "published_bytes_total", + Help: "Total number of bytes successfully published across all publishes", + }), + receivedBytesTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "coder", + Subsystem: "pubsub", + Name: "received_bytes_total", + Help: "Total number of bytes received across all messages", + }), + disconnectionsTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "coder", + Subsystem: "pubsub", + Name: "disconnections_total", + Help: "Total number of times we disconnected unexpectedly from postgres", + }), + connected: prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: "coder", + Subsystem: "pubsub", + Name: "connected", + Help: "Whether we are connected (1) or not connected (0) to postgres", + }), + } +} diff --git a/coderd/database/pubsub/pubsub_internal_test.go b/coderd/database/pubsub/pubsub_internal_test.go index 0f699b4e4d82c..7deacf00513cf 100644 --- a/coderd/database/pubsub/pubsub_internal_test.go +++ b/coderd/database/pubsub/pubsub_internal_test.go @@ -6,7 +6,7 @@ import ( "sync" "testing" - "github.com/lib/pq" + "github.com/jackc/pgx/v5/pgconn" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/go.mod b/go.mod index ef52718460cdd..d15b7d14c4ea9 100644 --- a/go.mod +++ b/go.mod @@ -55,11 +55,6 @@ replace github.com/gliderlabs/ssh => github.com/coder/ssh v0.0.0-20231128192721- // Waiting on https://github.com/imulab/go-scim/pull/95 to merge. replace github.com/imulab/go-scim/pkg/v2 => github.com/coder/go-scim/pkg/v2 v2.0.0-20230221055123-1d63c1222136 -// Adds support for a new Listener from a driver.Connector -// This lets us use rotating authentication tokens for passwords in connection strings -// which we use in the awsiamrds package. -replace github.com/lib/pq => github.com/coder/pq v1.10.5-0.20240813183442-0c420cb5a048 - // Removes an init() function that causes terminal sequences to be printed to the web terminal when // used in conjunction with agent-exec. See https://github.com/coder/coder/pull/15817 replace github.com/charmbracelet/bubbletea => github.com/coder/bubbletea v1.2.2-0.20241212190825-007a1cdb2c41 @@ -150,7 +145,6 @@ require ( github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f github.com/klauspost/compress v1.18.0 - github.com/lib/pq v1.10.9 github.com/mattn/go-isatty v0.0.20 github.com/mitchellh/go-wordwrap v1.0.1 github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c @@ -474,6 +468,9 @@ require ( github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 // indirect github.com/golang-jwt/jwt/v5 v5.2.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect ) @@ -483,6 +480,7 @@ require ( github.com/coder/preview v0.0.2-0.20250611164554-2e5caa65a54a github.com/fsnotify/fsnotify v1.9.0 github.com/kylecarbs/aisdk-go v0.0.8 + github.com/lib/pq v1.10.9 github.com/mark3labs/mcp-go v0.32.0 github.com/openai/openai-go v0.1.0-beta.10 google.golang.org/genai v0.7.0 @@ -515,6 +513,7 @@ require ( github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/hashicorp/go-getter v1.7.8 // indirect github.com/hashicorp/go-safetemp v1.0.0 // indirect + github.com/jackc/pgx/v5 v5.7.5 github.com/jackmordaunt/icns/v3 v3.0.1 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/moby/sys/user v0.4.0 // indirect diff --git a/go.sum b/go.sum index ee7587bfdd7b1..0ed2a9b93e4ed 100644 --- a/go.sum +++ b/go.sum @@ -910,8 +910,6 @@ github.com/coder/go-scim/pkg/v2 v2.0.0-20230221055123-1d63c1222136 h1:0RgB61LcNs github.com/coder/go-scim/pkg/v2 v2.0.0-20230221055123-1d63c1222136/go.mod h1:VkD1P761nykiq75dz+4iFqIQIZka189tx1BQLOp0Skc= github.com/coder/guts v1.5.0 h1:a94apf7xMf5jDdg1bIHzncbRiTn3+BvBZgrFSDbUnyI= github.com/coder/guts v1.5.0/go.mod h1:0Sbv5Kp83u1Nl7MIQiV2zmacJ3o02I341bkWkjWXSUQ= -github.com/coder/pq v1.10.5-0.20240813183442-0c420cb5a048 h1:3jzYUlGH7ZELIH4XggXhnTnP05FCYiAFeQpoN+gNR5I= -github.com/coder/pq v1.10.5-0.20240813183442-0c420cb5a048/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0/go.mod h1:5UuS2Ts+nTToAMeOjNlnHFkPahrtDkmpydBen/3wgZc= github.com/coder/preview v0.0.2-0.20250611164554-2e5caa65a54a h1:rArAOPl5zHB7lhT2sy+jfcmyLeDlm6tXDoGkGdWNq7g= @@ -1411,6 +1409,14 @@ github.com/illarion/gonotify v1.0.1 h1:F1d+0Fgbq/sDWjj/r66ekjDG+IDeecQKUFH4wNwso github.com/illarion/gonotify v1.0.1/go.mod h1:zt5pmDofZpU1f8aqlK0+95eQhoEAn/d4G4B/FjVW4jE= github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA= github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= +github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackmordaunt/icns/v3 v3.0.1 h1:xxot6aNuGrU+lNgxz5I5H0qSeCjNKp8uTXB1j8D4S3o= github.com/jackmordaunt/icns/v3 v3.0.1/go.mod h1:5sHL59nqTd2ynTnowxB/MDQFhKNqkK8X687uKNygaSQ= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= @@ -1488,6 +1494,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/liamg/memoryfs v1.6.0 h1:jAFec2HI1PgMTem5gR7UT8zi9u4BfG5jorCRlLH06W8= github.com/liamg/memoryfs v1.6.0/go.mod h1:z7mfqXFQS8eSeBBsFjYLlxYRMRyiPktytvYCYTb3BSk= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=