Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ api/lib/
├── internals/
│ ├── controller/ # HTTP I/O(Echo)。DB アクセス禁止
│ └── repository/ # SQL 実行のみ。*sql.Rows を返す
└── externals/{db,server,slack}
└── externals/{db,server,slack,scheduler} # scheduler: 通知の定期実行 ticker ループ

mobile/lib/
├── pages/ # 画面(StatefulWidget)
Expand All @@ -49,6 +49,12 @@ mobile/lib/
gas/{shift,task,user,rescue}/ # ドメイン別。コード.js / onChange.js 等
```

## 通知の定期実行

未送信の通知ログ(`action_logs` の `is_sent = false`)は、`externals/scheduler` の ticker ループが API プロセス内で5分間隔に `NotificationUseCase.ProcessUnsentNotifications` を呼び、Slack DM へ flush します(`di.go` で配線。`cmd/send-notifications` は手動 flush 用として併存)。

**API は単一インスタンス前提**です。複数レプリカで動かすと各プロセスの ticker が同じ未送信ログを拾って二重送信します。本番(`docker-compose.prod.yml`)は API を1レプリカで運用しているため現状は問題ありません。複数レプリカ化する場合はリーダー選出や排他制御が必要です。

## Code Style

### Go (`api/`)
Expand Down
17 changes: 17 additions & 0 deletions api/lib/di/di.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
package di

import (
"context"
"log"
"time"

"github.com/NUTFes/SeeFT/api/lib/externals/db"
"github.com/NUTFes/SeeFT/api/lib/externals/scheduler"
"github.com/NUTFes/SeeFT/api/lib/externals/server"
"github.com/NUTFes/SeeFT/api/lib/externals/slack"
"github.com/NUTFes/SeeFT/api/lib/internals/controller"
"github.com/NUTFes/SeeFT/api/lib/internals/repository"
"github.com/NUTFes/SeeFT/api/lib/internals/repository/abstract"
Expand Down Expand Up @@ -93,6 +98,18 @@ func InitializeServer() db.Client {
reviewController,
)

// Scheduler: 5分間隔で未送信通知を flush する(goroutine で起動し即 return)
slackService, err := slack.NewSlackService()
if err != nil {
log.Fatalf("slack init: %v", err)
}
notificationUseCase := usecase.NewNotificationUseCase(
actionLogRepository, slackService,
userRepository, dateRepository, timeRepository,
taskRepository, shiftRepository, weatherRepository,
)
scheduler.New("notification", 5*time.Minute, notificationUseCase.ProcessUnsentNotifications).Start(context.Background())
Comment on lines +101 to +111

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# InitializeServer の呼び出し元を確認
rg -nP '\bInitializeServer\s*\(' --type=go -C3

Repository: NUTFes/SeeFT

Length of output: 519


🏁 Script executed:

#!/bin/bash
# scheduler.go の実装を確認
fd -e go -p scheduler | head -20

Repository: NUTFes/SeeFT

Length of output: 97


🏁 Script executed:

#!/bin/bash
# scheduler.go の内容を確認
find . -name "scheduler.go" -type f | xargs cat -n | head -100

Repository: NUTFes/SeeFT

Length of output: 1150


🏁 Script executed:

#!/bin/bash
# main.go の全体を確認
cat -n api/main.go

Repository: NUTFes/SeeFT

Length of output: 807


🏁 Script executed:

#!/bin/bash
# signal handling や graceful shutdown のパターンを検索
rg -i "signal|shutdown|cancel|interrupt" --type=go -C2 | head -150

Repository: NUTFes/SeeFT

Length of output: 38


🏁 Script executed:

#!/bin/bash
# ProcessUnsentNotifications のシグネチャを確認
rg -nP 'ProcessUnsentNotifications.*\(' --type=go -A5 | head -40

Repository: NUTFes/SeeFT

Length of output: 1653


🏁 Script executed:

#!/bin/bash
# scheduler の Job 型の使用箇所を確認
rg -nP 'scheduler.New|type Job' --type=go -B2 -A2

Repository: NUTFes/SeeFT

Length of output: 738


scheduler の Start() メソッドが context キャンセルに対応していないため、graceful shutdown が不可能

scheduler.go の Start() メソッド(27-36行)は、渡された context を job に渡すだけで、スケジューラー自体が context のキャンセルをチェック(<-ctx.Done())していません。そのため、context.Background() 以外の cancellable context を渡しても、ticker は無限に動作し続けます。

さらに、main.go にはシグナルハンドリングや graceful shutdown の仕組みがなく、サーバー停止時に scheduler を適切に停止できません。

修正が必要な箇所:

  • scheduler.go の Start() 内の ticker ループに、context キャンセルをチェックする select 文を追加
  • main.go(または server.RunServer)にシグナルハンドリングと context 伝播の仕組みを実装
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/lib/di/di.go` around lines 101 - 111, The scheduler's Start() method does
not properly handle context cancellation, preventing graceful shutdown. First,
modify the ticker loop in scheduler.go's Start() method to include a select
statement that checks for context cancellation using <-ctx.Done(), ensuring the
scheduler stops when the context is cancelled. Second, replace the
context.Background() call in the di.go initialization with a cancellable context
that is properly propagated from signal handling logic (add signal handling in
main.go or server.RunServer to catch SIGTERM and SIGINT), allowing the scheduler
to be gracefully stopped when the application receives shutdown signals.


// Server
server.RunServer(router)

Expand Down
37 changes: 37 additions & 0 deletions api/lib/externals/scheduler/scheduler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package scheduler

import (
"context"
"log"
"time"
)

// Job は scheduler が定期実行する処理。usecase を import せず関数型で受け取り、
// scheduler と業務ロジックを疎結合に保つ。
type Job func(ctx context.Context) error

// Scheduler は「名前・間隔・実行する処理」を保持する。
type Scheduler struct {
name string
interval time.Duration
job Job
}

// New はコンストラクタでSchedulerを生成する。
func New(name string, interval time.Duration, job Job) *Scheduler {
return &Scheduler{name: name, interval: interval, job: job}
}

// Start は ticker ループを goroutine で起動し、即座に return する。
// interval ごとに job を実行し、job が返したエラーはログ出力のみ(ループは止めない)。
func (s *Scheduler) Start(ctx context.Context) {
go func() {
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
for range ticker.C {
if err := s.job(ctx); err != nil {
log.Printf("[scheduler:%s] job error: %v", s.name, err)
}
}
}()
}
Comment on lines +25 to +37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Context のキャンセルに対応していない(goroutine リーク)

for range ticker.C は Context がキャンセルされても終了しないため、goroutine がリークします。サーバーのシャットダウン時に ticker が停止せず、graceful shutdown が実装できません。

🔒 Context キャンセル対応の修正案
 func (s *Scheduler) Start(ctx context.Context) {
 	go func() {
 		ticker := time.NewTicker(s.interval)
 		defer ticker.Stop()
-		for range ticker.C {
-			if err := s.job(ctx); err != nil {
-				log.Printf("[scheduler:%s] job error: %v", s.name, err)
-			}
+		for {
+			select {
+			case <-ticker.C:
+				if err := s.job(ctx); err != nil {
+					log.Printf("[scheduler:%s] job error: %v", s.name, err)
+				}
+			case <-ctx.Done():
+				log.Printf("[scheduler:%s] stopped: %v", s.name, ctx.Err())
+				return
+			}
 		}
 	}()
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Start は ticker ループを goroutine で起動し、即座に return する。
// interval ごとに job を実行し、job が返したエラーはログ出力のみ(ループは止めない)。
func (s *Scheduler) Start(ctx context.Context) {
go func() {
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
for range ticker.C {
if err := s.job(ctx); err != nil {
log.Printf("[scheduler:%s] job error: %v", s.name, err)
}
}
}()
}
// Start は ticker ループを goroutine で起動し、即座に return する。
// interval ごとに job を実行し、job が返したエラーはログ出力のみ(ループは止めない)。
func (s *Scheduler) Start(ctx context.Context) {
go func() {
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := s.job(ctx); err != nil {
log.Printf("[scheduler:%s] job error: %v", s.name, err)
}
case <-ctx.Done():
log.Printf("[scheduler:%s] stopped: %v", s.name, ctx.Err())
return
}
}
}()
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/lib/externals/scheduler/scheduler.go` around lines 25 - 37, The Start
method's goroutine does not respond to Context cancellation, causing goroutine
leaks and preventing graceful shutdown. Modify the for loop to use a select
statement that monitors both ticker.C for job execution and ctx.Done() for
cancellation signals. When the context is cancelled (ctx.Done() receives a
signal), the function should break the loop, allowing the defer ticker.Stop() to
execute and properly clean up resources. This ensures the goroutine exits when
the context is cancelled rather than running indefinitely.