Fix unbounded memory growth in notification storage issue

This commit is contained in:
2025-10-26 00:17:13 -07:00
parent a3365c303a
commit 6291cfe218
21 changed files with 4686 additions and 53 deletions
+403
View File
@@ -0,0 +1,403 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"strings"
"time"
"github.com/igodwin/notifier/pkg/client"
)
func main() {
// Command
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
command := os.Args[1]
switch command {
case "send":
cmdSend(os.Args[2:])
case "status":
cmdStatus(os.Args[2:])
case "list":
cmdList(os.Args[2:])
case "stats":
cmdStats(os.Args[2:])
case "notifiers":
cmdNotifiers(os.Args[2:])
case "health":
cmdHealth(os.Args[2:])
default:
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command)
printUsage()
os.Exit(1)
}
}
func printUsage() {
fmt.Print(`Notifier Client - CLI for sending notifications
Usage:
client <command> [options]
Commands:
send Send a notification
status Get notification status
list List notifications
stats Get notification statistics
notifiers List available notifiers
health Check service health
Global Options:
--url Service URL (default: http://localhost:8080)
--key API key for authentication (optional)
--timeout Request timeout (default: 30s)
Examples:
# Send email notification
client send --type email --subject "Alert" --body "System down" --recipients user@example.com
# Check notification status
client status --id <notification-id>
# List recent notifications
client list --limit 10
# Get service stats
client stats
# Check health
client health --url http://localhost:8080
`)
}
func cmdSend(args []string) {
fs := flag.NewFlagSet("send", flag.ExitOnError)
fs.Usage = func() {
fmt.Print(`Send a notification
Usage:
client send [options]
Options:
--url Service URL (default: http://localhost:8080)
--key API key (optional)
--type Notification type (stdout, email, slack, ntfy) - required
--subject Subject line
--body Message body - required
--account Account name (optional, uses default)
--recipients Comma-separated recipients
--timeout Request timeout (default: 30s)
`)
}
baseURL := fs.String("url", "http://localhost:8080", "")
apiKey := fs.String("key", "", "")
timeout := fs.Duration("timeout", 30*time.Second, "")
notifType := fs.String("type", "", "")
subject := fs.String("subject", "", "")
body := fs.String("body", "", "")
account := fs.String("account", "", "")
recipients := fs.String("recipients", "", "")
fs.Parse(args)
if *notifType == "" || *body == "" {
fmt.Fprintf(os.Stderr, "Error: --type and --body are required\n")
fs.Usage()
os.Exit(1)
}
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
cfg := client.ClientConfig{
BaseURL: *baseURL,
APIKey: *apiKey,
Timeout: *timeout,
TLSInsecure: false,
}
c := client.NewRESTClient(cfg)
recipientList := []string{}
if *recipients != "" {
recipientList = strings.Split(*recipients, ",")
for i := range recipientList {
recipientList[i] = strings.TrimSpace(recipientList[i])
}
}
req := client.NotificationRequest{
Type: *notifType,
Subject: *subject,
Body: *body,
Account: *account,
Recipients: recipientList,
}
resp, err := c.Send(ctx, req)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
data, _ := json.MarshalIndent(resp, "", " ")
fmt.Println(string(data))
}
func cmdStatus(args []string) {
fs := flag.NewFlagSet("status", flag.ExitOnError)
fs.Usage = func() {
fmt.Print(`Get notification status
Usage:
client status [options]
Options:
--url Service URL (default: http://localhost:8080)
--key API key (optional)
--id Notification ID - required
--timeout Request timeout (default: 30s)
`)
}
baseURL := fs.String("url", "http://localhost:8080", "")
apiKey := fs.String("key", "", "")
timeout := fs.Duration("timeout", 30*time.Second, "")
id := fs.String("id", "", "")
fs.Parse(args)
if *id == "" {
fmt.Fprintf(os.Stderr, "Error: --id is required\n")
fs.Usage()
os.Exit(1)
}
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
cfg := client.ClientConfig{
BaseURL: *baseURL,
APIKey: *apiKey,
Timeout: *timeout,
TLSInsecure: false,
}
c := client.NewRESTClient(cfg)
notif, err := c.GetNotification(ctx, *id)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
data, _ := json.MarshalIndent(notif, "", " ")
fmt.Println(string(data))
}
func cmdList(args []string) {
fs := flag.NewFlagSet("list", flag.ExitOnError)
fs.Usage = func() {
fmt.Print(`List notifications
Usage:
client list [options]
Options:
--url Service URL (default: http://localhost:8080)
--key API key (optional)
--type Filter by type (comma-separated)
--status Filter by status (comma-separated)
--limit Limit results (default: 10)
--offset Offset (default: 0)
--timeout Request timeout (default: 30s)
`)
}
baseURL := fs.String("url", "http://localhost:8080", "")
apiKey := fs.String("key", "", "")
timeout := fs.Duration("timeout", 30*time.Second, "")
filterType := fs.String("type", "", "")
filterStatus := fs.String("status", "", "")
limit := fs.Int("limit", 10, "")
offset := fs.Int("offset", 0, "")
fs.Parse(args)
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
cfg := client.ClientConfig{
BaseURL: *baseURL,
APIKey: *apiKey,
Timeout: *timeout,
TLSInsecure: false,
}
c := client.NewRESTClient(cfg)
filter := client.ListNotificationsRequest{
Limit: *limit,
Offset: *offset,
}
if *filterType != "" {
filter.Types = strings.Split(*filterType, ",")
}
if *filterStatus != "" {
statuses := strings.Split(*filterStatus, ",")
for _, s := range statuses {
filter.Statuses = append(filter.Statuses, client.NotificationStatus(strings.TrimSpace(s)))
}
}
resp, err := c.ListNotifications(ctx, filter)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
data, _ := json.MarshalIndent(resp, "", " ")
fmt.Println(string(data))
}
func cmdStats(args []string) {
fs := flag.NewFlagSet("stats", flag.ExitOnError)
fs.Usage = func() {
fmt.Print(`Get notification statistics
Usage:
client stats [options]
Options:
--url Service URL (default: http://localhost:8080)
--key API key (optional)
--timeout Request timeout (default: 30s)
`)
}
baseURL := fs.String("url", "http://localhost:8080", "")
apiKey := fs.String("key", "", "")
timeout := fs.Duration("timeout", 30*time.Second, "")
fs.Parse(args)
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
cfg := client.ClientConfig{
BaseURL: *baseURL,
APIKey: *apiKey,
Timeout: *timeout,
TLSInsecure: false,
}
c := client.NewRESTClient(cfg)
stats, err := c.GetStats(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
data, _ := json.MarshalIndent(stats, "", " ")
fmt.Println(string(data))
}
func cmdNotifiers(args []string) {
fs := flag.NewFlagSet("notifiers", flag.ExitOnError)
fs.Usage = func() {
fmt.Print(`List available notifiers
Usage:
client notifiers [options]
Options:
--url Service URL (default: http://localhost:8080)
--key API key (optional)
--timeout Request timeout (default: 30s)
`)
}
baseURL := fs.String("url", "http://localhost:8080", "")
apiKey := fs.String("key", "", "")
timeout := fs.Duration("timeout", 30*time.Second, "")
fs.Parse(args)
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
cfg := client.ClientConfig{
BaseURL: *baseURL,
APIKey: *apiKey,
Timeout: *timeout,
TLSInsecure: false,
}
c := client.NewRESTClient(cfg)
notifiers, err := c.GetNotifiers(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
data, _ := json.MarshalIndent(notifiers, "", " ")
fmt.Println(string(data))
}
func cmdHealth(args []string) {
fs := flag.NewFlagSet("health", flag.ExitOnError)
fs.Usage = func() {
fmt.Print(`Check service health
Usage:
client health [options]
Options:
--url Service URL (default: http://localhost:8080)
--timeout Request timeout (default: 30s)
`)
}
baseURL := fs.String("url", "http://localhost:8080", "")
timeout := fs.Duration("timeout", 30*time.Second, "")
fs.Parse(args)
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
cfg := client.ClientConfig{
BaseURL: *baseURL,
Timeout: *timeout,
TLSInsecure: false,
}
c := client.NewRESTClient(cfg)
healthy, err := c.HealthCheck(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if healthy {
fmt.Println("Service is healthy")
os.Exit(0)
} else {
fmt.Println("Service is unhealthy")
os.Exit(1)
}
}
+11 -1
View File
@@ -12,6 +12,7 @@ import (
"syscall"
"time"
"github.com/gorilla/mux"
grpcapi "github.com/igodwin/notifier/api/grpc"
pb "github.com/igodwin/notifier/api/grpc/pb"
"github.com/igodwin/notifier/api/rest"
@@ -22,7 +23,6 @@ import (
"github.com/igodwin/notifier/internal/notifier"
"github.com/igodwin/notifier/internal/queue"
"github.com/igodwin/notifier/internal/service"
"github.com/gorilla/mux"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
)
@@ -101,6 +101,16 @@ func main() {
// Create notification service (pass config as account resolver)
svc := service.NewNotificationService(factory, q, cfg.Queue.WorkerCount, cfg, logger)
// Configure notification retention if enabled
if err := svc.WithRetentionConfig(cfg.Retention); err != nil {
logger.Warnf("Failed to configure retention: %v", err)
// Log defaults that will be used
logger.Infof("Using default retention config: enabled=%v", cfg.Retention.Enabled)
} else if cfg.Retention.Enabled {
logger.Infof("Configured notification retention: ttl=%s, check_frequency=%s, max_size=%d",
cfg.Retention.TTL, cfg.Retention.CheckFrequency, cfg.Retention.MaxSize)
}
// Start workers
if err := svc.Start(ctx); err != nil {
logger.Fatalf("Failed to start service: %v", err)