Run JOIN, PART and KICK as one transaction each (closes #153)
check / check (push) Waiting to run

JoinChannel made the channel, counted its members, ran the join
checks and inserted the member as separate statements, so two
requests could each decide on a count the other was about to change:
+l was exceeded, two first joiners both became operator, and a JOIN
racing the last PART failed its foreign key and answered 500.

db.CreateOrJoinChannel now does all of that, and uses up the invite,
in one transaction; the join checks moved into it, and the service
turns its refusals into the same IRC errors as before. PART and KICK
remove the member and delete an empty channel in one transaction
(db.PartChannelAndDeleteIfEmpty). The db functions this leaves with
no caller are removed.

Model: opus-5-5
This commit is contained in:
2026-10-20 20:17:46 +00:00
parent 292f1eb7a9
commit 39955494a4
4 changed files with 645 additions and 126 deletions
+229 -42
View File
@@ -462,46 +462,207 @@ func (database *Database) JoinChannel(
return nil
}
// JoinChannelAsOperator adds a session to a channel with
// operator status. Used when a user creates a new channel.
func (database *Database) JoinChannelAsOperator(
// Errors that CreateOrJoinChannel refuses a join with.
var (
ErrBannedFromChannel = errors.New("banned from channel")
ErrInviteOnlyChannel = errors.New("channel is invite-only")
ErrBadChannelKey = errors.New("wrong channel key")
ErrChannelIsFull = errors.New("channel is full")
)
// CreateOrJoinChannel adds a session to the channel called
// name, creating the channel if it does not exist. The
// session is made operator if the channel has no members;
// otherwise checkJoinRestrictions may refuse it. A join
// uses up the session's invite to the channel. It returns
// the channel ID and whether the session was made operator.
// All of it is one transaction, and the database has one
// connection, so every other statement waits until the
// join is decided.
func (database *Database) CreateOrJoinChannel(
ctx context.Context,
channelID, sessionID int64,
) error {
_, err := database.conn.ExecContext(ctx,
name string,
sessionID int64,
suppliedKey string,
) (int64, bool, error) {
transaction, err := database.conn.BeginTx(ctx, nil)
if err != nil {
return 0, false, fmt.Errorf("begin join: %w", err)
}
defer func() { _ = transaction.Rollback() }()
now := time.Now()
_, err = transaction.ExecContext(ctx,
`INSERT OR IGNORE INTO channels
(name, created_at, updated_at)
VALUES (?, ?, ?)`,
name, now, now)
if err != nil {
return 0, false, fmt.Errorf("create channel: %w", err)
}
var channelID, memberCount int64
err = transaction.QueryRowContext(ctx,
`SELECT id,
(SELECT COUNT(*) FROM channel_members
WHERE channel_id = channels.id)
FROM channels WHERE name = ?`,
name,
).Scan(&channelID, &memberCount)
if err != nil {
return 0, false, fmt.Errorf("get channel: %w", err)
}
isOperator := memberCount == 0
if !isOperator {
err = checkJoinRestrictions(
ctx, transaction, channelID, sessionID,
suppliedKey, memberCount,
)
if err != nil {
return 0, false, err
}
}
_, err = transaction.ExecContext(ctx,
`INSERT OR IGNORE INTO channel_members
(channel_id, session_id, is_operator, joined_at)
VALUES (?, ?, 1, ?)`,
channelID, sessionID, time.Now())
VALUES (?, ?, ?, ?)`,
channelID, sessionID, isOperator, now)
if err != nil {
return fmt.Errorf(
"join channel as operator: %w", err,
)
return 0, false, fmt.Errorf("join channel: %w", err)
}
_, err = transaction.ExecContext(ctx,
`DELETE FROM channel_invites
WHERE channel_id = ? AND session_id = ?`,
channelID, sessionID)
if err != nil {
return 0, false, fmt.Errorf("clear invite: %w", err)
}
err = transaction.Commit()
if err != nil {
return 0, false, fmt.Errorf("commit join: %w", err)
}
return channelID, isOperator, nil
}
// checkJoinRestrictions returns the error that refuses a
// session's join of a channel with memberCount members:
// ErrBannedFromChannel when a ban matches its hostmask,
// ErrInviteOnlyChannel when the channel is +i and the
// session has no invite, ErrBadChannelKey when suppliedKey
// is not the +k key, or ErrChannelIsFull when the channel
// has at least its +l number of members. It returns nil
// when none applies.
func checkJoinRestrictions(
ctx context.Context,
transaction *sql.Tx,
channelID, sessionID int64,
suppliedKey string,
memberCount int64,
) error {
banned, err := isSessionBannedInTx(
ctx, transaction, channelID, sessionID,
)
if err != nil {
return err
}
if banned {
return ErrBannedFromChannel
}
var (
inviteOnly, invited bool
key string
userLimit int64
)
err = transaction.QueryRowContext(ctx,
`SELECT is_invite_only, channel_key, user_limit,
EXISTS (SELECT 1 FROM channel_invites
WHERE channel_id = channels.id
AND session_id = ?)
FROM channels WHERE id = ?`,
sessionID, channelID,
).Scan(&inviteOnly, &key, &userLimit, &invited)
if err != nil {
return fmt.Errorf("get channel modes: %w", err)
}
switch {
case inviteOnly && !invited:
return ErrInviteOnlyChannel
case key != "" && suppliedKey != key:
return ErrBadChannelKey
case userLimit > 0 && memberCount >= userLimit:
return ErrChannelIsFull
}
return nil
}
// CountChannelMembers returns the number of members in
// a channel.
func (database *Database) CountChannelMembers(
// isSessionBannedInTx is IsSessionBanned, reading through
// transaction.
func isSessionBannedInTx(
ctx context.Context,
channelID int64,
) (int64, error) {
var count int64
transaction *sql.Tx,
channelID, sessionID int64,
) (bool, error) {
var nick, username, hostname string
err := database.conn.QueryRowContext(ctx,
`SELECT COUNT(*) FROM channel_members
WHERE channel_id = ?`,
channelID,
).Scan(&count)
err := transaction.QueryRowContext(ctx,
`SELECT nick, username, hostname
FROM sessions WHERE id = ?`,
sessionID,
).Scan(&nick, &username, &hostname)
if err != nil {
return 0, fmt.Errorf(
"count channel members: %w", err,
return false, fmt.Errorf(
"get session hostmask: %w", err,
)
}
return count, nil
hostmask := FormatHostmask(nick, username, hostname)
rows, err := transaction.QueryContext(ctx,
`SELECT mask FROM channel_bans
WHERE channel_id = ?`,
channelID)
if err != nil {
return false, fmt.Errorf("list channel bans: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var mask string
err = rows.Scan(&mask)
if err != nil {
return false, fmt.Errorf(
"scan channel ban: %w", err,
)
}
if MatchBanMask(mask, hostmask) {
return true, nil
}
}
err = rows.Err()
if err != nil {
return false, fmt.Errorf(
"iterate channel bans: %w", err,
)
}
return false, nil
}
// IsChannelOperator checks if a session has operator
@@ -729,6 +890,49 @@ func (database *Database) DeleteChannelIfEmpty(
return nil
}
// PartChannelAndDeleteIfEmpty removes a session from a
// channel and deletes the channel if that left it with no
// members, in one transaction, so that no JOIN lands
// between the two.
func (database *Database) PartChannelAndDeleteIfEmpty(
ctx context.Context,
channelID, sessionID int64,
) error {
transaction, err := database.conn.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin part: %w", err)
}
defer func() { _ = transaction.Rollback() }()
_, err = transaction.ExecContext(ctx,
`DELETE FROM channel_members
WHERE channel_id = ? AND session_id = ?`,
channelID, sessionID)
if err != nil {
return fmt.Errorf("part channel: %w", err)
}
_, err = transaction.ExecContext(ctx,
`DELETE FROM channels WHERE id = ?
AND NOT EXISTS
(SELECT 1 FROM channel_members
WHERE channel_id = ?)`,
channelID, channelID)
if err != nil {
return fmt.Errorf(
"delete channel if empty: %w", err,
)
}
err = transaction.Commit()
if err != nil {
return fmt.Errorf("commit part: %w", err)
}
return nil
}
// scanChannels scans rows into a ChannelInfo slice.
func scanChannels(
rows *sql.Rows,
@@ -2100,23 +2304,6 @@ func (database *Database) HasChannelInvite(
return count > 0, nil
}
// ClearChannelInvite removes a session's invite to a
// channel (called after successful JOIN).
func (database *Database) ClearChannelInvite(
ctx context.Context,
channelID, sessionID int64,
) error {
_, err := database.conn.ExecContext(ctx,
`DELETE FROM channel_invites
WHERE channel_id = ? AND session_id = ?`,
channelID, sessionID)
if err != nil {
return fmt.Errorf("clear invite: %w", err)
}
return nil
}
// --- Tier 2: Secret (+s) ---
// IsChannelSecret checks if a channel has +s mode.