Compare commits

...

4 commits
v0.2 ... main

Author SHA1 Message Date
42d3251251 Fix misrouted notifications from SQLite lock contention
send_message silently fell back to the default conversation whenever the
plan-time or slack-channel lookup errored, because the query error was
ignored. A transient "database is locked" (from the channel-creation/sync
routine holding the write lock while making slow Slack API calls) thus
misrouted a service notification to the admin DM instead of the event
channel.

- api.go: capture lookup errors and only treat gorm.ErrRecordNotFound as
  "no service / no channel"; on any other error, fail so the caller retries
  rather than posting to the wrong conversation.
- database.go: open SQLite with WAL journaling and a 10s busy timeout so
  reads proceed alongside the sync writer instead of locking.
- Bump version to 0.2.2.
2026-06-21 10:19:41 -05:00
6857622c26 Replace fatal errors with non-fatal logging to improve resilience
Version bump to 0.2.1. Changed log.Fatalln calls to log.Println with
continue/return so that individual API errors no longer crash the
entire service.
2026-03-22 10:38:42 -05:00
b6777746e4 Fix topic set after channel creation 2023-10-05 19:32:19 -05:00
dd0cab8a9d Fix readme 2023-10-01 08:00:16 -05:00
5 changed files with 68 additions and 18 deletions

29
api.go
View file

@ -2,12 +2,14 @@ package main
import ( import (
"encoding/json" "encoding/json"
"errors"
"log" "log"
"net/http" "net/http"
"time" "time"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/slack-go/slack" "github.com/slack-go/slack"
"gorm.io/gorm"
) )
// Commonly used strings. // Commonly used strings.
@ -98,13 +100,34 @@ func (s *HTTPServer) RegisterAPIRoutes(r *mux.Router) {
now := time.Now().UTC() now := time.Now().UTC()
conversation := app.config.Slack.DefaultConversation conversation := app.config.Slack.DefaultConversation
// Find plan times that are occuring right now. // Find plan times that are occuring right now. A 60-minute buffer
// is applied past ends_at so services that run long still resolve
// to their event channel instead of falling back to the default.
// Order by starts_at DESC so the most recent active service wins
// when a later service's window overlaps an earlier service's buffer.
var planTime PlanTimes var planTime PlanTimes
app.db.Where("time_type='service' AND starts_at < ? AND ends_at > ?", now, now).First(&planTime) err = app.db.Where("time_type='service' AND starts_at < ? AND ends_at > ?", now, now.Add(-60*time.Minute)).Order("starts_at DESC").First(&planTime).Error
// A "record not found" simply means no service is occuring right now, in
// which case we fall back to the default conversation. Any other error
// (e.g. "database is locked") must NOT be swallowed: treating it as "no
// service" would silently misroute the message to the default
// conversation instead of the event channel. Fail so the caller retries.
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
log.Println("Error looking up plan time:", err)
s.APISendGeneralResp(w, APIERR, "Error looking up plan time")
return
}
if planTime.Plan != 0 { if planTime.Plan != 0 {
// If plan found, check for the slack channel. // If plan found, check for the slack channel.
var channel SlackChannels var channel SlackChannels
app.db.Where("pc_plan = ?", planTime.Plan).First(&channel) err = app.db.Where("pc_plan = ?", planTime.Plan).First(&channel).Error
// As above, only "record not found" is a benign result here. On any
// other error we must not fall through to the default conversation.
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
log.Println("Error looking up slack channel:", err)
s.APISendGeneralResp(w, APIERR, "Error looking up slack channel")
return
}
if channel.ID != "" { if channel.ID != "" {
// If slack channel found, update the conversation to the channel ID. // If slack channel found, update the conversation to the channel ID.
conversation = channel.ID conversation = channel.ID

View file

@ -2,6 +2,7 @@ package main
import ( import (
"log" "log"
"strings"
"time" "time"
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
@ -126,7 +127,21 @@ func (a *App) InitDB() {
} }
// Depending on connection configuration, open the database. // Depending on connection configuration, open the database.
if a.config.DB.Type == "sqlite3" { if a.config.DB.Type == "sqlite3" {
a.db, err = gorm.Open(sqlite.Open(a.config.DB.Connection), dbConfig) // Enable WAL journaling and a busy timeout. Without WAL, a single
// long-running writer (e.g. the channel-creation/sync routine, which
// interleaves slow Slack API calls with its writes) blocks all readers,
// causing "database is locked" on concurrent reads such as the
// send_message channel lookup. WAL lets reads proceed alongside the
// writer, and the busy timeout makes any remaining contention wait
// rather than fail immediately. Append as DSN pragmas, preserving any
// query string already present in the configured connection.
conn := a.config.DB.Connection
sep := "?"
if strings.Contains(conn, "?") {
sep = "&"
}
conn += sep + "_journal_mode=WAL&_busy_timeout=10000"
a.db, err = gorm.Open(sqlite.Open(conn), dbConfig)
} else if a.config.DB.Type == "mysql" { } else if a.config.DB.Type == "mysql" {
a.db, err = gorm.Open(mysql.Open(a.config.DB.Connection), dbConfig) a.db, err = gorm.Open(mysql.Open(a.config.DB.Connection), dbConfig)
} else if a.config.DB.Type == "postgres" { } else if a.config.DB.Type == "postgres" {

View file

@ -13,7 +13,7 @@ import (
const ( const (
serviceName = "service-notifications" serviceName = "service-notifications"
serviceDescription = "Notifications for church services" serviceDescription = "Notifications for church services"
serviceVersion = "0.2" serviceVersion = "0.2.2"
) )
// App is the global application structure for communicating between servers and storing information. // App is the global application structure for communicating between servers and storing information.

View file

@ -110,7 +110,8 @@ func UpdatePCData() {
// Get the plans for this service type. // Get the plans for this service type.
allPlans, err := PCGetAll(fmt.Sprintf("/services/v2/service_types/%d/plans", serviceTypeID)) allPlans, err := PCGetAll(fmt.Sprintf("/services/v2/service_types/%d/plans", serviceTypeID))
if err != nil { if err != nil {
log.Fatalln(err) log.Println("Error getting plans for service type:", serviceTypeID, err)
continue
} }
// For each plan, update data in database and pull other plan releated items for updates. // For each plan, update data in database and pull other plan releated items for updates.
for _, data := range allPlans { for _, data := range allPlans {
@ -152,7 +153,8 @@ func UpdatePCData() {
// Get all times for this plan. // Get all times for this plan.
allPlanTimes, err := PCGetAll(fmt.Sprintf("/services/v2/service_types/%d/plans/%d/plan_times", serviceTypeID, planID)) allPlanTimes, err := PCGetAll(fmt.Sprintf("/services/v2/service_types/%d/plans/%d/plan_times", serviceTypeID, planID))
if err != nil { if err != nil {
log.Fatalln(err) log.Println("Error getting plan times for plan:", planID, err)
continue
} }
// With each time, save it to the database. // With each time, save it to the database.
for _, data := range allPlanTimes { for _, data := range allPlanTimes {
@ -188,7 +190,8 @@ func UpdatePCData() {
// Get all members of the plan. // Get all members of the plan.
allTeamMembers, err := PCGetAll(fmt.Sprintf("/services/v2/service_types/%d/plans/%d/team_members", serviceTypeID, planID)) allTeamMembers, err := PCGetAll(fmt.Sprintf("/services/v2/service_types/%d/plans/%d/team_members", serviceTypeID, planID))
if err != nil { if err != nil {
log.Fatalln(err) log.Println("Error getting team members for plan:", planID, err)
continue
} }
// With each member, update the database. // With each member, update the database.
for _, data := range allTeamMembers { for _, data := range allTeamMembers {
@ -226,11 +229,13 @@ func UpdateSlackData() {
// Get all users from Slack. // Get all users from Slack.
users, err := app.slack.GetUsers() users, err := app.slack.GetUsers()
if err != nil { if err != nil {
log.Fatalln(err) log.Println("Error getting Slack users:", err)
return
} }
// If no users returned, error as we should have some... // If no users returned, error as we should have some...
if len(users) == 0 { if len(users) == 0 {
log.Fatalln("No users found in Slack.") log.Println("No users found in Slack.")
return
} }
// With each user, update the database. // With each user, update the database.
for _, user := range users { for _, user := range users {
@ -354,7 +359,8 @@ func CreateSlackChannels() {
app.db.Where("time_type='service' AND starts_at > ? AND starts_at < ?", startDate, lastDate).Find(&planTimes) app.db.Where("time_type='service' AND starts_at > ? AND starts_at < ?", startDate, lastDate).Find(&planTimes)
// If no plan times matched, exit here. // If no plan times matched, exit here.
if len(planTimes) == 0 { if len(planTimes) == 0 {
log.Fatalln("No services found for this time frame.") log.Println("No services found for this time frame.")
return
} }
// With each plan time found, create a slack channel. // With each plan time found, create a slack channel.
@ -432,22 +438,28 @@ func CreateSlackChannels() {
log.Println("Creating channel:", channel.Name) log.Println("Creating channel:", channel.Name)
schan, err := app.slack.CreateConversation(channelInfo) schan, err := app.slack.CreateConversation(channelInfo)
if err != nil { if err != nil {
log.Fatalln("Failed to create channel:", err) log.Println("Failed to create channel:", err)
continue
} }
// If topic is defined, set the topic and purpose. // If topic is defined, set the topic and purpose.
if topic != "" { if topic != "" {
// Sleep before, as it takes time for Slack APIs // Keep count of failures so we can try again.
// to recongize the channel was created. failed := 0
time.Sleep(120 * time.Second) _, err = app.slack.SetTopicOfConversation(schan.ID, topic)
_, err = app.slack.SetTopicOfConversation(channel.ID, topic)
if err != nil { if err != nil {
failed++
log.Println("Failed to set topic:", err) log.Println("Failed to set topic:", err)
} }
_, err = app.slack.SetPurposeOfConversation(channel.ID, topic) _, err = app.slack.SetPurposeOfConversation(schan.ID, topic)
if err != nil { if err != nil {
failed++
log.Println("Failed to set purpose:", err) log.Println("Failed to set purpose:", err)
} }
// If it failed, make topic empty so we can try again next run.
if failed != 0 {
topic = ""
}
} }
// Save the channel to the database. // Save the channel to the database.