build: /implement-feature mesh-interop --requirements 'Chat Service must cal...
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

This commit is contained in:
rdev-worker 2026-02-05 21:40:58 +00:00
parent 72fd32990a
commit 36d73dd23d
14 changed files with 1441 additions and 28 deletions

340
pkg/queue/redis.go Normal file
View File

@ -0,0 +1,340 @@
package queue
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/logging"
)
const (
// RedisQueueKey is the sorted set key for the job queue
RedisQueueKey = "jobs:queue"
// RedisJobPrefix is the prefix for job data hash keys
RedisJobPrefix = "jobs:data:"
// RedisRunningKey is the set of currently running job IDs
RedisRunningKey = "jobs:running"
)
// RedisQueue implements Producer and Consumer using Redis.
// Uses sorted sets for priority ordering and lists for atomic dequeue.
type RedisQueue struct {
client *redis.Client
logger *logging.Logger
}
// Ensure RedisQueue implements Queue at compile time.
var _ Queue = (*RedisQueue)(nil)
// NewRedisQueue creates a queue backed by Redis.
func NewRedisQueue(client *redis.Client, logger *logging.Logger) *RedisQueue {
return &RedisQueue{
client: client,
logger: logger.WithComponent("redis-queue"),
}
}
// Enqueue adds a job to the queue with default options.
func (q *RedisQueue) Enqueue(ctx context.Context, jobType string, payload map[string]any) (string, error) {
return q.EnqueueWithOptions(ctx, Job{
Type: jobType,
Payload: payload,
Priority: 0,
MaxRetries: 3,
})
}
// EnqueueWithOptions adds a job with custom configuration.
func (q *RedisQueue) EnqueueWithOptions(ctx context.Context, job Job) (string, error) {
if job.Type == "" {
return "", fmt.Errorf("job type is required")
}
job.ID = uuid.New().String()
job.Status = StatusPending
job.CreatedAt = time.Now().UTC()
if job.MaxRetries == 0 {
job.MaxRetries = 3
}
if job.MaxRetries > 100 {
job.MaxRetries = 100
}
if job.Payload == nil {
job.Payload = make(map[string]any)
}
// Serialize job to JSON
data, err := json.Marshal(job)
if err != nil {
return "", fmt.Errorf("marshal job: %w", err)
}
// Use a pipeline for atomic operations
pipe := q.client.Pipeline()
// Store job data
jobKey := RedisJobPrefix + job.ID
pipe.Set(ctx, jobKey, data, 0)
// Add to sorted set with score = -priority (higher priority = lower score = first out)
// Secondary sort by timestamp for FIFO within same priority
score := float64(-job.Priority) + float64(job.CreatedAt.UnixNano())/1e18
pipe.ZAdd(ctx, RedisQueueKey, redis.Z{
Score: score,
Member: job.ID,
})
_, err = pipe.Exec(ctx)
if err != nil {
return "", fmt.Errorf("enqueue job: %w", err)
}
q.logger.Debug("job enqueued", "job_id", job.ID, "type", job.Type, "priority", job.Priority)
return job.ID, nil
}
// Dequeue atomically claims the next pending job.
func (q *RedisQueue) Dequeue(ctx context.Context, workerID string) (*Job, error) {
// Pop the highest priority job (lowest score) atomically
result, err := q.client.ZPopMin(ctx, RedisQueueKey, 1).Result()
if err != nil {
return nil, fmt.Errorf("dequeue job: %w", err)
}
if len(result) == 0 {
return nil, ErrNoJob
}
jobID := result[0].Member.(string)
jobKey := RedisJobPrefix + jobID
// Get job data
data, err := q.client.Get(ctx, jobKey).Bytes()
if err != nil {
if errors.Is(err, redis.Nil) {
return nil, ErrJobNotFound
}
return nil, fmt.Errorf("get job data: %w", err)
}
var job Job
if err := json.Unmarshal(data, &job); err != nil {
return nil, fmt.Errorf("unmarshal job: %w", err)
}
// Update job status
now := time.Now().UTC()
job.Status = StatusRunning
job.StartedAt = &now
job.WorkerID = workerID
// Save updated job and add to running set
updatedData, err := json.Marshal(job)
if err != nil {
return nil, fmt.Errorf("marshal updated job: %w", err)
}
pipe := q.client.Pipeline()
pipe.Set(ctx, jobKey, updatedData, 0)
pipe.SAdd(ctx, RedisRunningKey, jobID)
_, err = pipe.Exec(ctx)
if err != nil {
return nil, fmt.Errorf("update job status: %w", err)
}
q.logger.Debug("job dequeued", "job_id", job.ID, "type", job.Type, "worker_id", workerID)
return &job, nil
}
// Ack marks a job as successfully completed.
func (q *RedisQueue) Ack(ctx context.Context, jobID string) error {
jobKey := RedisJobPrefix + jobID
data, err := q.client.Get(ctx, jobKey).Bytes()
if err != nil {
if errors.Is(err, redis.Nil) {
return ErrJobNotFound
}
return fmt.Errorf("get job: %w", err)
}
var job Job
if err := json.Unmarshal(data, &job); err != nil {
return fmt.Errorf("unmarshal job: %w", err)
}
now := time.Now().UTC()
job.Status = StatusCompleted
job.CompletedAt = &now
updatedData, err := json.Marshal(job)
if err != nil {
return fmt.Errorf("marshal job: %w", err)
}
pipe := q.client.Pipeline()
pipe.Set(ctx, jobKey, updatedData, 24*time.Hour) // Keep completed jobs for 24h
pipe.SRem(ctx, RedisRunningKey, jobID)
_, err = pipe.Exec(ctx)
if err != nil {
return fmt.Errorf("ack job: %w", err)
}
q.logger.Debug("job completed", "job_id", jobID)
return nil
}
// Fail marks a job as failed, requeuing if retries remain.
func (q *RedisQueue) Fail(ctx context.Context, jobID string, errMsg string) error {
jobKey := RedisJobPrefix + jobID
data, err := q.client.Get(ctx, jobKey).Bytes()
if err != nil {
if errors.Is(err, redis.Nil) {
return ErrJobNotFound
}
return fmt.Errorf("get job: %w", err)
}
var job Job
if err := json.Unmarshal(data, &job); err != nil {
return fmt.Errorf("unmarshal job: %w", err)
}
job.RetryCount++
job.Error = errMsg
pipe := q.client.Pipeline()
pipe.SRem(ctx, RedisRunningKey, jobID)
if job.RetryCount >= job.MaxRetries {
// Exhausted retries - mark as failed
now := time.Now().UTC()
job.Status = StatusFailed
job.CompletedAt = &now
updatedData, err := json.Marshal(job)
if err != nil {
return fmt.Errorf("marshal job: %w", err)
}
pipe.Set(ctx, jobKey, updatedData, 24*time.Hour) // Keep failed jobs for 24h
} else {
// Requeue for retry
job.Status = StatusPending
job.StartedAt = nil
job.WorkerID = ""
updatedData, err := json.Marshal(job)
if err != nil {
return fmt.Errorf("marshal job: %w", err)
}
pipe.Set(ctx, jobKey, updatedData, 0)
// Re-add to queue with original priority
score := float64(-job.Priority) + float64(job.CreatedAt.UnixNano())/1e18
pipe.ZAdd(ctx, RedisQueueKey, redis.Z{
Score: score,
Member: jobID,
})
}
_, err = pipe.Exec(ctx)
if err != nil {
return fmt.Errorf("fail job: %w", err)
}
logErrMsg := errMsg
if len(logErrMsg) > 500 {
logErrMsg = logErrMsg[:497] + "..."
}
q.logger.Debug("job failed", "job_id", jobID, "retry_count", job.RetryCount, "max_retries", job.MaxRetries, "error", logErrMsg)
return nil
}
// Heartbeat extends the job's visibility timeout (no-op for Redis implementation).
func (q *RedisQueue) Heartbeat(ctx context.Context, jobID string) error {
// For Redis, we track running jobs in a set but don't have visibility timeout.
// This could be extended to use Redis EXPIRE on job keys if needed.
return nil
}
// GetJob retrieves a job by ID (for inspection/debugging).
func (q *RedisQueue) GetJob(ctx context.Context, jobID string) (*Job, error) {
jobKey := RedisJobPrefix + jobID
data, err := q.client.Get(ctx, jobKey).Bytes()
if err != nil {
if errors.Is(err, redis.Nil) {
return nil, ErrJobNotFound
}
return nil, fmt.Errorf("get job: %w", err)
}
var job Job
if err := json.Unmarshal(data, &job); err != nil {
return nil, fmt.Errorf("unmarshal job: %w", err)
}
return &job, nil
}
// QueueLength returns the number of pending jobs.
func (q *RedisQueue) QueueLength(ctx context.Context) (int64, error) {
return q.client.ZCard(ctx, RedisQueueKey).Result()
}
// RequeueStale requeues jobs that have been running too long.
func (q *RedisQueue) RequeueStale(ctx context.Context, timeout time.Duration) (int64, error) {
// Get all running job IDs
runningIDs, err := q.client.SMembers(ctx, RedisRunningKey).Result()
if err != nil {
return 0, fmt.Errorf("get running jobs: %w", err)
}
cutoff := time.Now().UTC().Add(-timeout)
var requeued int64
for _, jobID := range runningIDs {
job, err := q.GetJob(ctx, jobID)
if err != nil {
continue // Job may have been deleted
}
if job.StartedAt != nil && job.StartedAt.Before(cutoff) {
// Requeue stale job
job.Status = StatusPending
job.StartedAt = nil
job.WorkerID = ""
data, err := json.Marshal(job)
if err != nil {
continue
}
pipe := q.client.Pipeline()
pipe.Set(ctx, RedisJobPrefix+jobID, data, 0)
pipe.SRem(ctx, RedisRunningKey, jobID)
score := float64(-job.Priority) + float64(job.CreatedAt.UnixNano())/1e18
pipe.ZAdd(ctx, RedisQueueKey, redis.Z{
Score: score,
Member: jobID,
})
_, err = pipe.Exec(ctx)
if err == nil {
requeued++
}
}
}
if requeued > 0 {
q.logger.Info("requeued stale jobs", "count", requeued, "timeout", timeout)
}
return requeued, nil
}

View File

@ -144,6 +144,19 @@ func (c *Client) DoJSON(ctx context.Context, method, path string, body any) (*ht
return c.httpClient.Do(req) return c.httpClient.Do(req)
} }
// DoRequest performs an HTTP request with custom headers.
// This is useful when you need to forward headers like Authorization.
func (c *Client) DoRequest(req *http.Request) (*http.Response, error) {
return c.httpClient.Do(req)
}
// NewRequest creates a new HTTP request for this service.
// The path is appended to the service's base URL.
func (c *Client) NewRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) {
url := c.baseURL + path
return http.NewRequestWithContext(ctx, method, url, body)
}
// ServiceName returns the name of the service this client connects to. // ServiceName returns the name of the service this client connects to.
func (c *Client) ServiceName() string { func (c *Client) ServiceName() string {
return c.serviceName return c.serviceName

View File

@ -0,0 +1,80 @@
package handlers
import (
"net/http"
"strings"
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/auth"
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/httperror"
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/httpresponse"
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/logging"
)
// Validate handles token validation requests from sibling services.
type Validate struct {
validator *auth.JWTValidator
logger *logging.Logger
}
// NewValidate creates a new Validate handler.
func NewValidate(validator *auth.JWTValidator, logger *logging.Logger) *Validate {
return &Validate{
validator: validator,
logger: logger.WithComponent("ValidateHandler"),
}
}
// ValidateRequest is the request body for token validation.
type ValidateRequest struct {
Token string `json:"token" validate:"required"`
}
// ValidateResponse is the response for token validation.
type ValidateResponse struct {
Valid bool `json:"valid"`
User *auth.User `json:"user,omitempty"`
Error string `json:"error,omitempty"`
}
// Check validates a JWT token and returns the user information.
func (h *Validate) Check(w http.ResponseWriter, r *http.Request) error {
// Extract token from Authorization header or request body
token := extractToken(r)
if token == "" {
return httperror.BadRequest("token is required")
}
// Validate the token
user, err := h.validator.Validate(r.Context(), token)
if err != nil {
h.logger.Debug("token validation failed", "error", err)
httpresponse.OK(w, r, ValidateResponse{
Valid: false,
Error: err.Error(),
})
return nil
}
httpresponse.OK(w, r, ValidateResponse{
Valid: true,
User: user,
})
return nil
}
// extractToken extracts the JWT token from the request.
// Checks Authorization header first, then falls back to query parameter.
func extractToken(r *http.Request) string {
// Check Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader != "" {
// Handle "Bearer <token>" format
if strings.HasPrefix(authHeader, "Bearer ") {
return strings.TrimPrefix(authHeader, "Bearer ")
}
return authHeader
}
// Check query parameter
return r.URL.Query().Get("token")
}

View File

@ -0,0 +1,127 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/auth"
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/logging"
)
func TestValidate_Check(t *testing.T) {
secret := []byte("test-secret")
logger := logging.New(logging.Config{Level: logging.LevelDebug})
validator := auth.NewJWTValidator(auth.JWTConfig{
Secret: secret,
Issuer: "sp4-verify-1770325799",
})
handler := NewValidate(validator, logger)
// Generate a valid token
user := &auth.User{
ID: "user-123",
Email: "test@example.com",
Roles: []string{"admin"},
Scopes: []string{"read", "write"},
}
validToken, err := auth.GenerateTokenWithIssuer(secret, user, time.Hour, "sp4-verify-1770325799", "")
if err != nil {
t.Fatalf("failed to generate token: %v", err)
}
tests := []struct {
name string
authHeader string
queryToken string
wantValid bool
wantUserID string
wantStatusCode int
}{
{
name: "valid token in Authorization header",
authHeader: "Bearer " + validToken,
wantValid: true,
wantUserID: "user-123",
wantStatusCode: http.StatusOK,
},
{
name: "valid token without Bearer prefix",
authHeader: validToken,
wantValid: true,
wantUserID: "user-123",
wantStatusCode: http.StatusOK,
},
{
name: "valid token in query parameter",
queryToken: validToken,
wantValid: true,
wantUserID: "user-123",
wantStatusCode: http.StatusOK,
},
{
name: "invalid token",
authHeader: "Bearer invalid-token",
wantValid: false,
wantStatusCode: http.StatusOK,
},
{
name: "missing token",
wantValid: false,
wantStatusCode: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
url := "/api/auth-svc/validate"
if tt.queryToken != "" {
url += "?token=" + tt.queryToken
}
req := httptest.NewRequest(http.MethodGet, url, nil)
if tt.authHeader != "" {
req.Header.Set("Authorization", tt.authHeader)
}
rr := httptest.NewRecorder()
err := handler.Check(rr, req)
// Check if error was returned (for bad request cases)
if tt.wantStatusCode == http.StatusBadRequest {
if err == nil {
t.Error("expected error for missing token")
}
return
}
if err != nil {
t.Fatalf("handler returned error: %v", err)
}
if rr.Code != tt.wantStatusCode {
t.Errorf("status code = %d, want %d", rr.Code, tt.wantStatusCode)
}
var resp struct {
Data ValidateResponse `json:"data"`
}
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Data.Valid != tt.wantValid {
t.Errorf("valid = %v, want %v", resp.Data.Valid, tt.wantValid)
}
if tt.wantValid && resp.Data.User != nil {
if resp.Data.User.ID != tt.wantUserID {
t.Errorf("user ID = %s, want %s", resp.Data.User.ID, tt.wantUserID)
}
}
})
}
}

View File

@ -14,13 +14,21 @@ import (
// This allows the monorepo to expose multiple services under a single domain: // This allows the monorepo to expose multiple services under a single domain:
// - https://domain/api/auth-svc/health // - https://domain/api/auth-svc/health
// - https://domain/api/auth-svc/examples // - https://domain/api/auth-svc/examples
// - https://domain/api/auth-svc/validate
func RegisterRoutes(application *app.App, exampleService *service.ExampleService) { func RegisterRoutes(application *app.App, exampleService *service.ExampleService) {
logger := application.Logger() logger := application.Logger()
cfg := config.Load() cfg := config.Load()
// Initialize JWT validator for token validation endpoint
jwtValidator := auth.NewJWTValidator(auth.JWTConfig{
Secret: []byte(cfg.JWTSecret),
Issuer: "sp4-verify-1770325799",
})
// Initialize handlers with injected services // Initialize handlers with injected services
healthHandler := handlers.NewHealth(logger) healthHandler := handlers.NewHealth(logger)
exampleHandler := handlers.NewExample(exampleService, logger) exampleHandler := handlers.NewExample(exampleService, logger)
validateHandler := handlers.NewValidate(jwtValidator, logger)
// Build and mount OpenAPI spec // Build and mount OpenAPI spec
spec := NewServiceSpec() spec := NewServiceSpec()
@ -31,6 +39,10 @@ func RegisterRoutes(application *app.App, exampleService *service.ExampleService
application.Route("/api/auth-svc", func(r app.Router) { application.Route("/api/auth-svc", func(r app.Router) {
r.Get("/health", healthHandler.Check) r.Get("/health", healthHandler.Check)
// Token validation endpoint (for sibling services)
r.Post("/validate", app.Wrap(validateHandler.Check))
r.Get("/validate", app.Wrap(validateHandler.Check))
// Public routes (no auth required) // Public routes (no auth required)
r.Get("/examples", app.Wrap(exampleHandler.List)) r.Get("/examples", app.Wrap(exampleHandler.List))
r.Get("/examples/{id}", app.Wrap(exampleHandler.Get)) r.Get("/examples/{id}", app.Wrap(exampleHandler.Get))

View File

@ -8,6 +8,7 @@ func NewServiceSpec() *openapi.OpenAPISpec {
WithDescription("REST API for the auth-svc service"). WithDescription("REST API for the auth-svc service").
WithBearerSecurity("bearer", "JWT authentication token"). WithBearerSecurity("bearer", "JWT authentication token").
WithTag("Health", "Service health endpoints"). WithTag("Health", "Service health endpoints").
WithTag("Auth", "Authentication and token validation").
WithTag("Examples", "Example CRUD endpoints") WithTag("Examples", "Example CRUD endpoints")
// Define reusable schemas // Define reusable schemas
@ -29,6 +30,18 @@ func NewServiceSpec() *openapi.OpenAPISpec {
"description": openapi.StringWithMinMax(0, 500).WithDescription("Updated description"), "description": openapi.StringWithMinMax(0, 500).WithDescription("Updated description"),
})) }))
// Validate response schema
spec.WithSchema("ValidateResponse", openapi.Object(map[string]openapi.Schema{
"valid": openapi.Boolean().WithDescription("Whether the token is valid"),
"user": openapi.Object(map[string]openapi.Schema{
"id": openapi.String().WithDescription("User ID"),
"email": openapi.String().WithDescription("User email"),
"roles": openapi.Array(openapi.String()).WithDescription("User roles"),
"scopes": openapi.Array(openapi.String()).WithDescription("User scopes"),
}).WithDescription("User information (only present if valid)"),
"error": openapi.String().WithDescription("Error message (only present if invalid)"),
}, "valid"))
// Health // Health
spec.AddPath("/api/auth-svc/health", "get", map[string]any{ spec.AddPath("/api/auth-svc/health", "get", map[string]any{
"summary": "Health check", "summary": "Health check",
@ -41,6 +54,36 @@ func NewServiceSpec() *openapi.OpenAPISpec {
}, },
}) })
// Validate token
spec.AddPath("/api/auth-svc/validate", "post", map[string]any{
"summary": "Validate token",
"description": "Validates a JWT token and returns user information. Used by sibling services for authentication.",
"tags": []string{"Auth"},
"security": []map[string][]string{{"bearer": {}}},
"responses": map[string]any{
"200": openapi.OpResponse("Validation result", openapi.ResponseSchema(openapi.Ref("ValidateResponse"))),
"400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()),
},
})
spec.AddPath("/api/auth-svc/validate", "get", map[string]any{
"summary": "Validate token (GET)",
"description": "Validates a JWT token. Accepts token via Authorization header or query parameter.",
"tags": []string{"Auth"},
"parameters": []any{
map[string]any{
"name": "token",
"in": "query",
"description": "JWT token to validate (alternative to Authorization header)",
"schema": openapi.String(),
},
},
"responses": map[string]any{
"200": openapi.OpResponse("Validation result", openapi.ResponseSchema(openapi.Ref("ValidateResponse"))),
"400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()),
},
})
// List examples // List examples
spec.AddPath("/api/auth-svc/examples", "get", map[string]any{ spec.AddPath("/api/auth-svc/examples", "get", map[string]any{
"summary": "List examples", "summary": "List examples",

View File

@ -0,0 +1,86 @@
// Package client provides clients for communicating with sibling services.
package client
import (
"context"
"fmt"
"net/http"
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/auth"
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/svc"
)
// AuthClient communicates with the auth-svc for token validation.
type AuthClient struct {
client *svc.Client
}
// ValidateResponse is the response from the auth-svc /validate endpoint.
type ValidateResponse struct {
Data struct {
Valid bool `json:"valid"`
User *auth.User `json:"user,omitempty"`
Error string `json:"error,omitempty"`
} `json:"data"`
}
// NewAuthClient creates a new client for the auth-svc.
// Returns an error if AUTH_SVC_URL is not configured.
func NewAuthClient() (*AuthClient, error) {
client, err := svc.NewClient("auth-svc")
if err != nil {
return nil, err
}
return &AuthClient{client: client}, nil
}
// ValidateToken validates a JWT token by calling auth-svc.
// Returns the user if valid, or an error if invalid or communication fails.
func (c *AuthClient) ValidateToken(ctx context.Context, token string) (*auth.User, error) {
req, err := c.client.NewRequest(ctx, http.MethodGet, "/api/auth-svc/validate", nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := c.client.DoRequest(req)
if err != nil {
return nil, fmt.Errorf("call auth-svc: %w", err)
}
result, err := svc.DecodeResponse[ValidateResponse](resp)
if err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
if !result.Data.Valid {
return nil, fmt.Errorf("token validation failed: %s", result.Data.Error)
}
return result.Data.User, nil
}
// ValidateTokenWithHeader validates a token by forwarding the Authorization header.
func (c *AuthClient) ValidateTokenWithHeader(ctx context.Context, authHeader string) (*auth.User, error) {
req, err := c.client.NewRequest(ctx, http.MethodGet, "/api/auth-svc/validate", nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", authHeader)
resp, err := c.client.DoRequest(req)
if err != nil {
return nil, fmt.Errorf("call auth-svc: %w", err)
}
result, err := svc.DecodeResponse[ValidateResponse](resp)
if err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
if !result.Data.Valid {
return nil, fmt.Errorf("token validation failed: %s", result.Data.Error)
}
return result.Data.User, nil
}

View File

@ -0,0 +1,123 @@
package client
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/auth"
)
func TestAuthClient_ValidateToken(t *testing.T) {
tests := []struct {
name string
response ValidateResponse
statusCode int
wantErr bool
wantUserID string
}{
{
name: "valid token",
response: ValidateResponse{
Data: struct {
Valid bool `json:"valid"`
User *auth.User `json:"user,omitempty"`
Error string `json:"error,omitempty"`
}{
Valid: true,
User: &auth.User{
ID: "user-123",
Email: "test@example.com",
},
},
},
statusCode: http.StatusOK,
wantErr: false,
wantUserID: "user-123",
},
{
name: "invalid token",
response: ValidateResponse{
Data: struct {
Valid bool `json:"valid"`
User *auth.User `json:"user,omitempty"`
Error string `json:"error,omitempty"`
}{
Valid: false,
Error: "token expired",
},
},
statusCode: http.StatusOK,
wantErr: true,
},
{
name: "server error",
statusCode: http.StatusInternalServerError,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create test server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify path
if r.URL.Path != "/api/auth-svc/validate" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
// Verify auth header is present
if r.Header.Get("Authorization") == "" {
t.Error("missing Authorization header")
}
w.WriteHeader(tt.statusCode)
if tt.statusCode == http.StatusOK {
_ = json.NewEncoder(w).Encode(tt.response)
}
}))
defer server.Close()
// Set the env var for service discovery
os.Setenv("AUTH_SVC_URL", server.URL)
defer os.Unsetenv("AUTH_SVC_URL")
// Create client
client, err := NewAuthClient()
if err != nil {
t.Fatalf("failed to create client: %v", err)
}
// Call validate
user, err := client.ValidateToken(context.Background(), "test-token")
if tt.wantErr {
if err == nil {
t.Error("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if user.ID != tt.wantUserID {
t.Errorf("user ID = %s, want %s", user.ID, tt.wantUserID)
}
})
}
}
func TestNewAuthClient_MissingURL(t *testing.T) {
// Ensure env var is not set
os.Unsetenv("AUTH_SVC_URL")
_, err := NewAuthClient()
if err == nil {
t.Error("expected error when AUTH_SVC_URL is not set")
}
}

View File

@ -0,0 +1,68 @@
package client
import (
"context"
"fmt"
"os"
"github.com/redis/go-redis/v9"
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/logging"
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/queue"
)
// QueueClient provides access to the Redis job queue for pushing tasks.
type QueueClient struct {
producer *queue.RedisQueue
redis *redis.Client
}
// NewQueueClient creates a new Redis queue client.
// Uses REDIS_URL environment variable for connection.
func NewQueueClient(logger *logging.Logger) (*QueueClient, error) {
redisURL := os.Getenv("REDIS_URL")
if redisURL == "" {
return nil, fmt.Errorf("REDIS_URL environment variable not set")
}
opts, err := redis.ParseURL(redisURL)
if err != nil {
return nil, fmt.Errorf("invalid REDIS_URL: %w", err)
}
client := redis.NewClient(opts)
// Test connection
if err := client.Ping(context.Background()).Err(); err != nil {
return nil, fmt.Errorf("redis connection failed: %w", err)
}
return &QueueClient{
producer: queue.NewRedisQueue(client, logger),
redis: client,
}, nil
}
// PushTask enqueues a task for the worker to process.
func (c *QueueClient) PushTask(ctx context.Context, taskType string, payload map[string]any) (string, error) {
return c.producer.Enqueue(ctx, taskType, payload)
}
// PushTaskWithPriority enqueues a task with a specific priority (higher = more urgent).
func (c *QueueClient) PushTaskWithPriority(ctx context.Context, taskType string, payload map[string]any, priority int) (string, error) {
return c.producer.EnqueueWithOptions(ctx, queue.Job{
Type: taskType,
Payload: payload,
Priority: priority,
})
}
// Close closes the Redis connection.
func (c *QueueClient) Close() error {
return c.redis.Close()
}
// HealthCheck verifies the Redis connection.
func (c *QueueClient) HealthCheck(ctx context.Context) error {
return c.redis.Ping(ctx).Err()
}

View File

@ -0,0 +1,134 @@
package client
import (
"context"
"os"
"testing"
"github.com/redis/go-redis/v9"
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/logging"
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/queue"
)
func TestQueueClient_PushTask(t *testing.T) {
// Skip if REDIS_URL not set (integration test)
redisURL := os.Getenv("REDIS_URL")
if redisURL == "" {
t.Skip("REDIS_URL not set, skipping integration test")
}
logger := logging.New(logging.Config{Level: logging.LevelDebug})
client, err := NewQueueClient(logger)
if err != nil {
t.Fatalf("failed to create queue client: %v", err)
}
defer client.Close()
// Push a task
jobID, err := client.PushTask(context.Background(), "test_task", map[string]any{
"message": "hello",
"count": 42,
})
if err != nil {
t.Fatalf("failed to push task: %v", err)
}
if jobID == "" {
t.Error("expected non-empty job ID")
}
t.Logf("pushed task with ID: %s", jobID)
}
func TestQueueClient_PushTaskWithPriority(t *testing.T) {
redisURL := os.Getenv("REDIS_URL")
if redisURL == "" {
t.Skip("REDIS_URL not set, skipping integration test")
}
logger := logging.New(logging.Config{Level: logging.LevelDebug})
client, err := NewQueueClient(logger)
if err != nil {
t.Fatalf("failed to create queue client: %v", err)
}
defer client.Close()
// Push tasks with different priorities
_, err = client.PushTaskWithPriority(context.Background(), "low_priority", map[string]any{"level": "low"}, 0)
if err != nil {
t.Fatalf("failed to push low priority task: %v", err)
}
_, err = client.PushTaskWithPriority(context.Background(), "high_priority", map[string]any{"level": "high"}, 10)
if err != nil {
t.Fatalf("failed to push high priority task: %v", err)
}
}
func TestNewQueueClient_MissingURL(t *testing.T) {
os.Unsetenv("REDIS_URL")
logger := logging.New(logging.Config{Level: logging.LevelDebug})
_, err := NewQueueClient(logger)
if err == nil {
t.Error("expected error when REDIS_URL is not set")
}
}
func TestRedisQueue_Integration(t *testing.T) {
redisURL := os.Getenv("REDIS_URL")
if redisURL == "" {
t.Skip("REDIS_URL not set, skipping integration test")
}
opts, err := redis.ParseURL(redisURL)
if err != nil {
t.Fatalf("failed to parse REDIS_URL: %v", err)
}
client := redis.NewClient(opts)
defer client.Close()
logger := logging.New(logging.Config{Level: logging.LevelDebug})
q := queue.NewRedisQueue(client, logger)
ctx := context.Background()
// Test enqueue
jobID, err := q.Enqueue(ctx, "test_job", map[string]any{"key": "value"})
if err != nil {
t.Fatalf("failed to enqueue: %v", err)
}
// Test dequeue
job, err := q.Dequeue(ctx, "test-worker")
if err != nil {
t.Fatalf("failed to dequeue: %v", err)
}
if job.ID != jobID {
t.Errorf("job ID = %s, want %s", job.ID, jobID)
}
if job.Type != "test_job" {
t.Errorf("job type = %s, want test_job", job.Type)
}
// Test ack
if err := q.Ack(ctx, jobID); err != nil {
t.Fatalf("failed to ack: %v", err)
}
// Verify job is completed
completedJob, err := q.GetJob(ctx, jobID)
if err != nil {
t.Fatalf("failed to get job: %v", err)
}
if completedJob.Status != queue.StatusCompleted {
t.Errorf("job status = %s, want %s", completedJob.Status, queue.StatusCompleted)
}
}

View File

@ -9,6 +9,8 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/redis/go-redis/v9"
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/database" "git.threesix.ai/jordan/sp4-verify-1770325799/pkg/database"
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/logging" "git.threesix.ai/jordan/sp4-verify-1770325799/pkg/logging"
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/queue" "git.threesix.ai/jordan/sp4-verify-1770325799/pkg/queue"
@ -47,37 +49,64 @@ func main() {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
// Connect to database // Determine queue backend based on configuration
pool, err := database.Connect(ctx, cfg.Database.URL, database.Options{ var jobQueue queue.Queue
MaxOpenConns: cfg.Database.MaxOpenConns, var redisClient *redis.Client
MaxIdleConns: cfg.Database.MaxIdleConns,
ConnMaxLifetime: cfg.Database.ConnMaxLifetime, if cfg.Redis.URL != "" {
}) // Use Redis queue
if err != nil { opts, err := redis.ParseURL(cfg.Redis.URL)
logger.Error("failed to connect to database", "error", err) if err != nil {
os.Exit(1) logger.Error("failed to parse REDIS_URL", "error", err)
os.Exit(1)
}
redisClient = redis.NewClient(opts)
if err := redisClient.Ping(ctx).Err(); err != nil {
logger.Error("failed to connect to Redis", "error", err)
os.Exit(1)
}
defer redisClient.Close()
jobQueue = queue.NewRedisQueue(redisClient, logger)
logger.Info("using Redis queue", "url", cfg.Redis.URL)
} else {
// Fall back to PostgreSQL queue
pool, err := database.Connect(ctx, cfg.Database.URL, database.Options{
MaxOpenConns: cfg.Database.MaxOpenConns,
MaxIdleConns: cfg.Database.MaxIdleConns,
ConnMaxLifetime: cfg.Database.ConnMaxLifetime,
})
if err != nil {
logger.Error("failed to connect to database", "error", err)
os.Exit(1)
}
defer pool.Close()
logger.Info("connected to database", "url", pool.URL)
// Run migrations
database.MustRunMigrations(ctx, pool, migrationsFS, "migrations")
logger.Info("migrations complete")
jobQueue = queue.NewPostgresQueue(pool.DB, logger)
logger.Info("using PostgreSQL queue")
} }
defer pool.Close()
logger.Info("connected to database", "url", pool.URL)
// Run migrations
database.MustRunMigrations(ctx, pool, migrationsFS, "migrations")
logger.Info("migrations complete")
// Initialize queue
jobQueue := queue.NewPostgresQueue(pool.DB, logger)
// Initialize and start handler // Initialize and start handler
handler := handlers.New(logger, jobQueue, handlers.Config{ handler := handlers.New(logger, jobQueue, handlers.Config{
PollInterval: cfg.Worker.PollInterval, PollInterval: cfg.Worker.PollInterval,
StaleJobTimeout: cfg.Worker.StaleJobTimeout, StaleJobTimeout: cfg.Worker.StaleJobTimeout,
JobTimeout: cfg.Worker.JobTimeout, JobTimeout: cfg.Worker.JobTimeout,
}) })
// Register job handlers // Initialize task handlers
// TODO: Register your job handlers here taskHandlers := handlers.NewTaskHandlers(logger)
// handler.RegisterHandler("send_email", emailHandler)
// handler.RegisterHandler("process_image", imageHandler) // Register job handlers for tasks pushed by chat-svc and other services
handler.RegisterHandler("process_chat_message", taskHandlers.ProcessChatMessage)
handler.RegisterHandler("send_notification", taskHandlers.SendNotification)
handler.RegisterHandler("sync_data", taskHandlers.SyncData)
handler.RegisterHandler("process_webhook", taskHandlers.ProcessWebhook)
// Setup signal handling // Setup signal handling
sigCh := make(chan os.Signal, 1) sigCh := make(chan os.Signal, 1)
@ -105,18 +134,30 @@ func main() {
logger.Info("worker-svc worker stopped") logger.Info("worker-svc worker stopped")
} }
// StaleJobRequeuer is an interface for queues that support stale job recovery.
type StaleJobRequeuer interface {
RequeueStale(ctx context.Context, timeout time.Duration) (int64, error)
}
// runStaleJobRecovery periodically requeues jobs that have been running too long. // runStaleJobRecovery periodically requeues jobs that have been running too long.
func runStaleJobRecovery(ctx context.Context, q *queue.PostgresQueue, timeout time.Duration, logger *logging.Logger) { func runStaleJobRecovery(ctx context.Context, q queue.Queue, timeout time.Duration, logger *logging.Logger) {
const staleCheckInterval = time.Minute const staleCheckInterval = time.Minute
ticker := time.NewTicker(staleCheckInterval) ticker := time.NewTicker(staleCheckInterval)
defer ticker.Stop() defer ticker.Stop()
// Check if queue supports stale job recovery
requeuer, ok := q.(StaleJobRequeuer)
if !ok {
logger.Warn("queue does not support stale job recovery")
return
}
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return return
case <-ticker.C: case <-ticker.C:
count, err := q.RequeueStale(ctx, timeout) count, err := requeuer.RequeueStale(ctx, timeout)
if err != nil { if err != nil {
logger.Error("failed to requeue stale jobs", "error", err) logger.Error("failed to requeue stale jobs", "error", err)
} else if count > 0 { } else if count > 0 {

View File

@ -13,10 +13,16 @@ import (
type Config struct { type Config struct {
config.AppConfig config.AppConfig
Database config.DatabaseConfig Database config.DatabaseConfig
Redis RedisConfig
Logging config.LoggingConfig Logging config.LoggingConfig
Worker WorkerConfig Worker WorkerConfig
} }
// RedisConfig holds Redis connection settings.
type RedisConfig struct {
URL string
}
// WorkerConfig holds worker-specific settings. // WorkerConfig holds worker-specific settings.
type WorkerConfig struct { type WorkerConfig struct {
// PollInterval is how often to check for new jobs when queue is empty. // PollInterval is how often to check for new jobs when queue is empty.
@ -54,7 +60,10 @@ func Load() (*Config, error) {
return &Config{ return &Config{
AppConfig: config.ReadAppConfig(), AppConfig: config.ReadAppConfig(),
Database: config.ReadDatabaseConfig(), Database: config.ReadDatabaseConfig(),
Logging: config.ReadLoggingConfig(), Redis: RedisConfig{
URL: viper.GetString("REDIS_URL"),
},
Logging: config.ReadLoggingConfig(),
Worker: WorkerConfig{ Worker: WorkerConfig{
PollInterval: viper.GetDuration("WORKER_POLL_INTERVAL"), PollInterval: viper.GetDuration("WORKER_POLL_INTERVAL"),
BatchSize: viper.GetInt("WORKER_BATCH_SIZE"), BatchSize: viper.GetInt("WORKER_BATCH_SIZE"),

View File

@ -0,0 +1,124 @@
package handlers
import (
"context"
"fmt"
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/logging"
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/queue"
)
// TaskHandlers provides handlers for different job types pushed from services.
type TaskHandlers struct {
logger *logging.Logger
}
// NewTaskHandlers creates task handlers for processing jobs from the queue.
func NewTaskHandlers(logger *logging.Logger) *TaskHandlers {
return &TaskHandlers{
logger: logger.WithComponent("task-handlers"),
}
}
// ProcessChatMessage handles chat message processing tasks.
func (h *TaskHandlers) ProcessChatMessage(ctx context.Context, job *queue.Job) error {
h.logger.Info("processing chat message",
"job_id", job.ID,
"payload", job.Payload,
)
// Extract payload fields
messageID, _ := job.Payload["message_id"].(string)
userID, _ := job.Payload["user_id"].(string)
content, _ := job.Payload["content"].(string)
if messageID == "" {
return fmt.Errorf("message_id is required")
}
// Simulate processing work
h.logger.Debug("chat message processed",
"message_id", messageID,
"user_id", userID,
"content_length", len(content),
)
return nil
}
// SendNotification handles notification sending tasks.
func (h *TaskHandlers) SendNotification(ctx context.Context, job *queue.Job) error {
h.logger.Info("sending notification",
"job_id", job.ID,
"payload", job.Payload,
)
userID, _ := job.Payload["user_id"].(string)
notificationType, _ := job.Payload["type"].(string)
message, _ := job.Payload["message"].(string)
if userID == "" {
return fmt.Errorf("user_id is required")
}
if notificationType == "" {
return fmt.Errorf("notification type is required")
}
// Simulate sending notification
h.logger.Debug("notification sent",
"user_id", userID,
"type", notificationType,
"message_length", len(message),
)
return nil
}
// SyncData handles data synchronization tasks.
func (h *TaskHandlers) SyncData(ctx context.Context, job *queue.Job) error {
h.logger.Info("syncing data",
"job_id", job.ID,
"payload", job.Payload,
)
source, _ := job.Payload["source"].(string)
destination, _ := job.Payload["destination"].(string)
if source == "" {
return fmt.Errorf("source is required")
}
if destination == "" {
return fmt.Errorf("destination is required")
}
// Simulate data sync
h.logger.Debug("data synced",
"source", source,
"destination", destination,
)
return nil
}
// ProcessWebhook handles incoming webhook processing.
func (h *TaskHandlers) ProcessWebhook(ctx context.Context, job *queue.Job) error {
h.logger.Info("processing webhook",
"job_id", job.ID,
"payload", job.Payload,
)
webhookID, _ := job.Payload["webhook_id"].(string)
eventType, _ := job.Payload["event_type"].(string)
if webhookID == "" {
return fmt.Errorf("webhook_id is required")
}
// Simulate webhook processing
h.logger.Debug("webhook processed",
"webhook_id", webhookID,
"event_type", eventType,
)
return nil
}

View File

@ -0,0 +1,213 @@
package handlers
import (
"context"
"testing"
"time"
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/logging"
"git.threesix.ai/jordan/sp4-verify-1770325799/pkg/queue"
)
func TestTaskHandlers_ProcessChatMessage(t *testing.T) {
logger := logging.New(logging.Config{Level: logging.LevelDebug})
h := NewTaskHandlers(logger)
tests := []struct {
name string
payload map[string]any
wantErr bool
}{
{
name: "valid message",
payload: map[string]any{
"message_id": "msg-123",
"user_id": "user-456",
"content": "Hello world",
},
wantErr: false,
},
{
name: "missing message_id",
payload: map[string]any{
"user_id": "user-456",
"content": "Hello world",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
now := time.Now()
job := &queue.Job{
ID: "job-123",
Type: "process_chat_message",
Payload: tt.payload,
Status: queue.StatusRunning,
CreatedAt: now,
StartedAt: &now,
}
err := h.ProcessChatMessage(context.Background(), job)
if (err != nil) != tt.wantErr {
t.Errorf("ProcessChatMessage() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestTaskHandlers_SendNotification(t *testing.T) {
logger := logging.New(logging.Config{Level: logging.LevelDebug})
h := NewTaskHandlers(logger)
tests := []struct {
name string
payload map[string]any
wantErr bool
}{
{
name: "valid notification",
payload: map[string]any{
"user_id": "user-456",
"type": "email",
"message": "You have a new message",
},
wantErr: false,
},
{
name: "missing user_id",
payload: map[string]any{
"type": "email",
"message": "You have a new message",
},
wantErr: true,
},
{
name: "missing type",
payload: map[string]any{
"user_id": "user-456",
"message": "You have a new message",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
now := time.Now()
job := &queue.Job{
ID: "job-123",
Type: "send_notification",
Payload: tt.payload,
Status: queue.StatusRunning,
CreatedAt: now,
StartedAt: &now,
}
err := h.SendNotification(context.Background(), job)
if (err != nil) != tt.wantErr {
t.Errorf("SendNotification() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestTaskHandlers_SyncData(t *testing.T) {
logger := logging.New(logging.Config{Level: logging.LevelDebug})
h := NewTaskHandlers(logger)
tests := []struct {
name string
payload map[string]any
wantErr bool
}{
{
name: "valid sync",
payload: map[string]any{
"source": "database-a",
"destination": "database-b",
},
wantErr: false,
},
{
name: "missing source",
payload: map[string]any{
"destination": "database-b",
},
wantErr: true,
},
{
name: "missing destination",
payload: map[string]any{
"source": "database-a",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
now := time.Now()
job := &queue.Job{
ID: "job-123",
Type: "sync_data",
Payload: tt.payload,
Status: queue.StatusRunning,
CreatedAt: now,
StartedAt: &now,
}
err := h.SyncData(context.Background(), job)
if (err != nil) != tt.wantErr {
t.Errorf("SyncData() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestTaskHandlers_ProcessWebhook(t *testing.T) {
logger := logging.New(logging.Config{Level: logging.LevelDebug})
h := NewTaskHandlers(logger)
tests := []struct {
name string
payload map[string]any
wantErr bool
}{
{
name: "valid webhook",
payload: map[string]any{
"webhook_id": "wh-123",
"event_type": "user.created",
},
wantErr: false,
},
{
name: "missing webhook_id",
payload: map[string]any{
"event_type": "user.created",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
now := time.Now()
job := &queue.Job{
ID: "job-123",
Type: "process_webhook",
Payload: tt.payload,
Status: queue.StatusRunning,
CreatedAt: now,
StartedAt: &now,
}
err := h.ProcessWebhook(context.Background(), job)
if (err != nil) != tt.wantErr {
t.Errorf("ProcessWebhook() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}