Skip to content
Merged
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
11 changes: 3 additions & 8 deletions cmd/lambda/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (
"encoding/json"
"fmt"
"log"
"os"
"sync"

"github.com/LeanerCloud/CUDly/internal/server"
Expand Down Expand Up @@ -42,16 +41,12 @@ func initApp(ctx context.Context) (*server.Application, error) {
return app, nil
}

// Set version for the application.
// Note: os.Setenv is not thread-safe, but Lambda serializes cold starts so this is safe.
// Consider passing version through a struct field for multi-threaded environments.
os.Setenv("VERSION", Version)

log.Printf("CUDly Lambda Handler starting, version: %s", Version)

// Initialize using the unified server package (PostgreSQL-based)
// Initialize using the unified server package (PostgreSQL-based).
// Pass Version directly to avoid the os.Setenv round-trip (04-N1).
var err error
app, err = server.NewApplication(ctx)
app, err = server.NewApplication(ctx, Version)
if err != nil {
return nil, fmt.Errorf("failed to initialize application: %w", err)
}
Expand Down
44 changes: 13 additions & 31 deletions cmd/lambda/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,32 +36,17 @@ func TestInitApp_Cached(t *testing.T) {
assert.Equal(t, testApp, result, "should return cached app")
}

// TestInitApp_SetsVersion verifies that the ldflags-stamped Version is passed
// directly to NewApplication (04-N1) rather than round-tripping through
// os.Setenv("VERSION",...) / os.Getenv("VERSION"). We verify indirectly:
// initApp is expected to fail because DB_HOST is unset, which means
// NewApplication(ctx, Version) was called with the correct value. A later
// successful init path (TestNewApplicationFromDeps in internal/server) confirms
// the field is stored on ApplicationConfig.Version.
func TestInitApp_SetsVersion(t *testing.T) {
origApp := app
origVersion := Version
origDBHost := os.Getenv("DB_HOST")
defer func() {
app = origApp
Version = origVersion
os.Setenv("DB_HOST", origDBHost)
}()

// Ensure app is nil so initApp tries to initialize
app = nil
Version = "test-v1.2.3"
os.Unsetenv("DB_HOST")

_, err := initApp(context.Background())
// It will fail because DB_HOST is not set, but Version should have been set
require.Error(t, err)
assert.Equal(t, "test-v1.2.3", os.Getenv("VERSION"))
}

func TestInitApp_EmptyVersion(t *testing.T) {
origApp := app
origVersion := Version
origDBHost := os.Getenv("DB_HOST")
origEnvVersion := os.Getenv("VERSION")
defer func() {
app = origApp
Version = origVersion
Expand All @@ -70,22 +55,19 @@ func TestInitApp_EmptyVersion(t *testing.T) {
} else {
os.Unsetenv("DB_HOST")
}
if origEnvVersion != "" {
os.Setenv("VERSION", origEnvVersion)
} else {
os.Unsetenv("VERSION")
}
}()

app = nil
Version = ""
Version = "test-v1.2.3"
os.Unsetenv("DB_HOST")
os.Unsetenv("VERSION")

_, err := initApp(context.Background())
// Expected to fail because DB_HOST is not set; the Version is now passed
// directly to NewApplication, not via the VERSION env var (04-N1).
require.Error(t, err)
// When Version is empty, os.Setenv("VERSION", Version) should NOT be called
// so VERSION env should remain unset or whatever it was before
// VERSION env var is intentionally no longer set by initApp.
assert.NotEqual(t, "test-v1.2.3", os.Getenv("VERSION"),
"VERSION env var must not be set by initApp after 04-N1 refactor")
}

func TestInitApp_FailsWithoutDB(t *testing.T) {
Expand Down
35 changes: 24 additions & 11 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strconv"
"time"

"github.com/LeanerCloud/CUDly/internal/runtime"
"github.com/LeanerCloud/CUDly/internal/server"
)

Expand All @@ -30,16 +31,17 @@ func main() {
// Print version info
log.Printf("CUDly Server v%s (git: %s, built: %s)", Version, GitSHA, BuildTime)

// Export build metadata to the environment so the api package can read it
// without importing main (which would create an import cycle).
os.Setenv("VERSION", Version)
// Export BUILD_TIME and GIT_SHA to the environment so the api package can
// read them without importing main (import cycle). VERSION is passed
// directly to NewApplication to avoid the env round-trip (04-N1).
os.Setenv("BUILD_TIME", BuildTime)
os.Setenv("GIT_SHA", GitSHA)

ctx := context.Background()

// Initialize application
app, err := server.NewApplication(ctx)
// Initialize application; pass Version directly so it is stamped on
// ApplicationConfig without going through os.Setenv("VERSION",...).
app, err := server.NewApplication(ctx, Version)
if err != nil {
log.Fatalf("Failed to initialize application: %v", err)
}
Expand Down Expand Up @@ -79,13 +81,23 @@ func main() {
}

// getTaskTimeout returns the task timeout from TASK_TIMEOUT env var or the default of 15 minutes.
// Logs a warning when TASK_TIMEOUT is set but cannot be parsed or is non-positive,
// so the operator knows the value was not applied.
func getTaskTimeout() time.Duration {
const defaultTimeout = 15 * time.Minute
if v := os.Getenv("TASK_TIMEOUT"); v != "" {
if secs, err := strconv.Atoi(v); err == nil && secs > 0 {
return time.Duration(secs) * time.Second
secs, err := strconv.Atoi(v)
if err != nil {
log.Printf("WARNING: TASK_TIMEOUT=%q is not a valid integer; using default %v", v, defaultTimeout)
return defaultTimeout
}
if secs <= 0 {
log.Printf("WARNING: TASK_TIMEOUT=%q must be a positive number; using default %v", v, defaultTimeout)
return defaultTimeout
}
return time.Duration(secs) * time.Second
}
return 15 * time.Minute
return defaultTimeout
}

// determineRuntimeMode determines the runtime mode based on flags and environment
Expand All @@ -95,9 +107,10 @@ func determineRuntimeMode(modeFlag string) string {
return modeFlag
}

// Auto-detect based on environment
// Lambda sets AWS_LAMBDA_RUNTIME_API when running
if os.Getenv("AWS_LAMBDA_RUNTIME_API") != "" {
// Auto-detect based on environment using the canonical runtime helper,
// which encapsulates the detection rule so future changes stay consistent
// across all call sites (issue 04-M5).
if runtime.IsLambda() {
return "lambda"
}

Expand Down
32 changes: 32 additions & 0 deletions internal/config/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -488,3 +488,35 @@ func isValidRampScheduleType(t string) bool {
}
return false
}

// ValidatePaymentOptionEnv validates a payment-option value read from an
// environment variable (e.g. DEFAULT_PAYMENT_OPTION). Empty string is
// always valid ("use the purchase manager's built-in default"). Non-empty
// values must be in the union of all provider payment option sets.
// Called by the server startup boundary so misconfiguration is caught at
// boot time rather than silently propagated into purchases (issue #1026).
func ValidatePaymentOptionEnv(val string) error {
if val == "" {
return nil
}
if !isValidPaymentOption(val) {
return fmt.Errorf("value %q is not a recognised payment option (valid: %s)", val, strings.Join(validPaymentOptionsUnion, ", "))
}
return nil
}

// ValidateRampScheduleEnv validates a ramp-schedule value read from an
// environment variable (e.g. DEFAULT_RAMP_SCHEDULE). Empty string is
// always valid ("use the purchase manager's built-in default"). Non-empty
// values must be in ValidRampScheduleTypes.
// Called by the server startup boundary so misconfiguration is caught at
// boot time rather than silently propagated into purchases (issue #1026).
func ValidateRampScheduleEnv(val string) error {
if val == "" {
return nil
}
if !isValidRampScheduleType(val) {
return fmt.Errorf("value %q is not a recognised ramp schedule type (valid: %s)", val, strings.Join(ValidRampScheduleTypes, ", "))
}
return nil
}
Original file line number Diff line number Diff line change
Expand Up @@ -173,8 +173,14 @@ $$ LANGUAGE plpgsql;
-- Create partitions for current month + 3 months ahead
SELECT create_future_savings_partitions(3);

-- Initial refresh of materialized views (will be empty at first)
SELECT refresh_savings_materialized_views();
-- Initial (non-concurrent) refresh of materialized views.
-- REFRESH MATERIALIZED VIEW CONCURRENTLY cannot run inside a transaction
-- block and is unnecessary here because the views are empty at migration
-- time (06-M4). Reserve CONCURRENTLY for the scheduled runtime refresh
-- invoked via refresh_savings_materialized_views() outside any transaction.
REFRESH MATERIALIZED VIEW monthly_savings_summary;
REFRESH MATERIALIZED VIEW daily_savings_trend;
REFRESH MATERIALIZED VIEW provider_savings_summary;

-- Add comment explaining partition maintenance
COMMENT ON FUNCTION create_savings_snapshot_partition IS
Expand Down
Loading
Loading