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)
}
// 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.
func (c *Client) ServiceName() string {
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:
// - https://domain/api/auth-svc/health
// - https://domain/api/auth-svc/examples
// - https://domain/api/auth-svc/validate
func RegisterRoutes(application *app.App, exampleService *service.ExampleService) {
logger := application.Logger()
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
healthHandler := handlers.NewHealth(logger)
exampleHandler := handlers.NewExample(exampleService, logger)
validateHandler := handlers.NewValidate(jwtValidator, logger)
// Build and mount OpenAPI spec
spec := NewServiceSpec()
@ -31,6 +39,10 @@ func RegisterRoutes(application *app.App, exampleService *service.ExampleService
application.Route("/api/auth-svc", func(r app.Router) {
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)
r.Get("/examples", app.Wrap(exampleHandler.List))
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").
WithBearerSecurity("bearer", "JWT authentication token").
WithTag("Health", "Service health endpoints").
WithTag("Auth", "Authentication and token validation").
WithTag("Examples", "Example CRUD endpoints")
// Define reusable schemas
@ -29,6 +30,18 @@ func NewServiceSpec() *openapi.OpenAPISpec {
"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
spec.AddPath("/api/auth-svc/health", "get", map[string]any{
"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
spec.AddPath("/api/auth-svc/examples", "get", map[string]any{
"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"
"time"
"github.com/redis/go-redis/v9"
"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/queue"
@ -47,37 +49,64 @@ func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Connect to database
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)
// Determine queue backend based on configuration
var jobQueue queue.Queue
var redisClient *redis.Client
if cfg.Redis.URL != "" {
// Use Redis queue
opts, err := redis.ParseURL(cfg.Redis.URL)
if err != nil {
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
handler := handlers.New(logger, jobQueue, handlers.Config{
PollInterval: cfg.Worker.PollInterval,
StaleJobTimeout: cfg.Worker.StaleJobTimeout,
JobTimeout: cfg.Worker.JobTimeout,
PollInterval: cfg.Worker.PollInterval,
StaleJobTimeout: cfg.Worker.StaleJobTimeout,
JobTimeout: cfg.Worker.JobTimeout,
})
// Register job handlers
// TODO: Register your job handlers here
// handler.RegisterHandler("send_email", emailHandler)
// handler.RegisterHandler("process_image", imageHandler)
// Initialize task handlers
taskHandlers := handlers.NewTaskHandlers(logger)
// 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
sigCh := make(chan os.Signal, 1)
@ -105,18 +134,30 @@ func main() {
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.
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
ticker := time.NewTicker(staleCheckInterval)
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 {
select {
case <-ctx.Done():
return
case <-ticker.C:
count, err := q.RequeueStale(ctx, timeout)
count, err := requeuer.RequeueStale(ctx, timeout)
if err != nil {
logger.Error("failed to requeue stale jobs", "error", err)
} else if count > 0 {

View File

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