Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ linters:
- demo-app

rules:
- path: "(scheduler|internal|session|auth|application|doc)/*"
- path: "(session|auth)/*"
linters:
Comment on lines 70 to 72
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 | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

golangci-lint exclusion rules path regex pattern matching semantics

💡 Result:

  • path / path-except are regular expressions matched against the issue’s file path string (not glob patterns). Examples in the docs use regexes like (.+)_test\.go / _test\.go. [1]
  • The match is not implicitly anchored: your regex can match any substring of the path; use ^...$ if you need a full-string match (this follows Go’s regexp “match anywhere unless you anchor” behavior). [1]
  • What “path” is matched depends on run.relative-path-mode (this setting is used by exclusions):
    gomod (relative to the go.mod dir), gitroot (relative to repo root), cfg (relative to config file), or wd (relative to where you run golangci-lint). [2]
  • Path separator handling: in exclusion patterns, / is replaced with the OS-specific path separator so configs work on Windows. [3]
  • Older (v1) docs: exclusion path matching was described as relative to the current working directory, and --path-prefix could be used to extend paths before matching. [4]
  • Gotcha when running from subdirectories: historically, some path-based settings were “matched textually” and could fail to match when invoked from a subdir (see discussion/issue). [5]

Sources: [1] [2] [3] [4] [5]


Anchor the revive exclusion regex to the path root to prevent unintended matches.

(session|auth)/* is unanchored and will match the substrings "session" or "auth" anywhere in the file path. According to golangci-lint's regex semantics (which follow Go's regexp behavior), patterns must use ^...$ anchors for full-string matching. This means paths like docs/authors/something.go would be unintentionally excluded since they contain "auth" as a substring.

Change to ^(session|auth)(/|$) to match only these directories at the root level:

🔧 Suggested fix
-      - path: "(session|auth)/*"
+      - path: "^(session|auth)(/|$)"
🤖 Prompt for AI Agents
In @.golangci.yml around lines 70 - 72, The revive exclusion regex in
.golangci.yml currently uses an unanchored pattern "(session|auth)/*" which
matches substrings anywhere in a path; update the path pattern under the rules
entry to an anchored regex so it only matches the session or auth directories at
the root by replacing "(session|auth)/*" with the anchored pattern
^(session|auth)(/|$), ensuring it matches only those directory names and not
substrings in other paths.

- revive

Expand Down
9 changes: 6 additions & 3 deletions application/application.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Package application provides core application lifecycle management.
package application

import (
Expand Down Expand Up @@ -37,16 +38,16 @@ type Application struct {
services map[string]Runner
healthcheckers map[string]Healthchecker
databases map[string]*database.Database
health *ApplicationHealth
health *Health
}

// New creates and returns a new Application instance.
func New() *Application {
return &Application{services: make(map[string]Runner), healthcheckers: make(map[string]Healthchecker), databases: make(map[string]*database.Database), health: NewApplicationHealth()}
return &Application{services: make(map[string]Runner), healthcheckers: make(map[string]Healthchecker), databases: make(map[string]*database.Database), health: NewHealth()}
}

// Health returns the current health status of the application.
func (a *Application) Health(ctx context.Context) *ApplicationHealth {
func (a *Application) Health(ctx context.Context) *Health {
for hcName, hc := range a.healthcheckers {
a.health.SetServiceData(hcName, hc.Healthcheck(ctx))
}
Expand All @@ -58,6 +59,7 @@ func (a *Application) OnStart(task Runner, config StartupTaskConfig) {
a.startupTasks = append(a.startupTasks, startupTask{task, config})
}

// OnStartFunc registers a startup task using a RunnerFunc.
func (a *Application) OnStartFunc(task RunnerFunc, config StartupTaskConfig) {
a.startupTasks = append(a.startupTasks, startupTask{task, config})
}
Expand All @@ -84,6 +86,7 @@ func (a *Application) RegisterService(serviceName string, service Runner) {
}
}

// RegisterDomain registers a domain repository in the specified database.
func (a *Application) RegisterDomain(name, dbName string, domain Domain) {
if dbName != "" {
repository := domain.GetRepository()
Expand Down
1 change: 1 addition & 0 deletions application/domain.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package application

// Domain describes a domain module that exposes its repository.
type Domain interface {
GetRepository() any
}
31 changes: 21 additions & 10 deletions application/health.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,19 @@ import (
"time"
)

// ServiceStatus represents the lifecycle state of a service.
type ServiceStatus string

const (
// ServiceStatusNotStarted indicates service has not started yet.
ServiceStatusNotStarted ServiceStatus = "NOT_STARTED"
ServiceStatusStarted ServiceStatus = "STARTED"
ServiceStatusError ServiceStatus = "ERROR"
// ServiceStatusStarted indicates service is currently running.
ServiceStatusStarted ServiceStatus = "STARTED"
// ServiceStatusError indicates service finished with an error.
ServiceStatusError ServiceStatus = "ERROR"
)

// ServiceHealth contains health information for a single service.
type ServiceHealth struct {
Status ServiceStatus `json:"status"`
StartedAt *time.Time `json:"startedAt"`
Expand All @@ -21,16 +26,19 @@ type ServiceHealth struct {
Data any `json:"data,omitempty"`
}

type ApplicationHealth struct {
// Health contains overall application health and service states.
type Health struct {
StartedAt time.Time `json:"startedAt"`
Services map[string]*ServiceHealth `json:"services"`
}
Comment on lines +29 to 33
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:

# First, let's find and inspect the health.go file
find . -name "health.go" -type f

Repository: platforma-dev/platforma

Length of output: 90


🏁 Script executed:

# Let's examine the Health struct and related methods
cat -n application/health.go | head -100

Repository: platforma-dev/platforma

Length of output: 2928


🏁 Script executed:

# Search for calls to the Health methods to understand concurrent access patterns
rg "StartService|FailService|SetServiceData|String" --type go -B 2 -A 2 -i

Repository: platforma-dev/platforma

Length of output: 40513


🏁 Script executed:

# Also search for service runners and goroutine patterns
rg "go " --type go | grep -E "Service|Runner|health"

Repository: platforma-dev/platforma

Length of output: 49


🏁 Script executed:

# Check the service launching code in application.go
sed -n '130,160p' application/application.go

Repository: platforma-dev/platforma

Length of output: 938


🏁 Script executed:

# Look for all places where health methods are called
rg "health\.(StartService|FailService|SetServiceData|String)" --type go -B 2 -A 2

Repository: platforma-dev/platforma

Length of output: 953


🏁 Script executed:

# Verify the exact line numbers for all methods
sed -n '40,82p' application/health.go | cat -n

Repository: platforma-dev/platforma

Length of output: 1459


Guard Health.Services against concurrent map access with a mutex.

Multiple service goroutines call StartService(), FailService(), and SetServiceData() concurrently while the Health() endpoint calls SetServiceData() and String() reads the map. Unguarded concurrent map mutations will trigger panics at runtime.

Suggested fix (mutex + locking)
 import (
 	"encoding/json"
+	"sync"
 	"time"
 )
@@
 type Health struct {
+	mu        sync.RWMutex
 	StartedAt time.Time                 `json:"startedAt"`
 	Services  map[string]*ServiceHealth `json:"services"`
 }
@@
 func (h *Health) StartService(serviceName string) {
+	h.mu.Lock()
+	defer h.mu.Unlock()
 	if service, ok := h.Services[serviceName]; ok {
 		service.Status = ServiceStatusStarted
@@
 func (h *Health) FailService(serviceName string, err error) {
+	h.mu.Lock()
+	defer h.mu.Unlock()
 	if service, ok := h.Services[serviceName]; ok {
@@
 func (h *Health) SetServiceData(serviceName string, data any) {
+	h.mu.Lock()
+	defer h.mu.Unlock()
 	if service, ok := h.Services[serviceName]; ok {
 		service.Data = data
 		h.Services[serviceName] = service
 	}
 }
@@
 func (h *Health) String() string {
+	h.mu.RLock()
+	defer h.mu.RUnlock()
 	b, _ := json.Marshal(h)
 	return string(b)
 }
@@
 func (h *Health) StartApplication() {
+	h.mu.Lock()
+	defer h.mu.Unlock()
 	h.StartedAt = time.Now()
 }
📝 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
// Health contains overall application health and service states.
type Health struct {
StartedAt time.Time `json:"startedAt"`
Services map[string]*ServiceHealth `json:"services"`
}
import (
"encoding/json"
"sync"
"time"
)
// Health contains overall application health and service states.
type Health struct {
mu sync.RWMutex
StartedAt time.Time `json:"startedAt"`
Services map[string]*ServiceHealth `json:"services"`
}
🤖 Prompt for AI Agents
In `@application/health.go` around lines 29 - 33, Health.Services is accessed
concurrently and needs synchronization: add a sync.RWMutex field to the Health
struct and use it to guard the Services map; acquire the write lock
(Lock/Unlock) inside StartService, FailService, and SetServiceData (ensure those
methods have pointer receivers) and acquire the read lock (RLock/RUnlock) inside
String and any read-only access such as the Health() endpoint handler before
iterating or reading from Services. Ensure all new or existing methods that
touch Services use the mutex consistently to prevent concurrent map writes and
panics.


func NewApplicationHealth() *ApplicationHealth {
return &ApplicationHealth{Services: make(map[string]*ServiceHealth)}
// NewHealth creates an ApplicationHealth with initialized storage.
func NewHealth() *Health {
return &Health{Services: make(map[string]*ServiceHealth)}
Comment on lines +35 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 | 🟡 Minor

Update doc comment to reference Health.

The comment still mentions ApplicationHealth, which no longer exists.

✏️ Suggested fix
-// NewHealth creates an ApplicationHealth with initialized storage.
+// NewHealth creates a Health with initialized storage.
📝 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
// NewHealth creates an ApplicationHealth with initialized storage.
func NewHealth() *Health {
return &Health{Services: make(map[string]*ServiceHealth)}
// NewHealth creates a Health with initialized storage.
func NewHealth() *Health {
return &Health{Services: make(map[string]*ServiceHealth)}
🤖 Prompt for AI Agents
In `@application/health.go` around lines 35 - 37, The doc comment for NewHealth
incorrectly references ApplicationHealth; update it to reference the current
type Health and follow Go doc convention (start with the function name). e.g.
change the comment to describe that NewHealth creates a Health (or initializes
Services) so the comment matches the NewHealth function and the Health type.

}

func (h *ApplicationHealth) StartService(serviceName string) {
// StartService marks the given service as started and stores start time.
func (h *Health) StartService(serviceName string) {
if service, ok := h.Services[serviceName]; ok {
service.Status = ServiceStatusStarted

Expand All @@ -41,7 +49,8 @@ func (h *ApplicationHealth) StartService(serviceName string) {
}
}

func (h *ApplicationHealth) FailService(serviceName string, err error) {
// FailService marks the given service as failed and stores the error.
func (h *Health) FailService(serviceName string, err error) {
if service, ok := h.Services[serviceName]; ok {
service.Status = ServiceStatusError

Expand All @@ -54,18 +63,20 @@ func (h *ApplicationHealth) FailService(serviceName string, err error) {
}
}

func (h *ApplicationHealth) SetServiceData(serviceName string, data any) {
// SetServiceData stores additional health payload for the given service.
func (h *Health) SetServiceData(serviceName string, data any) {
if service, ok := h.Services[serviceName]; ok {
service.Data = data
h.Services[serviceName] = service
}
}

func (h *ApplicationHealth) String() string {
func (h *Health) String() string {
b, _ := json.Marshal(h)
return string(b)
}

func (h *ApplicationHealth) StartApplication() {
// StartApplication marks application start time.
func (h *Health) StartApplication() {
h.StartedAt = time.Now()
}
4 changes: 3 additions & 1 deletion application/healthcheck.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import (
)

type healther interface {
Health(context.Context) *ApplicationHealth
Health(context.Context) *Health
}

// HealthCheckHandler serves application health information as JSON.
type HealthCheckHandler struct {
app healther
}

// NewHealthCheckHandler creates a HealthCheckHandler for the given application.
func NewHealthCheckHandler(app healther) *HealthCheckHandler {
return &HealthCheckHandler{app: app}
}
Expand Down
100 changes: 100 additions & 0 deletions demo-app/cmd/scheduler-cron/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package main

import (
"context"
"fmt"
"time"

"github.com/platforma-dev/platforma/application"
"github.com/platforma-dev/platforma/log"
"github.com/platforma-dev/platforma/scheduler"
)

func dailyBackup(ctx context.Context) error {
log.InfoContext(ctx, "executing daily backup task")
return nil
}

func weekdayReport(ctx context.Context) error {
log.InfoContext(ctx, "generating weekday report")
return nil
}

func frequentHealthCheck(ctx context.Context) error {
log.InfoContext(ctx, "performing health check")
return nil
}

func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

// Example 1: Using @every syntax - every 5 seconds
s1, err := scheduler.New("@every 5s", application.RunnerFunc(func(ctx context.Context) error {
log.InfoContext(ctx, "@every syntax: every 5 seconds")
return nil
}))
if err != nil {
log.ErrorContext(ctx, "failed to create scheduler 1", "error", err)
return
}

// Example 2: Using @every syntax - every 3 seconds
s2, err := scheduler.New("@every 3s", application.RunnerFunc(func(ctx context.Context) error {
log.InfoContext(ctx, "@every syntax: every 3 seconds")
return nil
}))
if err != nil {
log.ErrorContext(ctx, "failed to create scheduler 2", "error", err)
return
}

// Example 3: Daily task (would run at midnight, but won't execute in this demo)
s3, err := scheduler.New("@daily", application.RunnerFunc(dailyBackup))
if err != nil {
log.ErrorContext(ctx, "failed to create scheduler 3", "error", err)
return
}

// Example 4: Weekday task (would run at 9 AM on weekdays, won't execute in this demo)
s4, err := scheduler.New("0 9 * * MON-FRI", application.RunnerFunc(weekdayReport))
if err != nil {
log.ErrorContext(ctx, "failed to create scheduler 4", "error", err)
return
}

// Example 5: Hourly task (won't execute in this demo)
s5, err := scheduler.New("@hourly", application.RunnerFunc(frequentHealthCheck))
if err != nil {
log.ErrorContext(ctx, "failed to create scheduler 5", "error", err)
return
}

fmt.Println("Starting cron scheduler demo...")
fmt.Println("Active schedulers:")
fmt.Println(" 1. Every 5 seconds (@every 5s)")
fmt.Println(" 2. Every 3 seconds (@every 3s)")
fmt.Println(" 3. Daily at midnight (@daily) - won't execute in demo")
fmt.Println(" 4. Weekdays at 9 AM (0 9 * * MON-FRI) - won't execute in demo")
fmt.Println(" 5. Hourly (@hourly) - won't execute in demo")
fmt.Println()
fmt.Println("Watch the logs for executions. Demo will run for 15 seconds.")
fmt.Println()

// Start all schedulers in background
go s1.Run(ctx)
go s2.Run(ctx)
go s3.Run(ctx)
go s4.Run(ctx)
go s5.Run(ctx)

// Run for 15 seconds to demonstrate the frequent tasks
time.Sleep(15 * time.Second)
cancel()

// Allow graceful shutdown
time.Sleep(100 * time.Millisecond)

fmt.Println()
fmt.Println("Demo completed!")
}
6 changes: 5 additions & 1 deletion demo-app/cmd/scheduler/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ func scheduledTask(ctx context.Context) error {
func main() {
ctx, cancel := context.WithCancel(context.Background())

s := scheduler.New(time.Second, application.RunnerFunc(scheduledTask))
s, err := scheduler.New("@every 1s", application.RunnerFunc(scheduledTask))
if err != nil {
log.ErrorContext(ctx, "failed to create scheduler", "error", err)
return
}

go func() {
time.Sleep(3500 * time.Millisecond)
Expand Down
Loading