From 60fbc277620cbb671286c5ca737d570319c29830 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Sat, 29 Aug 2026 18:29:48 +0300 Subject: [PATCH 01/78] chore(go module): added amqp 0.9.1 client --- go.mod | 1 + go.sum | 2 ++ 2 files changed, 3 insertions(+) diff --git a/go.mod b/go.mod index 79a8576..3fec23c 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( github.com/go-openapi/swag/stringutils v0.29.1 // indirect github.com/go-openapi/swag/typeutils v0.29.1 // indirect github.com/go-openapi/swag/yamlutils v0.29.1 // indirect + github.com/rabbitmq/amqp091-go v1.14.0 // indirect github.com/sv-tools/openapi v0.4.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/mod v0.40.0 // indirect diff --git a/go.sum b/go.sum index 899ebd5..9343f13 100644 --- a/go.sum +++ b/go.sum @@ -35,6 +35,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rabbitmq/amqp091-go v1.14.0 h1:RSaT7aOKt/OrkVUyswPDW29lnRz9psuGmfZFBmLqLek= +github.com/rabbitmq/amqp091-go v1.14.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/sv-tools/openapi v0.4.0 h1:UhD9DVnGox1hfTePNclpUzUFgos57FvzT2jmcAuTOJ4= From f6406a195864e53162b599277d34bc3da09fc719 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Sun, 30 Aug 2026 02:20:20 +0300 Subject: [PATCH 02/78] refactor(repository): added a module 'storage' --- internal/repository/{ => storage}/repository.go | 2 +- internal/repository/{ => storage}/session.go | 2 +- internal/repository/{ => storage}/task.go | 2 +- internal/repository/{ => storage}/user.go | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename internal/repository/{ => storage}/repository.go (97%) rename internal/repository/{ => storage}/session.go (97%) rename internal/repository/{ => storage}/task.go (98%) rename internal/repository/{ => storage}/user.go (97%) diff --git a/internal/repository/repository.go b/internal/repository/storage/repository.go similarity index 97% rename from internal/repository/repository.go rename to internal/repository/storage/repository.go index 2e4f2b1..1bb759c 100644 --- a/internal/repository/repository.go +++ b/internal/repository/storage/repository.go @@ -1,4 +1,4 @@ -package repository +package storage import ( "sync" diff --git a/internal/repository/session.go b/internal/repository/storage/session.go similarity index 97% rename from internal/repository/session.go rename to internal/repository/storage/session.go index c895c3f..eb77d51 100644 --- a/internal/repository/session.go +++ b/internal/repository/storage/session.go @@ -1,4 +1,4 @@ -package repository +package storage import ( "github.com/belyaevedu/remote-code-service/internal/domain" diff --git a/internal/repository/task.go b/internal/repository/storage/task.go similarity index 98% rename from internal/repository/task.go rename to internal/repository/storage/task.go index bed75a4..07bfc2b 100644 --- a/internal/repository/task.go +++ b/internal/repository/storage/task.go @@ -1,4 +1,4 @@ -package repository +package storage import ( "github.com/belyaevedu/remote-code-service/internal/domain" diff --git a/internal/repository/user.go b/internal/repository/storage/user.go similarity index 97% rename from internal/repository/user.go rename to internal/repository/storage/user.go index 66cae75..1d5afdb 100644 --- a/internal/repository/user.go +++ b/internal/repository/storage/user.go @@ -1,4 +1,4 @@ -package repository +package storage import ( "github.com/belyaevedu/remote-code-service/internal/domain" From 996075aa5df3661206bd89b2df48629d437f76f5 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Sun, 30 Aug 2026 02:20:31 +0300 Subject: [PATCH 03/78] fix(cmd/server): fixed import --- cmd/server/main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 0746ffc..70ab15b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -9,7 +9,7 @@ import ( "github.com/belyaevedu/remote-code-service/internal/config" "github.com/belyaevedu/remote-code-service/internal/controller" "github.com/belyaevedu/remote-code-service/internal/controller/handlers" - "github.com/belyaevedu/remote-code-service/internal/repository" + "github.com/belyaevedu/remote-code-service/internal/repository/storage" "github.com/belyaevedu/remote-code-service/internal/service" ) @@ -34,7 +34,7 @@ func main() { log.Fatalf("invalid config: %v", err) } - repo := repository.New() + repo := storage.New() taskService := service.NewTaskService(repo, cfg.ProcessingTime) userService := service.NewUserService(repo, repo) From bbd245789f2e94256f29ef54925e7bcec096f1c9 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Sun, 30 Aug 2026 22:19:21 +0300 Subject: [PATCH 04/78] feat(port): defined the CodeExecutor interface --- internal/port/port.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/port/port.go b/internal/port/port.go index d21ce0b..f1a3375 100644 --- a/internal/port/port.go +++ b/internal/port/port.go @@ -1,6 +1,8 @@ package port import ( + "context" + "github.com/belyaevedu/remote-code-service/internal/domain" ) @@ -37,3 +39,7 @@ type SessionRepository interface { type AuthService interface { Authenticate(token string) (string, error) } + +type CodeExecutor interface { + Execute(ctx context.Context, req domain.ExecutionRequest) (domain.ExecutionResult, error) +} From eb453a8ad546c9c4b06ec1cbbdaa482c448ed6fb Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Sun, 30 Aug 2026 22:19:46 +0300 Subject: [PATCH 05/78] feat(domain/task): added structs for execution requests and results --- internal/domain/task.go | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/internal/domain/task.go b/internal/domain/task.go index 826d8f3..b8f4d66 100644 --- a/internal/domain/task.go +++ b/internal/domain/task.go @@ -5,8 +5,10 @@ import ( ) var ( - ErrTaskNotFound = errors.New("task not found") - ErrAccessDenied = errors.New("access denied") + ErrTaskNotFound = errors.New("task not found") + ErrAccessDenied = errors.New("access denied") + ErrUnsupportedTranslator = errors.New("unsupported translator") + ErrExecutionTimeout = errors.New("execution timed out") ) type TaskStatus string @@ -26,3 +28,15 @@ type Task struct { Status TaskStatus Result *Result } + +type ExecutionRequest struct { + Name string // the executor-side task name, expected to be unique + Translator string + Code string +} + +type ExecutionResult struct { + Output string + ExitCode int // -1 when unavailable + Failed bool +} From 23d39b3ba8aad84f5e7d6c587c589ceb6f575228 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Sun, 30 Aug 2026 22:42:47 +0300 Subject: [PATCH 06/78] feat/refactor(config): added 'philharmonic' config, split configs into 'app' and 'philharmonic' --- internal/config/app.go | 40 ++++++++++++++ internal/config/helpers.go | 44 +++++++++++++++ internal/config/philharmonic.go | 95 +++++++++++++++++++++++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 internal/config/app.go create mode 100644 internal/config/helpers.go create mode 100644 internal/config/philharmonic.go diff --git a/internal/config/app.go b/internal/config/app.go new file mode 100644 index 0000000..c0c2f8d --- /dev/null +++ b/internal/config/app.go @@ -0,0 +1,40 @@ +package config + +import ( + "fmt" + "time" +) + +type AppConfig struct { + HTTPAddr string + ProcessingTime time.Duration + ShutdownTimeout time.Duration +} + +const ( + envVarAppAddress = "HTTP_ADDR" + envVarAppProcessingTime = "TASK_PROCESSING_TIME" + envVarAppShutdownTimeout = "SHUTDOWN_TIMEOUT" +) + +const ( + defaultAppAddress = ":8000" + defaultAppProcessingTime = 2 * time.Second + defaultAppShutdownTimeout = 10 * time.Second +) + +func loadAppConfig() (AppConfig, error) { + appCfg := AppConfig{ + HTTPAddr: envString(envVarAppAddress, defaultAppAddress), + ProcessingTime: envDuration(envVarAppProcessingTime, defaultAppProcessingTime), + ShutdownTimeout: envDuration(envVarAppShutdownTimeout, defaultAppShutdownTimeout), + } + + if appCfg.ProcessingTime <= 0 { + return AppConfig{}, fmt.Errorf("TASK_PROCESSING_TIME must be positive, got %s", appCfg.ProcessingTime) + } + if appCfg.ShutdownTimeout <= 0 { + return AppConfig{}, fmt.Errorf("SHUTDOWN_TIMEOUT must be positive, got %s", appCfg.ShutdownTimeout) + } + return appCfg, nil +} diff --git a/internal/config/helpers.go b/internal/config/helpers.go new file mode 100644 index 0000000..457f4f0 --- /dev/null +++ b/internal/config/helpers.go @@ -0,0 +1,44 @@ +package config + +import ( + "os" + "strconv" + "strings" + "time" +) + +func envString(key, def string) string { + if v, ok := os.LookupEnv(key); ok && strings.TrimSpace(v) != "" { + return v + } + return def +} + +func envDuration(key string, def time.Duration) time.Duration { + if v, ok := os.LookupEnv(key); ok && strings.TrimSpace(v) != "" { + d, err := time.ParseDuration(strings.TrimSpace(v)) + if err != nil { + return def + } + return d + } + return def +} + +func envFloat(key string, def float64) float64 { + if v, ok := os.LookupEnv(key); ok { + if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil { + return f + } + } + return def +} + +func envInt64(key string, def int64) int64 { + if v, ok := os.LookupEnv(key); ok { + if i, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil { + return i + } + } + return def +} diff --git a/internal/config/philharmonic.go b/internal/config/philharmonic.go new file mode 100644 index 0000000..21ccf5d --- /dev/null +++ b/internal/config/philharmonic.go @@ -0,0 +1,95 @@ +package config + +import ( + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + envVarPhilURL = "PHILHARMONIC_URL" + envVarPhilToken = "PHILHARMONIC_TOKEN" + envVarPhilImage = "PHILHARMONIC_IMAGE" + envVarPhilTaskTimeout = "PHILHARMONIC_TASK_TIMEOUT" + envVarPhilPollInterval = "PHILHARMONIC_POLL_INTERVAL" + envVarPhilPollTimeout = "PHILHARMONIC_POLL_TIMEOUT" + envVarPhilCpu = "PHILHARMONIC_CPU" + envVarPhilMemory = "PHILHARMONIC_MEMORY" +) + +const ( + defaultPhilURL = "http://localhost:5555" + defaultPhilImage = "sandbox:latest" + defaultPhilTaskTimeout = 30 * time.Second + defaultPhilPollInterval = time.Second + defaultPhilCpu = 0.5 + defaultPhilMemory = 256 << 20 // bytes +) + +type PhilharmonicConfig struct { + BaseURL string + Token string // empty means the manager runs without auth + SandboxImage string + TaskTimeout time.Duration + PollInterval time.Duration + PollTimeout time.Duration + + // per-task resource limits + Cpu float64 + Memory int64 // bytes + + // HTTPClient overrides the transport, mainly for tests; nil = default + HTTPClient *http.Client +} + +// called by Load +func loadPhilharmonicConfig() (PhilharmonicConfig, error) { + phrmCfg := PhilharmonicConfig{ + BaseURL: envString(envVarPhilURL, defaultPhilURL), + Token: envString(envVarPhilToken, ""), + SandboxImage: envString(envVarPhilImage, defaultPhilImage), + TaskTimeout: envDuration(envVarPhilTaskTimeout, defaultPhilTaskTimeout), + PollInterval: envDuration(envVarPhilPollInterval, defaultPhilPollInterval), + PollTimeout: envDuration(envVarPhilPollTimeout, 0), // derived below + Cpu: envFloat(envVarPhilCpu, defaultPhilCpu), + Memory: envInt64(envVarPhilMemory, defaultPhilMemory), + } + + if phrmCfg.PollTimeout <= 0 { + phrmCfg.PollTimeout = phrmCfg.TaskTimeout + 15*time.Second + } + + if err := validatePhilharmonic(phrmCfg); err != nil { + return PhilharmonicConfig{}, err + } + return phrmCfg, nil +} + +func validatePhilharmonic(opts PhilharmonicConfig) error { + u, err := url.Parse(opts.BaseURL) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { + return fmt.Errorf("%s must be a valid http(s) URL, got %q", envVarPhilURL, opts.BaseURL) + } + if strings.TrimSpace(opts.SandboxImage) == "" { + return fmt.Errorf("%s must not be empty", envVarPhilImage) + } + if opts.TaskTimeout <= 0 { + return fmt.Errorf("%s must be positive, got %s", envVarPhilTaskTimeout, opts.TaskTimeout) + } + if opts.PollInterval <= 0 { + return fmt.Errorf("%s must be positive, got %s", envVarPhilPollInterval, opts.PollInterval) + } + if opts.PollTimeout < opts.TaskTimeout { + return fmt.Errorf("%s (%s) must exceed %s (%s): the worker enforces the kill switch asynchronously", + envVarPhilPollTimeout, opts.PollTimeout, envVarPhilTaskTimeout, opts.TaskTimeout) + } + if opts.Cpu <= 0 { + return fmt.Errorf("%s must be positive, got %v", envVarPhilCpu, opts.Cpu) + } + if opts.Memory <= 0 { + return fmt.Errorf("%s must be positive (bytes), got %d", envVarPhilMemory, opts.Memory) + } + return nil +} From 48fc6f147181900a9016407e497ffd91f7998fb9 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Sun, 30 Aug 2026 22:43:35 +0300 Subject: [PATCH 07/78] feat(config): loading the separate configs into one struct --- internal/config/config.go | 51 +++++++++++---------------------------- 1 file changed, 14 insertions(+), 37 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index d739f9c..5e9f65f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,54 +1,31 @@ package config import ( + "errors" "fmt" - "os" - "strings" - "time" ) type Config struct { - HTTPAddr string - ProcessingTime time.Duration - ShutdownTimeout time.Duration + App AppConfig + Philharmonic PhilharmonicConfig } -const ( - envVarAddress = "HTTP_ADDR" - envVarProcessing = "TASK_PROCESSING_TIME" - envVarShutdownTimeout = "SHUTDOWN_TIMEOUT" -) - func Load() (Config, error) { - cfg := Config{ - HTTPAddr: envString(envVarAddress, ":8000"), - ProcessingTime: envDuration(envVarProcessing, 2*time.Second), - ShutdownTimeout: envDuration(envVarShutdownTimeout, 10*time.Second), - } + var errs []error - if cfg.ProcessingTime <= 0 { - return Config{}, fmt.Errorf("TASK_PROCESSING_TIME must be positive, got %s", cfg.ProcessingTime) + appCfg, err := loadAppConfig() + if err != nil { + errs = append(errs, fmt.Errorf("loading app config: %w", err)) } - if cfg.ShutdownTimeout <= 0 { - return Config{}, fmt.Errorf("SHUTDOWN_TIMEOUT must be positive, got %s", cfg.ShutdownTimeout) - } - return cfg, nil -} -func envString(key, def string) string { - if v, ok := os.LookupEnv(key); ok && strings.TrimSpace(v) != "" { - return v + phrmCfg, err := loadPhilharmonicConfig() + if err != nil { + errs = append(errs, fmt.Errorf("loading philharmonic config: %w", err)) } - return def -} -func envDuration(key string, def time.Duration) time.Duration { - if v, ok := os.LookupEnv(key); ok && strings.TrimSpace(v) != "" { - d, err := time.ParseDuration(strings.TrimSpace(v)) - if err != nil { - return def - } - return d + if len(errs) == 0 { + return Config{App: appCfg, Philharmonic: phrmCfg}, nil + } else { + return Config{}, errors.Join(err) } - return def } From 82096a63b34f72c9b909c2af075f2293eb7bc695 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Sun, 30 Aug 2026 22:43:58 +0300 Subject: [PATCH 08/78] fix(main): fixed config referencing --- cmd/server/main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 70ab15b..01dd2ec 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -36,14 +36,14 @@ func main() { repo := storage.New() - taskService := service.NewTaskService(repo, cfg.ProcessingTime) + taskService := service.NewTaskService(repo, cfg.App.ProcessingTime) userService := service.NewUserService(repo, repo) taskHandler := handlers.NewTaskHandlers(taskService) userHandler := handlers.NewUserHandlers(userService) router := controller.NewRouter(taskHandler, userHandler, userService) - server := controller.NewApi(cfg.HTTPAddr, router, cfg.ShutdownTimeout) + server := controller.NewApi(cfg.App.HTTPAddr, router, cfg.App.ShutdownTimeout) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() From ed00a2a843c707b74547f7e1c1ce89ef7e1ee69c Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Sun, 30 Aug 2026 22:52:14 +0300 Subject: [PATCH 09/78] chore: go mod tidy --- go.mod | 6 +++++- go.sum | 25 ++++++++++++++++++------- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/go.mod b/go.mod index 3fec23c..2d59578 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( require ( github.com/KyleBanks/depth v1.2.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/go-openapi/jsonpointer v1.0.0 // indirect github.com/go-openapi/jsonreference v1.0.1 // indirect github.com/go-openapi/spec v0.22.11 // indirect @@ -24,10 +25,13 @@ require ( github.com/go-openapi/swag/stringutils v0.29.1 // indirect github.com/go-openapi/swag/typeutils v0.29.1 // indirect github.com/go-openapi/swag/yamlutils v0.29.1 // indirect - github.com/rabbitmq/amqp091-go v1.14.0 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/rogpeppe/go-internal v1.13.1 // indirect + github.com/stretchr/testify v1.11.1 // indirect github.com/sv-tools/openapi v0.4.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/mod v0.40.0 // indirect golang.org/x/tools v0.49.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 9343f13..40aa084 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,8 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= @@ -33,12 +34,21 @@ github.com/go-openapi/testify/v2 v2.6.1 h1:6CNJhTjMzgaeaH8WhshcsZNPIvRemiOcFpU7s github.com/go-openapi/testify/v2 v2.6.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rabbitmq/amqp091-go v1.14.0 h1:RSaT7aOKt/OrkVUyswPDW29lnRz9psuGmfZFBmLqLek= -github.com/rabbitmq/amqp091-go v1.14.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/sv-tools/openapi v0.4.0 h1:UhD9DVnGox1hfTePNclpUzUFgos57FvzT2jmcAuTOJ4= github.com/sv-tools/openapi v0.4.0/go.mod h1:kD/dG+KP0+Fom1r6nvcj/ORtLus8d8enXT6dyRZDirE= github.com/swaggo/swag/v2 v2.0.0-rc5 h1:fK7d6ET9rrEsdB8IyuwXREWMcyQN3N7gawGFbbrjgHk= @@ -53,7 +63,8 @@ golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 26b803809cee7c22d486efe6aa4119c6aa6a0311 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Sun, 30 Aug 2026 22:55:37 +0300 Subject: [PATCH 10/78] refactor(config): split 'app' and 'philharmonic' configs again --- internal/config/app.go | 2 +- internal/config/config.go | 31 ------------------------------- internal/config/philharmonic.go | 2 +- 3 files changed, 2 insertions(+), 33 deletions(-) delete mode 100644 internal/config/config.go diff --git a/internal/config/app.go b/internal/config/app.go index c0c2f8d..d8e7e16 100644 --- a/internal/config/app.go +++ b/internal/config/app.go @@ -23,7 +23,7 @@ const ( defaultAppShutdownTimeout = 10 * time.Second ) -func loadAppConfig() (AppConfig, error) { +func LoadAppConfig() (AppConfig, error) { appCfg := AppConfig{ HTTPAddr: envString(envVarAppAddress, defaultAppAddress), ProcessingTime: envDuration(envVarAppProcessingTime, defaultAppProcessingTime), diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index 5e9f65f..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,31 +0,0 @@ -package config - -import ( - "errors" - "fmt" -) - -type Config struct { - App AppConfig - Philharmonic PhilharmonicConfig -} - -func Load() (Config, error) { - var errs []error - - appCfg, err := loadAppConfig() - if err != nil { - errs = append(errs, fmt.Errorf("loading app config: %w", err)) - } - - phrmCfg, err := loadPhilharmonicConfig() - if err != nil { - errs = append(errs, fmt.Errorf("loading philharmonic config: %w", err)) - } - - if len(errs) == 0 { - return Config{App: appCfg, Philharmonic: phrmCfg}, nil - } else { - return Config{}, errors.Join(err) - } -} diff --git a/internal/config/philharmonic.go b/internal/config/philharmonic.go index 21ccf5d..dab8579 100644 --- a/internal/config/philharmonic.go +++ b/internal/config/philharmonic.go @@ -45,7 +45,7 @@ type PhilharmonicConfig struct { } // called by Load -func loadPhilharmonicConfig() (PhilharmonicConfig, error) { +func LoadPhilharmonicConfig() (PhilharmonicConfig, error) { phrmCfg := PhilharmonicConfig{ BaseURL: envString(envVarPhilURL, defaultPhilURL), Token: envString(envVarPhilToken, ""), From 881055cdefe08b2b2fedcb3f1f21f26e75f05556 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Sun, 30 Aug 2026 22:55:55 +0300 Subject: [PATCH 11/78] fix(main): updated config-related references --- cmd/server/main.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 01dd2ec..5cb1b96 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -29,21 +29,21 @@ import ( // // @securitydefinitions.bearerauth BearerAuth func main() { - cfg, err := config.Load() + cfg, err := config.LoadAppConfig() if err != nil { log.Fatalf("invalid config: %v", err) } repo := storage.New() - taskService := service.NewTaskService(repo, cfg.App.ProcessingTime) + taskService := service.NewTaskService(repo, cfg.ProcessingTime) userService := service.NewUserService(repo, repo) taskHandler := handlers.NewTaskHandlers(taskService) userHandler := handlers.NewUserHandlers(userService) router := controller.NewRouter(taskHandler, userHandler, userService) - server := controller.NewApi(cfg.App.HTTPAddr, router, cfg.App.ShutdownTimeout) + server := controller.NewApi(cfg.HTTPAddr, router, cfg.ShutdownTimeout) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() From 8f46553d3a9fecd10d86e331767ea69a090cdcc6 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Sun, 30 Aug 2026 22:57:22 +0300 Subject: [PATCH 12/78] chore: updated .env.example to reflect new env vars --- .env.example | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.env.example b/.env.example index 4f5d18d..9c60792 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,15 @@ HTTP_ADDR=:8000 TASK_PROCESSING_TIME=2s SHUTDOWN_TIMEOUT=10s + +PHILHARMONIC_URL=http://localhost:5555 +# empty means manager runs without auth +PHILHARMONIC_TOKEN= +PHILHARMONIC_IMAGE=sandbox:latest +PHILHARMONIC_TASK_TIMEOUT=30s +PHILHARMONIC_POLL_INTERVAL=1s +# unset means PHILHARMONIC_TASK_TIMEOUT + 15s +PHILHARMONIC_POLL_TIMEOUT= +PHILHARMONIC_CPU=0.5 +# bytes +PHILHARMONIC_MEMORY=268435456 From da5220cd5d1b4619dfcf30f28e01e68cbaafaa87 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Mon, 31 Aug 2026 16:28:39 +0300 Subject: [PATCH 13/78] feat(service): added a client for philharmonic --- internal/service/philharmonic.go | 345 +++++++++++++++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 internal/service/philharmonic.go diff --git a/internal/service/philharmonic.go b/internal/service/philharmonic.go new file mode 100644 index 0000000..fdeca1a --- /dev/null +++ b/internal/service/philharmonic.go @@ -0,0 +1,345 @@ +package service + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/belyaevedu/remote-code-service/internal/config" + "github.com/belyaevedu/remote-code-service/internal/domain" + "github.com/belyaevedu/remote-code-service/internal/port" +) + +// mirroring philharmonic's task.State +const ( + phrmStatePending = 0 + phrmStateScheduled = 1 + phrmStateRunning = 2 + phrmStateCompleted = 3 + phrmStateFailed = 4 +) + +var supportedTranslators = map[string]struct{}{ + "python3": {}, + "gcc": {}, + "clang": {}, +} + +const ( + phrmDefaultPollInterval = time.Second + phrmDefaultHTTPTimeout = 10 * time.Second + phrmStopTimeout = 5 * time.Second +) + +type PhilharmonicExecutor struct { + cfg config.PhilharmonicConfig + client *http.Client +} + +var _ port.CodeExecutor = (*PhilharmonicExecutor)(nil) + +func NewPhilharmonicExecutor(cfg config.PhilharmonicConfig) *PhilharmonicExecutor { + if cfg.PollInterval <= 0 { + cfg.PollInterval = phrmDefaultPollInterval + } + if cfg.PollTimeout <= 0 { + cfg.PollTimeout = cfg.TaskTimeout + 15*time.Second + } + if cfg.HTTPClient == nil { + cfg.HTTPClient = &http.Client{Timeout: phrmDefaultHTTPTimeout} + } + cfg.BaseURL = strings.TrimSuffix(cfg.BaseURL, "/") + + return &PhilharmonicExecutor{cfg: cfg, client: cfg.HTTPClient} +} + +func (e *PhilharmonicExecutor) Execute(ctx context.Context, req domain.ExecutionRequest) (domain.ExecutionResult, error) { + if _, ok := supportedTranslators[req.Translator]; !ok { + return domain.ExecutionResult{}, fmt.Errorf("%w: %q", domain.ErrUnsupportedTranslator, req.Translator) + } + if req.Name == "" { + return domain.ExecutionResult{}, fmt.Errorf("execution name must not be empty") + } + + if err := e.submit(ctx, req); err != nil { + return domain.ExecutionResult{}, fmt.Errorf("philharmonic submit: %w", err) + } + + entry, err := e.awaitTerminal(ctx, req.Name) + if err != nil { + e.stopQuietly(req.Name) + return domain.ExecutionResult{}, err + } + + output, exitCode, err := e.logs(ctx, req.Name) + if err != nil { + e.stopQuietly(req.Name) + return domain.ExecutionResult{}, fmt.Errorf("philharmonic logs: %w", err) + } + + // a 2nd stop OR a stop on a task in a terminal state removes the record from the manager + e.stopQuietly(req.Name) + + result := domain.ExecutionResult{ + Output: output, + ExitCode: exitCode, + Failed: entry.State == phrmStateFailed, + } + if result.Failed && strings.TrimSpace(result.Output) == "" { + if entry.FailureReason != "" { + result.Output = fmt.Sprintf("execution failed: %s", entry.FailureReason) + } else { + result.Output = fmt.Sprintf("execution failed (exit code %d)", exitCode) + } + } + return result, nil +} + +// mirrors the philharmonic task.Task JSON field names +type submitTask struct { + Name string `json:"Name"` + Image string `json:"Image"` + Env []string `json:"Env,omitempty"` + RestartPolicy string `json:"RestartPolicy"` + Timeout int64 `json:"Timeout"` // seconds + Cpu float64 `json:"Cpu"` + Memory int64 `json:"Memory"` // bytes +} + +type submitEvent struct { + Task submitTask `json:"Task"` +} + +func (e *PhilharmonicExecutor) submit(ctx context.Context, req domain.ExecutionRequest) error { + body, err := json.Marshal(submitEvent{ + Task: submitTask{ + Name: req.Name, + Image: e.cfg.SandboxImage, + Env: []string{ + "TRANSLATOR=" + req.Translator, + "USER_CODE_B64=" + base64.StdEncoding.EncodeToString([]byte(req.Code)), + }, + RestartPolicy: "no", + + // ceiling-division to whole seconds + Timeout: int64((e.cfg.TaskTimeout + time.Second - 1) / time.Second), + + Cpu: e.cfg.Cpu, + Memory: e.cfg.Memory, + }, + }) + if err != nil { + return err + } + + resp, err := e.sendRequest(ctx, http.MethodPost, "/tasks", bytes.NewReader(body)) + if err != nil { + return err + } + return drainAndCheck(resp, http.StatusCreated) +} + +// fields of a philharmonic TaskView the client cares about +type taskListEntry struct { + Name string `json:"Name"` + State int `json:"State"` + FailureReason string `json:"FailureReason"` +} + +// polls GET /tasks until the named task reaches a terminal state (Completed/Failed) or the poll budget runs out +func (e *PhilharmonicExecutor) awaitTerminal(ctx context.Context, name string) (taskListEntry, error) { + deadline := time.Now().Add(e.cfg.PollTimeout) + + for { + resp, err := e.sendRequest(ctx, http.MethodGet, "/tasks", nil) + if err != nil { + return taskListEntry{}, fmt.Errorf("philharmonic poll: %w", err) + } + + entries, err := decodeTaskList(resp) + if err != nil { + return taskListEntry{}, fmt.Errorf("philharmonic poll: %w", err) + } + + var match *taskListEntry + found := 0 + for i := range entries { + if entries[i].Name == name { + found++ + match = &entries[i] + } + } + + switch { + case found > 1: + return taskListEntry{}, fmt.Errorf("philharmonic poll: %d tasks named %q exist", found, name) + case found == 1 && (match.State == phrmStateCompleted || match.State == phrmStateFailed): + return *match, nil + } + + if err := ctx.Err(); err != nil { + return taskListEntry{}, fmt.Errorf("philharmonic poll: %w", err) + } + + wait := time.Until(deadline) + if wait <= 0 { + return taskListEntry{}, fmt.Errorf("%w: task %q did not reach a terminal state within %s", + domain.ErrExecutionTimeout, name, e.cfg.PollTimeout) + } + if wait > e.cfg.PollInterval { + wait = e.cfg.PollInterval + } + + select { + case <-ctx.Done(): + return taskListEntry{}, fmt.Errorf("philharmonic poll: %w", ctx.Err()) + case <-time.After(wait): + } + } +} + +// fetches stdout+stderr logs from the manager +func (e *PhilharmonicExecutor) logs(ctx context.Context, name string) (string, int, error) { + resp, err := e.sendRequest(ctx, http.MethodGet, "/tasks/logs/"+url.PathEscape(name), nil) + if err != nil { + return "", 0, err + } + defer func() { + if err := resp.Body.Close(); err != nil { + log.Printf("error raised closing resp body: %v\n", err) + } + }() + + if resp.StatusCode != http.StatusOK { + return "", 0, unexpectedStatus(resp) + } + + // the orchestrator bounds the captured log size itself + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", 0, err + } + + exitCode := -1 + if v := resp.Header.Get("X-Exit-Code"); v != "" { + code, err := strconv.Atoi(v) + if err != nil { + log.Printf("philharmonic: malformed X-Exit-Code header %q for task %s: %v", v, name, err) + } else { + exitCode = code + } + } + return string(body), exitCode, nil +} + +func (e *PhilharmonicExecutor) stopQuietly(name string) { + if err := e.stop(name); err != nil { + log.Printf("philharmonic: cleanup of task %q failed: %v", name, err) + } +} + +// for terminal state tasks this removes the record from the manager store, +// for live ones it stops the container +func (e *PhilharmonicExecutor) stop(name string) error { + // fresh context: the caller's may already be expired or canceled + ctx, cancel := context.WithTimeout(context.Background(), phrmStopTimeout) + defer cancel() + + resp, err := e.sendRequest(ctx, http.MethodDelete, "/tasks/"+url.PathEscape(name), nil) + if err != nil { + return fmt.Errorf("stop request: %w", err) + } + + defer func() { + if err := resp.Body.Close(); err != nil { + log.Printf("error raised closing resp body: %v\n", err) + } + }() + + body, err := readSnippet(resp) + if err != nil { + return fmt.Errorf("reading stop response: %w", err) + } + + switch resp.StatusCode { + case http.StatusNoContent, http.StatusNotFound: + return nil + default: + return statusError(resp.StatusCode, body) + } +} + +func (e *PhilharmonicExecutor) sendRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) { + httpReq, err := http.NewRequestWithContext(ctx, method, e.cfg.BaseURL+path, body) + if err != nil { + return nil, err + } + if body != nil { + httpReq.Header.Set("Content-Type", "application/json") + } + if e.cfg.Token != "" { + httpReq.Header.Set("Authorization", "Bearer "+e.cfg.Token) + } + return e.client.Do(httpReq) +} + +func decodeTaskList(resp *http.Response) ([]taskListEntry, error) { + defer func() { + if err := resp.Body.Close(); err != nil { + log.Printf("error raised closing resp body: %v\n", err) + } + }() + + if resp.StatusCode != http.StatusOK { + return nil, unexpectedStatus(resp) + } + + var entries []taskListEntry + if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil { + return nil, fmt.Errorf("decoding task list: %w", err) + } + return entries, nil +} + +func readSnippet(resp *http.Response) ([]byte, error) { + return io.ReadAll(io.LimitReader(resp.Body, 4<<10)) +} + +func statusError(status int, body []byte) error { + return fmt.Errorf("unexpected status %d: %s", status, strings.TrimSpace(string(body))) +} + +// draining responses to keep the connection reusable +func drainAndCheck(resp *http.Response, want int) error { + defer func() { + if err := resp.Body.Close(); err != nil { + log.Printf("error raised closing resp body: %v\n", err) + } + }() + + body, err := readSnippet(resp) + if err != nil { + return fmt.Errorf("reading response body: %w", err) + } + if resp.StatusCode != want { + return statusError(resp.StatusCode, body) + } + return nil +} + +func unexpectedStatus(resp *http.Response) error { + body, err := readSnippet(resp) + if err != nil { + return fmt.Errorf("unexpected status %d (body unreadable: %w)", resp.StatusCode, err) + } + return statusError(resp.StatusCode, body) +} From 405713e35105013fdd929d00f41cc08f67056341 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Mon, 31 Aug 2026 20:48:09 +0300 Subject: [PATCH 14/78] feat(config): added queue config --- internal/config/helpers.go | 9 ++++++ internal/config/queue.go | 59 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 internal/config/queue.go diff --git a/internal/config/helpers.go b/internal/config/helpers.go index 457f4f0..688c3d8 100644 --- a/internal/config/helpers.go +++ b/internal/config/helpers.go @@ -34,6 +34,15 @@ func envFloat(key string, def float64) float64 { return def } +func envInt(key string, def int) int { + if v, ok := os.LookupEnv(key); ok { + if i, err := strconv.Atoi(strings.TrimSpace(v)); err == nil { + return i + } + } + return def +} + func envInt64(key string, def int64) int64 { if v, ok := os.LookupEnv(key); ok { if i, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil { diff --git a/internal/config/queue.go b/internal/config/queue.go new file mode 100644 index 0000000..ca10f96 --- /dev/null +++ b/internal/config/queue.go @@ -0,0 +1,59 @@ +package config + +import ( + "fmt" + "net/url" + "time" +) + +type QueueConfig struct { + URL string + Queue string + Prefetch int + ReconnectDelay time.Duration +} + +const ( + envVarQueueURL = "RABBITMQ_URL" + envVarQueueName = "TASK_QUEUE_NAME" + envVarQueuePrefetch = "TASK_QUEUE_PREFETCH" + envVarQueueReconnect = "TASK_QUEUE_RECONNECT_DELAY" +) + +const ( + defaultQueueURL = "amqp://guest:guest@localhost:5672/" + defaultQueueName = "tasks" + defaultQueuePrefetch = 1 + defaultQueueReconnect = time.Second +) + +func LoadQueueConfig() (QueueConfig, error) { + queueCfg := QueueConfig{ + URL: envString(envVarQueueURL, defaultQueueURL), + Queue: envString(envVarQueueName, defaultQueueName), + Prefetch: envInt(envVarQueuePrefetch, defaultQueuePrefetch), + ReconnectDelay: envDuration(envVarQueueReconnect, defaultQueueReconnect), + } + + if err := validateQueueConfig(queueCfg); err != nil { + return QueueConfig{}, err + } + return queueCfg, nil +} + +func validateQueueConfig(queueCfg QueueConfig) error { + u, err := url.Parse(queueCfg.URL) + if err != nil || u.Host == "" || (u.Scheme != "amqp" && u.Scheme != "amqps") { + return fmt.Errorf("%s must be a valid amqp(s) URL, got %q", envVarQueueURL, queueCfg.URL) + } + if queueCfg.Queue == "" { + return fmt.Errorf("%s must not be empty", envVarQueueName) + } + if queueCfg.Prefetch <= 0 { + return fmt.Errorf("%s must be positive, got %d", envVarQueuePrefetch, queueCfg.Prefetch) + } + if queueCfg.ReconnectDelay <= 0 { + return fmt.Errorf("%s must be positive, got %s", envVarQueueReconnect, queueCfg.ReconnectDelay) + } + return nil +} From cefd43b143a38d03b06b7814f0c1a88abf570e33 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Mon, 31 Aug 2026 20:48:38 +0300 Subject: [PATCH 15/78] feat(task): added struct for rabbitmq payload --- internal/domain/task.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/domain/task.go b/internal/domain/task.go index b8f4d66..e253071 100644 --- a/internal/domain/task.go +++ b/internal/domain/task.go @@ -29,6 +29,12 @@ type Task struct { Result *Result } +type TaskMessage struct { + TaskID string `json:"task_id"` + Translator string `json:"translator"` + Code string `json:"code"` +} + type ExecutionRequest struct { Name string // the executor-side task name, expected to be unique Translator string From 3c0346e1e376b05ff29deeb751b5212d7dfd21e7 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Mon, 31 Aug 2026 20:54:56 +0300 Subject: [PATCH 16/78] feat(port): added interfaces and a type for producer->consumer interactions via queue --- internal/port/port.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/internal/port/port.go b/internal/port/port.go index f1a3375..ebed695 100644 --- a/internal/port/port.go +++ b/internal/port/port.go @@ -43,3 +43,15 @@ type AuthService interface { type CodeExecutor interface { Execute(ctx context.Context, req domain.ExecutionRequest) (domain.ExecutionResult, error) } + +type TaskPublisher interface { + Publish(ctx context.Context, msg domain.TaskMessage) error +} + +// processes a single task message consumed from the queue +// returning an error requeues the message once. a message that already failed once is dropped +type TaskHandler func(ctx context.Context, msg domain.TaskMessage) error + +type TaskConsumer interface { + Consume(ctx context.Context, handler TaskHandler) error +} From 36d16f3d2e832d9157662576ab432ce38de2ab54 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Tue, 1 Sep 2026 00:57:00 +0300 Subject: [PATCH 17/78] feat(repository): added queue package with basic connection functions --- internal/repository/queue/queue.go | 58 ++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 internal/repository/queue/queue.go diff --git a/internal/repository/queue/queue.go b/internal/repository/queue/queue.go new file mode 100644 index 0000000..bfe623d --- /dev/null +++ b/internal/repository/queue/queue.go @@ -0,0 +1,58 @@ +package queue + +import ( + "context" + "fmt" + "io" + "log" + "time" + + "github.com/rabbitmq/amqp091-go" + + "github.com/belyaevedu/remote-code-service/internal/config" +) + +func closeCleanup(c io.Closer, what string) { + if err := c.Close(); err != nil { + log.Printf("queue: closing %s during cleanup: %v", what, err) + } +} + +func connect(cfg config.QueueConfig) (*amqp091.Connection, *amqp091.Channel, error) { + conn, err := amqp091.Dial(cfg.URL) + if err != nil { + return nil, nil, fmt.Errorf("queue dial: %w", err) + } + + ch, err := conn.Channel() + if err != nil { + closeCleanup(conn, "connection") + return nil, nil, fmt.Errorf("queue channel: %w", err) + } + + // durable, autoDelete, exclusive, noWait, args + if _, err := ch.QueueDeclare(cfg.Queue, true, false, false, false, nil); err != nil { + closeCleanup(ch, "channel") + closeCleanup(conn, "connection") + return nil, nil, fmt.Errorf("queue declare: %w", err) + } + + return conn, ch, nil +} + +// pauses for d, reporting false as soon as ctx is done +func waitReconnect(ctx context.Context, d time.Duration) bool { + if d <= 0 { + return ctx.Err() == nil + } + + t := time.NewTimer(d) + defer t.Stop() + + select { + case <-ctx.Done(): + return false + case <-t.C: + return true + } +} From 280397af7b88632770f878bfa66feb009ce0c623 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Tue, 1 Sep 2026 01:09:24 +0300 Subject: [PATCH 18/78] feat(repository/queue): added a publisher implementation --- internal/repository/queue/publisher.go | 122 +++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 internal/repository/queue/publisher.go diff --git a/internal/repository/queue/publisher.go b/internal/repository/queue/publisher.go new file mode 100644 index 0000000..672b46c --- /dev/null +++ b/internal/repository/queue/publisher.go @@ -0,0 +1,122 @@ +package queue + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "sync" + "time" + + "github.com/rabbitmq/amqp091-go" + + "github.com/belyaevedu/remote-code-service/internal/config" + "github.com/belyaevedu/remote-code-service/internal/domain" + "github.com/belyaevedu/remote-code-service/internal/port" +) + +type Publisher struct { + cfg config.QueueConfig + + conn *amqp091.Connection + ch *amqp091.Channel + mu sync.Mutex +} + +var _ port.TaskPublisher = (*Publisher)(nil) + +func NewPublisher(cfg config.QueueConfig) *Publisher { + if cfg.Prefetch <= 0 { + cfg.Prefetch = 1 + } + if cfg.ReconnectDelay <= 0 { + cfg.ReconnectDelay = time.Second + } + return &Publisher{cfg: cfg} +} + +func (p *Publisher) Publish(ctx context.Context, msg domain.TaskMessage) error { + pub, err := newTaskPublishing(msg) + if err != nil { + return err + } + + p.mu.Lock() + defer p.mu.Unlock() + + if err := p.ensureReady(); err != nil { + return err + } + + // "" being the default exchange + if err := p.ch.PublishWithContext(ctx, "", p.cfg.Queue, false, false, pub); err != nil { + // the cached connection and/or channel went stale + // drop, redial and retry the publish exactly once + if err := p.reset(); err != nil { + log.Printf("queue publisher: discarding stale session: %v", err) + } + + if err := p.ensureReady(); err != nil { + return err + } + + if err := p.ch.PublishWithContext(ctx, "", p.cfg.Queue, false, false, pub); err != nil { + if err := p.reset(); err != nil { + log.Printf("queue publisher: discarding failed session: %v", err) + } + return fmt.Errorf("queue publish: %w", err) + } + } + return nil +} + +func newTaskPublishing(msg domain.TaskMessage) (amqp091.Publishing, error) { + body, err := json.Marshal(msg) + if err != nil { + return amqp091.Publishing{}, fmt.Errorf("queue encode: %w", err) + } + + return amqp091.Publishing{ + ContentType: "application/json", + Body: body, + DeliveryMode: amqp091.Persistent, + CorrelationId: msg.TaskID, + Timestamp: time.Now(), + Type: "task.submitted", + }, nil +} + +func (p *Publisher) Close() error { + p.mu.Lock() + defer p.mu.Unlock() + return p.reset() +} + +func (p *Publisher) ensureReady() error { + if p.ch != nil { + return nil + } + conn, ch, err := connect(p.cfg) + if err != nil { + return err + } + p.conn, p.ch = conn, ch + return nil +} + +func (p *Publisher) reset() error { + var errs []error + if p.ch != nil { + if err := p.ch.Close(); err != nil { + errs = append(errs, fmt.Errorf("closing channel: %w", err)) + } + } + if p.conn != nil { + if err := p.conn.Close(); err != nil { + errs = append(errs, fmt.Errorf("closing connection: %w", err)) + } + } + p.ch, p.conn = nil, nil + return errors.Join(errs...) +} From 6204112df0ff4aed4258d3ae4b62f29e525d4f5a Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Tue, 1 Sep 2026 01:22:26 +0300 Subject: [PATCH 19/78] feat(repository/queue): added a consumer implementation --- internal/repository/queue/consumer.go | 125 ++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 internal/repository/queue/consumer.go diff --git a/internal/repository/queue/consumer.go b/internal/repository/queue/consumer.go new file mode 100644 index 0000000..6540191 --- /dev/null +++ b/internal/repository/queue/consumer.go @@ -0,0 +1,125 @@ +package queue + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "time" + + "github.com/rabbitmq/amqp091-go" + + "github.com/belyaevedu/remote-code-service/internal/config" + "github.com/belyaevedu/remote-code-service/internal/domain" + "github.com/belyaevedu/remote-code-service/internal/port" +) + +type Consumer struct { + cfg config.QueueConfig +} + +var _ port.TaskConsumer = (*Consumer)(nil) + +func NewConsumer(cfg config.QueueConfig) *Consumer { + if cfg.Prefetch <= 0 { + cfg.Prefetch = 1 + } + if cfg.ReconnectDelay <= 0 { + cfg.ReconnectDelay = time.Second + } + return &Consumer{cfg: cfg} +} + +// draining the task queue until the context stops +func (c *Consumer) Consume(ctx context.Context, handler port.TaskHandler) error { + for { + if ctx.Err() != nil { + return nil + } + + if err := c.consumeOnce(ctx, handler); err != nil && ctx.Err() == nil { + log.Printf("queue consumer: %v, reconnecting in %s", err, c.cfg.ReconnectDelay) + } + + if !waitReconnect(ctx, c.cfg.ReconnectDelay) { + return nil + } + } +} + +// runs a single broker session +func (c *Consumer) consumeOnce(ctx context.Context, handler port.TaskHandler) error { + conn, ch, err := connect(c.cfg) + if err != nil { + return err + } + defer func() { + closeCleanup(ch, "channel") + closeCleanup(conn, "connection") + }() + + // bounding in-flight messages + if err := ch.Qos(c.cfg.Prefetch, 0, false); err != nil { + return fmt.Errorf("queue qos: %w", err) + } + + deliveries, err := ch.ConsumeWithContext(ctx, c.cfg.Queue, "", false, false, false, false, nil) + if err != nil { + return fmt.Errorf("queue consume: %w", err) + } + + for { + select { + case <-ctx.Done(): + return nil + case d, ok := <-deliveries: + if !ok { + // the broker closed the channel + return errors.New("queue deliveries closed: connection lost") + } + c.handle(ctx, handler, d) + } + } +} + +// success acknowledges, a handler failure requeues the message once and a message that already failed once is dropped. +// malformed messages are just dropped +func (c *Consumer) handle(ctx context.Context, handler port.TaskHandler, d amqp091.Delivery) { + var msg domain.TaskMessage + if err := json.Unmarshal(d.Body, &msg); err != nil { + log.Printf("queue consumer: dropping malformed task message: %v", err) + c.nack(d, false) + return + } + if msg.TaskID == "" { + log.Printf("queue consumer: dropping task message without an id") + c.nack(d, false) + return + } + + if err := handler(ctx, msg); err != nil { + if d.Redelivered { + log.Printf("queue consumer: dropping task %s after a repeated failure: %v", msg.TaskID, err) + c.nack(d, false) + return + } + log.Printf("queue consumer: requeueing task %s after handler failure: %v", msg.TaskID, err) + c.nack(d, true) + return + } + + c.ack(d) +} + +func (c *Consumer) ack(d amqp091.Delivery) { + if err := d.Ack(false); err != nil { + log.Printf("queue consumer: acknowledging task: %v", err) + } +} + +func (c *Consumer) nack(d amqp091.Delivery, requeue bool) { + if err := d.Nack(false, requeue); err != nil { + log.Printf("queue consumer: nacking task (requeue=%t): %v", requeue, err) + } +} From 16ad7b36811eb36bee54d31dbac5f42d7dda6c81 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Tue, 1 Sep 2026 01:22:59 +0300 Subject: [PATCH 20/78] docs(.env): updated .env.example --- .env.example | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.env.example b/.env.example index 9c60792..0342e73 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,8 @@ PHILHARMONIC_POLL_TIMEOUT= PHILHARMONIC_CPU=0.5 # bytes PHILHARMONIC_MEMORY=268435456 + +RABBITMQ_URL=amqp://guest:guest@localhost:5672/ +TASK_QUEUE_NAME=tasks +TASK_QUEUE_PREFETCH=1 +TASK_QUEUE_RECONNECT_DELAY=1s From b108b028f616778aeba28fdd4f050ef8c2ef5128 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Tue, 1 Sep 2026 01:23:10 +0300 Subject: [PATCH 21/78] chore: updated go module info --- go.mod | 1 + go.sum | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/go.mod b/go.mod index 2d59578..a8704c7 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( ) require ( + github.com/rabbitmq/amqp091-go v1.14.0 github.com/swaggo/swag/v2 v2.0.0-rc5 golang.org/x/crypto v0.55.0 ) diff --git a/go.sum b/go.sum index 40aa084..9cfcc5e 100644 --- a/go.sum +++ b/go.sum @@ -44,6 +44,8 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rabbitmq/amqp091-go v1.14.0 h1:RSaT7aOKt/OrkVUyswPDW29lnRz9psuGmfZFBmLqLek= +github.com/rabbitmq/amqp091-go v1.14.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= @@ -53,6 +55,8 @@ github.com/sv-tools/openapi v0.4.0 h1:UhD9DVnGox1hfTePNclpUzUFgos57FvzT2jmcAuTOJ github.com/sv-tools/openapi v0.4.0/go.mod h1:kD/dG+KP0+Fom1r6nvcj/ORtLus8d8enXT6dyRZDirE= github.com/swaggo/swag/v2 v2.0.0-rc5 h1:fK7d6ET9rrEsdB8IyuwXREWMcyQN3N7gawGFbbrjgHk= github.com/swaggo/swag/v2 v2.0.0-rc5/go.mod h1:kCL8Fu4Zl8d5tB2Bgj96b8wRowwrwk175bZHXfuGVFI= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= From 9f39414cb2cdbaa60564c9aa7af22fec5ec8cf22 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Tue, 1 Sep 2026 14:52:59 +0300 Subject: [PATCH 22/78] refactor: removed double of TaskRequest --- internal/domain/task.go | 6 ------ internal/port/port.go | 4 ++-- internal/service/philharmonic.go | 27 +++++++++++++++------------ 3 files changed, 17 insertions(+), 20 deletions(-) diff --git a/internal/domain/task.go b/internal/domain/task.go index e253071..31c0620 100644 --- a/internal/domain/task.go +++ b/internal/domain/task.go @@ -35,12 +35,6 @@ type TaskMessage struct { Code string `json:"code"` } -type ExecutionRequest struct { - Name string // the executor-side task name, expected to be unique - Translator string - Code string -} - type ExecutionResult struct { Output string ExitCode int // -1 when unavailable diff --git a/internal/port/port.go b/internal/port/port.go index ebed695..b33b7e1 100644 --- a/internal/port/port.go +++ b/internal/port/port.go @@ -14,7 +14,7 @@ type TaskRepository interface { } type TaskService interface { - Submit(userID string) (string, error) + Submit(ctx context.Context, userID string, sub domain.Submission) (string, error) Status(userID, id string) (domain.TaskStatus, error) Result(userID, id string) (*domain.Result, error) } @@ -41,7 +41,7 @@ type AuthService interface { } type CodeExecutor interface { - Execute(ctx context.Context, req domain.ExecutionRequest) (domain.ExecutionResult, error) + Execute(ctx context.Context, msg domain.TaskMessage) (domain.ExecutionResult, error) } type TaskPublisher interface { diff --git a/internal/service/philharmonic.go b/internal/service/philharmonic.go index fdeca1a..f83faf5 100644 --- a/internal/service/philharmonic.go +++ b/internal/service/philharmonic.go @@ -62,32 +62,35 @@ func NewPhilharmonicExecutor(cfg config.PhilharmonicConfig) *PhilharmonicExecuto return &PhilharmonicExecutor{cfg: cfg, client: cfg.HTTPClient} } -func (e *PhilharmonicExecutor) Execute(ctx context.Context, req domain.ExecutionRequest) (domain.ExecutionResult, error) { - if _, ok := supportedTranslators[req.Translator]; !ok { - return domain.ExecutionResult{}, fmt.Errorf("%w: %q", domain.ErrUnsupportedTranslator, req.Translator) +func (e *PhilharmonicExecutor) Execute(ctx context.Context, msg domain.TaskMessage) (domain.ExecutionResult, error) { + if _, ok := supportedTranslators[msg.Translator]; !ok { + return domain.ExecutionResult{}, fmt.Errorf("%w: %q", domain.ErrUnsupportedTranslator, msg.Translator) } - if req.Name == "" { - return domain.ExecutionResult{}, fmt.Errorf("execution name must not be empty") + if msg.TaskID == "" { + return domain.ExecutionResult{}, fmt.Errorf("task message without an id") } - if err := e.submit(ctx, req); err != nil { + // unique per orchestrator and traceable back to our task id + name := "run-" + msg.TaskID + + if err := e.submit(ctx, name, msg); err != nil { return domain.ExecutionResult{}, fmt.Errorf("philharmonic submit: %w", err) } - entry, err := e.awaitTerminal(ctx, req.Name) + entry, err := e.awaitTerminal(ctx, name) if err != nil { - e.stopQuietly(req.Name) + e.stopAndLog(name) return domain.ExecutionResult{}, err } - output, exitCode, err := e.logs(ctx, req.Name) + output, exitCode, err := e.logs(ctx, name) if err != nil { - e.stopQuietly(req.Name) + e.stopAndLog(name) return domain.ExecutionResult{}, fmt.Errorf("philharmonic logs: %w", err) } // a 2nd stop OR a stop on a task in a terminal state removes the record from the manager - e.stopQuietly(req.Name) + e.stopAndLog(name) result := domain.ExecutionResult{ Output: output, @@ -241,7 +244,7 @@ func (e *PhilharmonicExecutor) logs(ctx context.Context, name string) (string, i return string(body), exitCode, nil } -func (e *PhilharmonicExecutor) stopQuietly(name string) { +func (e *PhilharmonicExecutor) stopAndLog(name string) { if err := e.stop(name); err != nil { log.Printf("philharmonic: cleanup of task %q failed: %v", name, err) } From a0a725ce6c09fbca7b4feec15d06d67d03a215a0 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Tue, 1 Sep 2026 14:58:11 +0300 Subject: [PATCH 23/78] feat(service/philharmonic): added image prewarming fix(service/philharmonic): added back name references --- internal/service/philharmonic.go | 64 ++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 4 deletions(-) diff --git a/internal/service/philharmonic.go b/internal/service/philharmonic.go index f83faf5..96bedfd 100644 --- a/internal/service/philharmonic.go +++ b/internal/service/philharmonic.go @@ -122,14 +122,14 @@ type submitEvent struct { Task submitTask `json:"Task"` } -func (e *PhilharmonicExecutor) submit(ctx context.Context, req domain.ExecutionRequest) error { +func (e *PhilharmonicExecutor) submit(ctx context.Context, name string, msg domain.TaskMessage) error { body, err := json.Marshal(submitEvent{ Task: submitTask{ - Name: req.Name, + Name: name, Image: e.cfg.SandboxImage, Env: []string{ - "TRANSLATOR=" + req.Translator, - "USER_CODE_B64=" + base64.StdEncoding.EncodeToString([]byte(req.Code)), + "TRANSLATOR=" + msg.Translator, + "USER_CODE_B64=" + base64.StdEncoding.EncodeToString([]byte(msg.Code)), }, RestartPolicy: "no", @@ -346,3 +346,59 @@ func unexpectedStatus(resp *http.Response) error { } return statusError(resp.StatusCode, body) } + +// wire shapes of philharmonic manager's POST /images endpoint +type pullImagesRequest struct { + Image string `json:"image"` +} + +type pullImagesReport struct { + Image string `json:"image"` + Results []pullImageResult `json:"results"` +} + +type pullImageResult struct { + Worker string `json:"worker"` + OK bool `json:"ok"` + Pulled bool `json:"pulled"` + Error string `json:"error"` +} + +// asks manager to pull the sandbox image on all workers +func (e *PhilharmonicExecutor) PreWarm(ctx context.Context) error { + body, err := json.Marshal(pullImagesRequest{Image: e.cfg.SandboxImage}) + if err != nil { + return err + } + + resp, err := e.sendRequest(ctx, http.MethodPost, "/images", bytes.NewReader(body)) + if err != nil { + return err + } + defer func() { + if err := resp.Body.Close(); err != nil { + log.Printf("error raised closing resp body: %v\n", err) + } + }() + + // the manager always answers 200 and reports the pull per worker + if resp.StatusCode != http.StatusOK { + snippet, err := readSnippet(resp) + if err != nil { + return fmt.Errorf("reading pre-warm response: %w", err) + } + return statusError(resp.StatusCode, snippet) + } + + var report pullImagesReport + if err := json.NewDecoder(resp.Body).Decode(&report); err != nil { + return fmt.Errorf("decoding pull report: %w", err) + } + + for _, res := range report.Results { + if !res.OK { + return fmt.Errorf("worker %s: %s", res.Worker, res.Error) + } + } + return nil +} From de6c5080d6e929f71e406774493ccd968a5e8758 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Tue, 1 Sep 2026 15:01:45 +0300 Subject: [PATCH 24/78] feat: added migrations --- migrations/001_init.sql | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 migrations/001_init.sql diff --git a/migrations/001_init.sql b/migrations/001_init.sql new file mode 100644 index 0000000..80eb358 --- /dev/null +++ b/migrations/001_init.sql @@ -0,0 +1,23 @@ +-- +migrate Up +CREATE TYPE task_status AS ENUM ('in_progress', 'ready'); + +CREATE TABLE users ( + id uuid PRIMARY KEY, + login text NOT NULL UNIQUE, + password_hash text NOT NULL +); + +CREATE TABLE tasks ( + id uuid PRIMARY KEY, + user_id uuid NOT NULL REFERENCES users (id), + status task_status NOT NULL DEFAULT 'in_progress', + translator text, + result jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + finished_at timestamptz +); + +-- +migrate Down +DROP TABLE tasks; +DROP TABLE users; +DROP TYPE task_status; From 4b3df4bc1404262f39b4b5cc1c5e6087a37baecf Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Tue, 1 Sep 2026 15:10:50 +0300 Subject: [PATCH 25/78] feat(config): added postgres --- internal/config/db.go | 47 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 internal/config/db.go diff --git a/internal/config/db.go b/internal/config/db.go new file mode 100644 index 0000000..0d03d82 --- /dev/null +++ b/internal/config/db.go @@ -0,0 +1,47 @@ +package config + +import ( + "fmt" + "net/url" +) + +type DBConfig struct { + URL string + MigrationsDir string +} + +const ( + envVarDBURL = "DATABASE_URL" + envVarDBMigrations = "MIGRATIONS_DIR" +) + +const ( + defaultDBURL = "postgres://postgres:postgres@localhost:5432/remote_code?sslmode=disable" + defaultDBMigrations = "migrations" +) + +func LoadDBConfig() (DBConfig, error) { + dbCfg := DBConfig{ + URL: envString(envVarDBURL, defaultDBURL), + MigrationsDir: envString(envVarDBMigrations, defaultDBMigrations), + } + + if err := validateDBConfig(dbCfg); err != nil { + return DBConfig{}, err + } + return dbCfg, nil +} + +func validateDBConfig(dbCfg DBConfig) error { + if dbCfg.URL == "" { + return fmt.Errorf("%s must not be empty", envVarDBURL) + } + u, err := url.Parse(dbCfg.URL) + if err != nil || (u.Scheme != "postgres" && u.Scheme != "postgresql") || u.Host == "" { + return fmt.Errorf("%s must be a valid postgres:// URL, got %q", envVarDBURL, dbCfg.URL) + } + if dbCfg.MigrationsDir == "" { + return fmt.Errorf("%s must not be empty", envVarDBMigrations) + } + return nil +} From d9a52f53c1fe7c4dce7e7b152d84bd9d1ba0f598 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Tue, 1 Sep 2026 15:11:34 +0300 Subject: [PATCH 26/78] feat(config): added redis --- internal/config/redis.go | 53 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 internal/config/redis.go diff --git a/internal/config/redis.go b/internal/config/redis.go new file mode 100644 index 0000000..e0e0d40 --- /dev/null +++ b/internal/config/redis.go @@ -0,0 +1,53 @@ +package config + +import ( + "fmt" + "time" +) + +type RedisConfig struct { + Addr string + Password string // empty means no auth + DB int // logical database index + SessionTTL time.Duration +} + +const ( + envVarRedisAddr = "REDIS_ADDR" + envVarRedisPassword = "REDIS_PASSWORD" + envVarRedisDB = "REDIS_DB" + envVarRedisTTL = "SESSION_TTL" +) + +const ( + defaultRedisAddr = "localhost:6379" + defaultRedisDB = 0 + defaultRedisTTL = 7 * 24 * time.Hour +) + +func LoadRedisConfig() (RedisConfig, error) { + redisCfg := RedisConfig{ + Addr: envString(envVarRedisAddr, defaultRedisAddr), + Password: envString(envVarRedisPassword, ""), + DB: envInt(envVarRedisDB, defaultRedisDB), + SessionTTL: envDuration(envVarRedisTTL, defaultRedisTTL), + } + + if err := validateRedisConfig(redisCfg); err != nil { + return RedisConfig{}, err + } + return redisCfg, nil +} + +func validateRedisConfig(redisCfg RedisConfig) error { + if redisCfg.Addr == "" { + return fmt.Errorf("%s must not be empty", envVarRedisAddr) + } + if redisCfg.DB < 0 { + return fmt.Errorf("%s must not be negative, got %d", envVarRedisDB, redisCfg.DB) + } + if redisCfg.SessionTTL <= 0 { + return fmt.Errorf("%s must be positive, got %s", envVarRedisTTL, redisCfg.SessionTTL) + } + return nil +} From 0a81d19458a2f8bc97d3f48505e820504ec67402 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Thu, 3 Sep 2026 01:06:17 +0300 Subject: [PATCH 27/78] feat(repository): added postgres package - repository struct and opening methods --- internal/repository/postgres/repository.go | 89 ++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 internal/repository/postgres/repository.go diff --git a/internal/repository/postgres/repository.go b/internal/repository/postgres/repository.go new file mode 100644 index 0000000..087ca38 --- /dev/null +++ b/internal/repository/postgres/repository.go @@ -0,0 +1,89 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/belyaevedu/remote-code-service/internal/config" + "github.com/belyaevedu/remote-code-service/internal/domain" + "github.com/belyaevedu/remote-code-service/internal/port" +) + +const ( + pgUniqueViolation = "23505" // login already taken +) + +// stores users and tasks +type Repository struct { + pool *pgxpool.Pool +} + +var ( + _ port.TaskRepository = (*Repository)(nil) + _ port.UserRepository = (*Repository)(nil) +) + +func New(ctx context.Context, cfg config.DBConfig) (*Repository, error) { + pool, err := pgxpool.New(ctx, cfg.URL) + if err != nil { + return nil, fmt.Errorf("postgres pool: %w", err) + } + + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := pool.Ping(pingCtx); err != nil { + pool.Close() + return nil, fmt.Errorf("postgres ping: %w", err) + } + + return &Repository{pool: pool}, nil +} + +func (r *Repository) Close() { + r.pool.Close() +} + +func mapPgError(err error) error { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == pgUniqueViolation { + return domain.ErrUserAlreadyExists + } + return err +} + +const ( + schemaWaitTimeout = time.Minute + schemaWaitInterval = 500 * time.Millisecond +) + +// blocks until the schema applied by the server's migrations is visible. +// an error is returned once schemaWaitTimeout lapses +func (r *Repository) WaitReady(ctx context.Context) error { + deadline := time.Now().Add(schemaWaitTimeout) + + for { + var tasksPresent bool + err := r.pool.QueryRow(ctx, + `SELECT to_regclass('public.tasks') IS NOT NULL`, + ).Scan(&tasksPresent) + + if err == nil && tasksPresent { + return nil + } + + if time.Now().After(deadline) { + return fmt.Errorf("schema not ready after %s: has the server migrated?", schemaWaitTimeout) + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(schemaWaitInterval): + } + } +} From 551156b2b6a4688b0dd6d47e29d0d885bf816653 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Thu, 3 Sep 2026 01:07:05 +0300 Subject: [PATCH 28/78] feat(repository/postgres): added Migrate function --- internal/repository/postgres/migrate.go | 37 +++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 internal/repository/postgres/migrate.go diff --git a/internal/repository/postgres/migrate.go b/internal/repository/postgres/migrate.go new file mode 100644 index 0000000..ebc82b9 --- /dev/null +++ b/internal/repository/postgres/migrate.go @@ -0,0 +1,37 @@ +package postgres + +import ( + "context" + "database/sql" + "fmt" + "log" + + _ "github.com/jackc/pgx/v5/stdlib" + migrate "github.com/rubenv/sql-migrate" + + "github.com/belyaevedu/remote-code-service/internal/config" +) + +func Migrate(ctx context.Context, cfg config.DBConfig) error { + db, err := sql.Open("pgx", cfg.URL) + if err != nil { + return fmt.Errorf("migrate open: %w", err) + } + defer func() { + if err := db.Close(); err != nil { + log.Printf("error raised closing migration db: %v\n", err) + } + }() + + source := &migrate.FileMigrationSource{Dir: cfg.MigrationsDir} + + applied, err := migrate.ExecContext(ctx, db, "postgres", source, migrate.Up) + if err != nil { + return fmt.Errorf("migrate up: %w", err) + } + + if applied > 0 { + log.Printf("applied %d migration(s) from %s", applied, cfg.MigrationsDir) + } + return nil +} From 871ce4e20cb24e893a04efc21556c9da6eb72af4 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Thu, 3 Sep 2026 01:19:45 +0300 Subject: [PATCH 29/78] fix(migrations): added a NOT NULL constraint to tasks' translator field --- migrations/001_init.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/001_init.sql b/migrations/001_init.sql index 80eb358..e9e39e4 100644 --- a/migrations/001_init.sql +++ b/migrations/001_init.sql @@ -11,7 +11,7 @@ CREATE TABLE tasks ( id uuid PRIMARY KEY, user_id uuid NOT NULL REFERENCES users (id), status task_status NOT NULL DEFAULT 'in_progress', - translator text, + translator text NOT NULL, result jsonb, created_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz From cfa95c3e539e808391e510bd06c94eee8f51bf55 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Thu, 3 Sep 2026 01:33:47 +0300 Subject: [PATCH 30/78] feat(domain/task): added translator field to task, submission struct --- internal/domain/task.go | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/internal/domain/task.go b/internal/domain/task.go index 31c0620..76ad047 100644 --- a/internal/domain/task.go +++ b/internal/domain/task.go @@ -9,6 +9,7 @@ var ( ErrAccessDenied = errors.New("access denied") ErrUnsupportedTranslator = errors.New("unsupported translator") ErrExecutionTimeout = errors.New("execution timed out") + ErrInvalidSubmission = errors.New("invalid submission") ) type TaskStatus string @@ -23,10 +24,17 @@ type Result struct { } type Task struct { - ID string - UserID string - Status TaskStatus - Result *Result + ID string + UserID string + Status TaskStatus + Translator string + Result *Result +} + +// pre-creation api payload +type Submission struct { + Translator string + Code string } type TaskMessage struct { From 73315cd6c0dd425b91204d55694eec55523a1f5c Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Thu, 3 Sep 2026 01:21:08 +0300 Subject: [PATCH 31/78] feat(repository/postgres): implemented port.taskrepository --- internal/repository/postgres/task.go | 91 ++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 internal/repository/postgres/task.go diff --git a/internal/repository/postgres/task.go b/internal/repository/postgres/task.go new file mode 100644 index 0000000..72367f4 --- /dev/null +++ b/internal/repository/postgres/task.go @@ -0,0 +1,91 @@ +package postgres + +import ( + "context" + "encoding/json" + "errors" + + "github.com/jackc/pgx/v5" + + "github.com/belyaevedu/remote-code-service/internal/domain" +) + +func (r *Repository) Save(task *domain.Task) error { + _, err := r.pool.Exec(context.Background(), + `INSERT INTO tasks (id, user_id, status, translator) VALUES ($1, $2, $3, $4)`, + task.ID, task.UserID, task.Status, task.Translator, + ) + return err +} + +func (r *Repository) Get(id string) (*domain.Task, error) { + var ( + userID string + status domain.TaskStatus + translator string + result []byte + ) + + err := r.pool.QueryRow(context.Background(), + `SELECT user_id, status, translator, result FROM tasks WHERE id = $1`, id, + ).Scan(&userID, &status, &translator, &result) + if errors.Is(err, pgx.ErrNoRows) { + return nil, domain.ErrTaskNotFound + } + if err != nil { + return nil, err + } + + task := &domain.Task{ + ID: id, + UserID: userID, + Status: status, + Translator: translator, + } + if result != nil { + var res domain.Result + if err := json.Unmarshal(result, &res); err != nil { + return nil, err + } + task.Result = &res + } + return task, nil +} + +func (r *Repository) UpdateStatus(id string, status domain.TaskStatus) error { + tag, err := r.pool.Exec(context.Background(), + `UPDATE tasks SET status = $1 WHERE id = $2`, status, id, + ) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return domain.ErrTaskNotFound + } + return nil +} + +func (r *Repository) SaveResult(id string, result *domain.Result) error { + if result == nil { + result = &domain.Result{} + } + + data, err := json.Marshal(result) + if err != nil { + return err + } + + tag, err := r.pool.Exec(context.Background(), + `UPDATE tasks + SET status = 'ready', result = $1, finished_at = now() + WHERE id = $2`, + data, id, + ) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return domain.ErrTaskNotFound + } + return nil +} From f0c0a65455fcf7f2209ae303b406a1daa095db4c Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Thu, 3 Sep 2026 01:28:21 +0300 Subject: [PATCH 32/78] feat(repository/postgres): implemented port.userrepository --- internal/repository/postgres/user.go | 49 ++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 internal/repository/postgres/user.go diff --git a/internal/repository/postgres/user.go b/internal/repository/postgres/user.go new file mode 100644 index 0000000..338463a --- /dev/null +++ b/internal/repository/postgres/user.go @@ -0,0 +1,49 @@ +package postgres + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + + "github.com/belyaevedu/remote-code-service/internal/domain" +) + +func (r *Repository) SaveUser(user *domain.User) error { + // using the UNIQUE constraint on login + _, err := r.pool.Exec(context.Background(), + `INSERT INTO users (id, login, password_hash) VALUES ($1, $2, $3)`, + user.ID, user.Login, user.Password, + ) + return mapPgError(err) +} + +func (r *Repository) GetUserByID(id string) (*domain.User, error) { + return r.getUser("SELECT id, login, password_hash FROM users WHERE id = $1", id) +} + +func (r *Repository) GetUserByLogin(login string) (*domain.User, error) { + return r.getUser("SELECT id, login, password_hash FROM users WHERE login = $1", login) +} + +func (r *Repository) getUser(query, arg string) (*domain.User, error) { + var ( + id string + found string + password string + ) + + err := r.pool.QueryRow(context.Background(), query, arg).Scan(&id, &found, &password) + if errors.Is(err, pgx.ErrNoRows) { + return nil, domain.ErrUserNotFound + } + if err != nil { + return nil, err + } + + return &domain.User{ + ID: id, + Login: found, + Password: password, + }, nil +} From ba883995d0f9676b82274586d0e930b5e001ed42 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Thu, 3 Sep 2026 01:28:42 +0300 Subject: [PATCH 33/78] fix(repository): removed ram storage package --- internal/repository/storage/repository.go | 31 ------------ internal/repository/storage/session.go | 41 ---------------- internal/repository/storage/task.go | 57 ----------------------- internal/repository/storage/user.go | 46 ------------------ 4 files changed, 175 deletions(-) delete mode 100644 internal/repository/storage/repository.go delete mode 100644 internal/repository/storage/session.go delete mode 100644 internal/repository/storage/task.go delete mode 100644 internal/repository/storage/user.go diff --git a/internal/repository/storage/repository.go b/internal/repository/storage/repository.go deleted file mode 100644 index 1bb759c..0000000 --- a/internal/repository/storage/repository.go +++ /dev/null @@ -1,31 +0,0 @@ -package storage - -import ( - "sync" - - "github.com/belyaevedu/remote-code-service/internal/domain" - "github.com/belyaevedu/remote-code-service/internal/port" -) - -type Repository struct { - mu sync.RWMutex - - tasks map[string]*domain.Task - users map[string]*domain.User // key - login - sessions map[string]*domain.Session // key - session id -} - -// compile-time asserts -var ( - _ port.TaskRepository = (*Repository)(nil) - _ port.UserRepository = (*Repository)(nil) - _ port.SessionRepository = (*Repository)(nil) -) - -func New() *Repository { - return &Repository{ - tasks: make(map[string]*domain.Task), - users: make(map[string]*domain.User), - sessions: make(map[string]*domain.Session), - } -} diff --git a/internal/repository/storage/session.go b/internal/repository/storage/session.go deleted file mode 100644 index eb77d51..0000000 --- a/internal/repository/storage/session.go +++ /dev/null @@ -1,41 +0,0 @@ -package storage - -import ( - "github.com/belyaevedu/remote-code-service/internal/domain" -) - -func (r *Repository) CreateSession(session *domain.Session) error { - r.mu.Lock() - defer r.mu.Unlock() - - clone := *session - r.sessions[clone.SessionID] = &clone - - return nil -} - -func (r *Repository) GetSession(sessionID string) (*domain.Session, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - s, ok := r.sessions[sessionID] - if !ok { - return nil, domain.ErrSessionNotFound - } - - clone := *s - return &clone, nil -} - -func (r *Repository) DeleteSession(sessionID string) error { - r.mu.Lock() - defer r.mu.Unlock() - - if _, ok := r.sessions[sessionID]; !ok { - return domain.ErrSessionNotFound - } - - delete(r.sessions, sessionID) - - return nil -} diff --git a/internal/repository/storage/task.go b/internal/repository/storage/task.go deleted file mode 100644 index 07bfc2b..0000000 --- a/internal/repository/storage/task.go +++ /dev/null @@ -1,57 +0,0 @@ -package storage - -import ( - "github.com/belyaevedu/remote-code-service/internal/domain" -) - -func (r *Repository) Save(task *domain.Task) error { - r.mu.Lock() - defer r.mu.Unlock() - - r.tasks[task.ID] = task - - return nil -} - -func (r *Repository) Get(id string) (*domain.Task, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - t, ok := r.tasks[id] - if !ok { - return nil, domain.ErrTaskNotFound - } - - // returning a clone so the caller sees an unchanging snapshot - clone := *t - return &clone, nil -} - -func (r *Repository) UpdateStatus(id string, status domain.TaskStatus) error { - r.mu.Lock() - defer r.mu.Unlock() - - t, ok := r.tasks[id] - if !ok { - return domain.ErrTaskNotFound - } - - t.Status = status - - return nil -} - -func (r *Repository) SaveResult(id string, result *domain.Result) error { - r.mu.Lock() - defer r.mu.Unlock() - - t, ok := r.tasks[id] - if !ok { - return domain.ErrTaskNotFound - } - - t.Status = domain.StatusReady - t.Result = result - - return nil -} diff --git a/internal/repository/storage/user.go b/internal/repository/storage/user.go deleted file mode 100644 index 1d5afdb..0000000 --- a/internal/repository/storage/user.go +++ /dev/null @@ -1,46 +0,0 @@ -package storage - -import ( - "github.com/belyaevedu/remote-code-service/internal/domain" -) - -func (r *Repository) SaveUser(user *domain.User) error { - r.mu.Lock() - defer r.mu.Unlock() - - if _, ok := r.users[user.Login]; ok { - return domain.ErrUserAlreadyExists - } - - clone := *user - r.users[clone.Login] = &clone - - return nil -} - -func (r *Repository) GetUserByLogin(login string) (*domain.User, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - u, ok := r.users[login] - if !ok { - return nil, domain.ErrUserNotFound - } - - clone := *u - return &clone, nil -} - -func (r *Repository) GetUserByID(id string) (*domain.User, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - for _, u := range r.users { - if u.ID == id { - clone := *u - return &clone, nil - } - } - - return nil, domain.ErrUserNotFound -} From 896facdddc16a7c0e4c329b042613957d893d27a Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Thu, 3 Sep 2026 01:34:39 +0300 Subject: [PATCH 34/78] style: updated taskrepository method names to be userrepository-alike --- internal/port/port.go | 8 ++++---- internal/repository/postgres/task.go | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/port/port.go b/internal/port/port.go index b33b7e1..9f1016c 100644 --- a/internal/port/port.go +++ b/internal/port/port.go @@ -7,10 +7,10 @@ import ( ) type TaskRepository interface { - Save(task *domain.Task) error - Get(id string) (*domain.Task, error) - UpdateStatus(id string, status domain.TaskStatus) error - SaveResult(id string, result *domain.Result) error + SaveTask(task *domain.Task) error + GetTask(id string) (*domain.Task, error) + UpdateTaskStatus(id string, status domain.TaskStatus) error + SaveTaskResult(id string, result *domain.Result) error } type TaskService interface { diff --git a/internal/repository/postgres/task.go b/internal/repository/postgres/task.go index 72367f4..ea4842b 100644 --- a/internal/repository/postgres/task.go +++ b/internal/repository/postgres/task.go @@ -10,7 +10,7 @@ import ( "github.com/belyaevedu/remote-code-service/internal/domain" ) -func (r *Repository) Save(task *domain.Task) error { +func (r *Repository) SaveTask(task *domain.Task) error { _, err := r.pool.Exec(context.Background(), `INSERT INTO tasks (id, user_id, status, translator) VALUES ($1, $2, $3, $4)`, task.ID, task.UserID, task.Status, task.Translator, @@ -18,7 +18,7 @@ func (r *Repository) Save(task *domain.Task) error { return err } -func (r *Repository) Get(id string) (*domain.Task, error) { +func (r *Repository) GetTask(id string) (*domain.Task, error) { var ( userID string status domain.TaskStatus @@ -52,7 +52,7 @@ func (r *Repository) Get(id string) (*domain.Task, error) { return task, nil } -func (r *Repository) UpdateStatus(id string, status domain.TaskStatus) error { +func (r *Repository) UpdateTaskStatus(id string, status domain.TaskStatus) error { tag, err := r.pool.Exec(context.Background(), `UPDATE tasks SET status = $1 WHERE id = $2`, status, id, ) @@ -65,7 +65,7 @@ func (r *Repository) UpdateStatus(id string, status domain.TaskStatus) error { return nil } -func (r *Repository) SaveResult(id string, result *domain.Result) error { +func (r *Repository) SaveTaskResult(id string, result *domain.Result) error { if result == nil { result = &domain.Result{} } From 753c0856f651ee480dc9a17febf40338e75c29c9 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Thu, 3 Sep 2026 01:41:15 +0300 Subject: [PATCH 35/78] feat(repository): added redis --- internal/repository/redis/repository.go | 83 +++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 internal/repository/redis/repository.go diff --git a/internal/repository/redis/repository.go b/internal/repository/redis/repository.go new file mode 100644 index 0000000..1f1ba04 --- /dev/null +++ b/internal/repository/redis/repository.go @@ -0,0 +1,83 @@ +package redis + +import ( + "context" + "errors" + "fmt" + "log" + "time" + + "github.com/redis/go-redis/v9" + + "github.com/belyaevedu/remote-code-service/internal/config" + "github.com/belyaevedu/remote-code-service/internal/domain" + "github.com/belyaevedu/remote-code-service/internal/port" +) + +const sessionKeyPrefix = "session:" + +type Repository struct { + client *redis.Client + ttl time.Duration +} + +var _ port.SessionRepository = (*Repository)(nil) + +func New(ctx context.Context, cfg config.RedisConfig) (*Repository, error) { + client := redis.NewClient(&redis.Options{ + Addr: cfg.Addr, + Password: cfg.Password, + DB: cfg.DB, + }) + + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := client.Ping(pingCtx).Err(); err != nil { + if err := client.Close(); err != nil { + log.Printf("error raised closing redis client: %v\n", err) + } + return nil, fmt.Errorf("redis ping: %w", err) + } + + return &Repository{client: client, ttl: cfg.SessionTTL}, nil +} + +func (r *Repository) Close() error { + return r.client.Close() +} + +func (r *Repository) CreateSession(session *domain.Session) error { + return r.client.Set(context.Background(), + sessionKeyPrefix+session.SessionID, session.UserID, r.ttl, + ).Err() +} + +func (r *Repository) GetSession(sessionID string) (*domain.Session, error) { + userID, err := r.client.Get(context.Background(), + sessionKeyPrefix+sessionID, + ).Result() + if errors.Is(err, redis.Nil) { + return nil, domain.ErrSessionNotFound + } + if err != nil { + return nil, err + } + + return &domain.Session{ + UserID: userID, + SessionID: sessionID, + }, nil +} + +func (r *Repository) DeleteSession(sessionID string) error { + removed, err := r.client.Del(context.Background(), + sessionKeyPrefix+sessionID, + ).Result() + if err != nil { + return err + } + if removed == 0 { + return domain.ErrSessionNotFound + } + return nil +} From fa97289429e7ac584328fd09bd8ed5d45dec69af Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Thu, 3 Sep 2026 01:49:51 +0300 Subject: [PATCH 36/78] fix(service/task): using publisher, adhering to the new fields --- internal/service/task.go | 66 ++++++++++++++++++---------------------- 1 file changed, 29 insertions(+), 37 deletions(-) diff --git a/internal/service/task.go b/internal/service/task.go index e0fe583..b648dc1 100644 --- a/internal/service/task.go +++ b/internal/service/task.go @@ -1,8 +1,8 @@ package service import ( - "log" - "time" + "context" + "fmt" "github.com/belyaevedu/remote-code-service/internal/domain" "github.com/belyaevedu/remote-code-service/internal/port" @@ -10,47 +10,53 @@ import ( "github.com/google/uuid" ) -const ( - outputMessage = "puk" -) - type TaskService struct { - repo port.TaskRepository - processingTime time.Duration + repo port.TaskRepository + publisher port.TaskPublisher } -// compile-time assert that task's TaskService struct -// implements port's TaskService interface var _ port.TaskService = (*TaskService)(nil) -func NewTaskService(repo port.TaskRepository, processingTime time.Duration) *TaskService { - if processingTime <= 0 { - processingTime = 2 * time.Second - } +func NewTaskService(repo port.TaskRepository, publisher port.TaskPublisher) *TaskService { return &TaskService{ - repo: repo, - processingTime: processingTime, + repo: repo, + publisher: publisher, } } -func (s *TaskService) Submit(userID string) (string, error) { +func (s *TaskService) Submit(ctx context.Context, userID string, sub domain.Submission) (string, error) { if userID == "" { return "", domain.ErrAccessDenied } + if _, ok := supportedTranslators[sub.Translator]; !ok { + return "", fmt.Errorf("%w: %q", domain.ErrUnsupportedTranslator, sub.Translator) + } + if sub.Code == "" { + return "", fmt.Errorf("%w: empty code", domain.ErrInvalidSubmission) + } id := uuid.NewString() task := &domain.Task{ - ID: id, - UserID: userID, - Status: domain.StatusInProgress, + ID: id, + UserID: userID, + Status: domain.StatusInProgress, + Translator: sub.Translator, } - if err := s.repo.Save(task); err != nil { + if err := s.repo.SaveTask(task); err != nil { return "", err } - go s.process(id) + msg := domain.TaskMessage{ + TaskID: id, + Translator: sub.Translator, + Code: sub.Code, + } + if err := s.publisher.Publish(ctx, msg); err != nil { + // the row stays behind as in_progress evidence of the failure + return "", fmt.Errorf("queue publish: %w", err) + } return id, nil } @@ -81,7 +87,7 @@ func (s *TaskService) getOwnedTask(userID, id string) (*domain.Task, error) { return nil, domain.ErrAccessDenied } - t, err := s.repo.Get(id) + t, err := s.repo.GetTask(id) if err != nil { return nil, err } @@ -92,17 +98,3 @@ func (s *TaskService) getOwnedTask(userID, id string) (*domain.Task, error) { return t, nil } - -func (s *TaskService) process(id string) { - time.Sleep(s.processingTime) - - result := &domain.Result{ - Output: outputMessage, - } - - if err := s.repo.SaveResult(id, result); err != nil { - log.Printf("Failed to save task result %s: %v\n", id, err) - return - } - log.Printf("Task finished: %s\n", id) -} From 453deb60a46501357704d3b7605f85f32bffd75f Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Thu, 3 Sep 2026 02:09:52 +0300 Subject: [PATCH 37/78] feat(controller/handlers/task): updated Create handler to submit tasks --- internal/controller/handlers/task.go | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/internal/controller/handlers/task.go b/internal/controller/handlers/task.go index fe35762..afb1521 100644 --- a/internal/controller/handlers/task.go +++ b/internal/controller/handlers/task.go @@ -1,6 +1,7 @@ package handlers import ( + "encoding/json" "errors" "net/http" @@ -23,6 +24,11 @@ func NewTaskHandlers(taskSvc port.TaskService) *TaskHandlers { } // POST /task +type taskCreateRequest struct { + Translator string `json:"translator"` + Code string `json:"code"` +} + type taskCreateResponse struct { TaskID string `json:"task_id"` } @@ -38,10 +44,11 @@ type taskResultResponse struct { } // @Summary Create a task -// @Description Creating a task owned by an authenticated user +// @Description Creating a task owned by an authenticated user and queuing it for execution // @Tags task // @Accept json // @Produce json +// @Param request body taskCreateRequest true "task submission" // @Success 201 {object} taskCreateResponse // @Failure 400 {object} ErrorResponse // @Failure 401 {object} ErrorResponse @@ -55,8 +62,23 @@ func (h *TaskHandlers) Create(w http.ResponseWriter, r *http.Request) { return } - id, err := h.taskSvc.Submit(userID) + var req taskCreateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + WriteJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid request body"}) + return + } + + submission := domain.Submission{ + Translator: req.Translator, + Code: req.Code, + } + + id, err := h.taskSvc.Submit(r.Context(), userID, submission) if err != nil { + if errors.Is(err, domain.ErrUnsupportedTranslator) || errors.Is(err, domain.ErrInvalidSubmission) { + WriteJSON(w, http.StatusBadRequest, ErrorResponse{Error: err.Error()}) + return + } WriteJSON(w, http.StatusInternalServerError, ErrorResponse{Error: err.Error()}) return } From 36773f929d96ce4c18260713814c7a2ac2039ee9 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Thu, 3 Sep 2026 02:21:32 +0300 Subject: [PATCH 38/78] fix(cmd/server): updated server --- cmd/server/main.go | 59 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 5cb1b96..d889cdc 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -9,7 +9,9 @@ import ( "github.com/belyaevedu/remote-code-service/internal/config" "github.com/belyaevedu/remote-code-service/internal/controller" "github.com/belyaevedu/remote-code-service/internal/controller/handlers" - "github.com/belyaevedu/remote-code-service/internal/repository/storage" + "github.com/belyaevedu/remote-code-service/internal/repository/postgres" + "github.com/belyaevedu/remote-code-service/internal/repository/queue" + "github.com/belyaevedu/remote-code-service/internal/repository/redis" "github.com/belyaevedu/remote-code-service/internal/service" ) @@ -29,24 +31,61 @@ import ( // // @securitydefinitions.bearerauth BearerAuth func main() { - cfg, err := config.LoadAppConfig() + appCfg, err := config.LoadAppConfig() if err != nil { - log.Fatalf("invalid config: %v", err) + log.Fatalf("invalid app config: %v", err) } + dbCfg, err := config.LoadDBConfig() + if err != nil { + log.Fatalf("invalid db config: %v", err) + } + redisCfg, err := config.LoadRedisConfig() + if err != nil { + log.Fatalf("invalid redis config: %v", err) + } + queueCfg, err := config.LoadQueueConfig() + if err != nil { + log.Fatalf("invalid queue config: %v", err) + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + if err := postgres.Migrate(ctx, dbCfg); err != nil { + log.Fatalf("migrations: %v", err) + } + + db, err := postgres.New(ctx, dbCfg) + if err != nil { + log.Fatalf("postgres: %v", err) + } + defer db.Close() + + sessions, err := redis.New(ctx, redisCfg) + if err != nil { + log.Fatalf("redis: %v", err) + } + defer func() { + if err := sessions.Close(); err != nil { + log.Printf("closing redis: %v", err) + } + }() - repo := storage.New() + publisher := queue.NewPublisher(queueCfg) + defer func() { + if err := publisher.Close(); err != nil { + log.Printf("closing queue publisher: %v", err) + } + }() - taskService := service.NewTaskService(repo, cfg.ProcessingTime) - userService := service.NewUserService(repo, repo) + taskService := service.NewTaskService(db, publisher) + userService := service.NewUserService(db, sessions) taskHandler := handlers.NewTaskHandlers(taskService) userHandler := handlers.NewUserHandlers(userService) router := controller.NewRouter(taskHandler, userHandler, userService) - server := controller.NewApi(cfg.HTTPAddr, router, cfg.ShutdownTimeout) - - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stop() + server := controller.NewApi(appCfg.HTTPAddr, router, appCfg.ShutdownTimeout) if err := server.Start(ctx); err != nil { log.Fatalf("server exited with error: %v", err) From db51867e7f1bf3fc0f454a08a4be71b94e28657c Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Thu, 3 Sep 2026 02:21:43 +0300 Subject: [PATCH 39/78] feat(cmd): added processor --- cmd/processor/main.go | 72 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 cmd/processor/main.go diff --git a/cmd/processor/main.go b/cmd/processor/main.go new file mode 100644 index 0000000..f910106 --- /dev/null +++ b/cmd/processor/main.go @@ -0,0 +1,72 @@ +package main + +import ( + "context" + "fmt" + "log" + "os/signal" + "syscall" + "time" + + "github.com/belyaevedu/remote-code-service/internal/config" + "github.com/belyaevedu/remote-code-service/internal/domain" + "github.com/belyaevedu/remote-code-service/internal/repository/postgres" + "github.com/belyaevedu/remote-code-service/internal/repository/queue" + "github.com/belyaevedu/remote-code-service/internal/service" +) + +func main() { + dbCfg, err := config.LoadDBConfig() + if err != nil { + log.Fatalf("invalid db config: %v", err) + } + queueCfg, err := config.LoadQueueConfig() + if err != nil { + log.Fatalf("invalid queue config: %v", err) + } + philCfg, err := config.LoadPhilharmonicConfig() + if err != nil { + log.Fatalf("invalid philharmonic config: %v", err) + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + db, err := postgres.New(ctx, dbCfg) + if err != nil { + log.Fatalf("postgres: %v", err) + } + defer db.Close() + + if err := db.WaitReady(ctx); err != nil { + log.Fatalf("waiting for schema: %v", err) + } + + executor := service.NewPhilharmonicExecutor(philCfg) + + // pull the sandbox image on every worker before the queue opens + prewarmCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) + if err := executor.PreWarm(prewarmCtx); err != nil { + log.Printf("processor: sandbox pre-warm failed: %v (continuing)", err) + } else { + log.Printf("processor: sandbox image %q pre-warmed on workers", philCfg.SandboxImage) + } + cancel() + + consumer := queue.NewConsumer(queueCfg) + + handle := func(ctx context.Context, msg domain.TaskMessage) error { + result, err := executor.Execute(ctx, msg) + if err != nil { + return fmt.Errorf("execute task %s: %w", msg.TaskID, err) + } + + return db.SaveTaskResult(msg.TaskID, &domain.Result{Output: result.Output}) + } + + log.Printf("processor: consuming queue %q", queueCfg.Queue) + if err := consumer.Consume(ctx, handle); err != nil { + log.Fatalf("consumer exited with error: %v", err) + } + log.Printf("processor stopped gracefully") +} From a61d4bc568accd3f3816565abd948c02f3acba21 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Thu, 3 Sep 2026 02:22:03 +0300 Subject: [PATCH 40/78] fix(config/app): removed unnecessary fields --- internal/config/app.go | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/internal/config/app.go b/internal/config/app.go index d8e7e16..7cc648c 100644 --- a/internal/config/app.go +++ b/internal/config/app.go @@ -7,34 +7,27 @@ import ( type AppConfig struct { HTTPAddr string - ProcessingTime time.Duration ShutdownTimeout time.Duration } const ( envVarAppAddress = "HTTP_ADDR" - envVarAppProcessingTime = "TASK_PROCESSING_TIME" envVarAppShutdownTimeout = "SHUTDOWN_TIMEOUT" ) const ( defaultAppAddress = ":8000" - defaultAppProcessingTime = 2 * time.Second defaultAppShutdownTimeout = 10 * time.Second ) func LoadAppConfig() (AppConfig, error) { appCfg := AppConfig{ HTTPAddr: envString(envVarAppAddress, defaultAppAddress), - ProcessingTime: envDuration(envVarAppProcessingTime, defaultAppProcessingTime), ShutdownTimeout: envDuration(envVarAppShutdownTimeout, defaultAppShutdownTimeout), } - if appCfg.ProcessingTime <= 0 { - return AppConfig{}, fmt.Errorf("TASK_PROCESSING_TIME must be positive, got %s", appCfg.ProcessingTime) - } if appCfg.ShutdownTimeout <= 0 { - return AppConfig{}, fmt.Errorf("SHUTDOWN_TIMEOUT must be positive, got %s", appCfg.ShutdownTimeout) + return AppConfig{}, fmt.Errorf("%s must be positive, got %s", envVarAppShutdownTimeout, appCfg.ShutdownTimeout) } return appCfg, nil } From 094508381851b9c521ecea0a0b7ad6fa7a01d604 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Thu, 3 Sep 2026 02:23:32 +0300 Subject: [PATCH 41/78] fix(cmd/processor): reworded comment --- cmd/processor/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/processor/main.go b/cmd/processor/main.go index f910106..035983b 100644 --- a/cmd/processor/main.go +++ b/cmd/processor/main.go @@ -44,7 +44,7 @@ func main() { executor := service.NewPhilharmonicExecutor(philCfg) - // pull the sandbox image on every worker before the queue opens + // pulling the sandbox image on every worker before starting to process prewarmCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) if err := executor.PreWarm(prewarmCtx); err != nil { log.Printf("processor: sandbox pre-warm failed: %v (continuing)", err) From e4248f908ed4b89c65faf276a340a337594cc1a8 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Thu, 3 Sep 2026 02:25:35 +0300 Subject: [PATCH 42/78] docs(swagger): regenerated --- docs/docs.go | 28 +++++++++++++++++++++++++--- docs/swagger.json | 28 +++++++++++++++++++++++++--- docs/swagger.yaml | 18 ++++++++++++++++-- 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index 5caea76..d8df8c5 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -51,6 +51,17 @@ const docTemplate = `{ }, "type": "object" }, + "handlers.taskCreateRequest": { + "properties": { + "code": { + "type": "string" + }, + "translator": { + "type": "string" + } + }, + "type": "object" + }, "handlers.taskCreateResponse": { "properties": { "task_id": { @@ -396,15 +407,26 @@ const docTemplate = `{ }, "/task": { "post": { - "description": "Creating a task owned by an authenticated user", + "description": "Creating a task owned by an authenticated user and queuing it for execution", "requestBody": { "content": { "application/json": { "schema": { - "type": "object" + "oneOf": [ + { + "type": "object" + }, + { + "$ref": "#/components/schemas/handlers.taskCreateRequest", + "summary": "request", + "description": "task submission" + } + ] } } - } + }, + "description": "task submission", + "required": true }, "responses": { "201": { diff --git a/docs/swagger.json b/docs/swagger.json index 6bf7472..1c586cf 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -44,6 +44,17 @@ }, "type": "object" }, + "handlers.taskCreateRequest": { + "properties": { + "code": { + "type": "string" + }, + "translator": { + "type": "string" + } + }, + "type": "object" + }, "handlers.taskCreateResponse": { "properties": { "task_id": { @@ -389,15 +400,26 @@ }, "/task": { "post": { - "description": "Creating a task owned by an authenticated user", + "description": "Creating a task owned by an authenticated user and queuing it for execution", "requestBody": { "content": { "application/json": { "schema": { - "type": "object" + "oneOf": [ + { + "type": "object" + }, + { + "$ref": "#/components/schemas/handlers.taskCreateRequest", + "summary": "request", + "description": "task submission" + } + ] } } - } + }, + "description": "task submission", + "required": true }, "responses": { "201": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 57280b2..f2c634a 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -27,6 +27,13 @@ components: message: type: string type: object + handlers.taskCreateRequest: + properties: + code: + type: string + translator: + type: string + type: object handlers.taskCreateResponse: properties: task_id: @@ -238,12 +245,19 @@ paths: - task /task: post: - description: Creating a task owned by an authenticated user + description: Creating a task owned by an authenticated user and queuing it for + execution requestBody: content: application/json: schema: - type: object + oneOf: + - type: object + - $ref: '#/components/schemas/handlers.taskCreateRequest' + description: task submission + summary: request + description: task submission + required: true responses: "201": content: From 32a5ef42a110dbf64b495df34b7b4d96218938a9 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Fri, 4 Sep 2026 12:10:55 +0300 Subject: [PATCH 43/78] build(dockerfile): added migrations to main dockerfile, separate dockerfile for processor --- Dockerfile | 3 ++- Dockerfile.processor | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 Dockerfile.processor diff --git a/Dockerfile b/Dockerfile index 76d3f24..8acdbc9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,9 +14,10 @@ RUN CGO_ENABLED=0 GOOS=linux \ FROM gcr.io/distroless/static-debian12:nonroot AS runtime -WORKDIR / +WORKDIR /app COPY --from=builder /out/server /app/server +COPY --from=builder /src/migrations /app/migrations EXPOSE 8000 diff --git a/Dockerfile.processor b/Dockerfile.processor new file mode 100644 index 0000000..30cc2d9 --- /dev/null +++ b/Dockerfile.processor @@ -0,0 +1,21 @@ +FROM golang:1.26-alpine AS builder + +WORKDIR /src + +RUN apk add --no-cache git ca-certificates + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux \ + go build -trimpath -ldflags="-s -w" -o /out/processor ./cmd/processor + +FROM gcr.io/distroless/static-debian12:nonroot AS runtime + +WORKDIR /app + +COPY --from=builder /out/processor /app/processor + +ENTRYPOINT ["/app/processor"] From 57c0012047401ebdc4552567a5dffa34e90a604a Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Fri, 4 Sep 2026 12:22:50 +0300 Subject: [PATCH 44/78] build(dockerfile): base image and dep version pinning --- Dockerfile | 6 +++--- Dockerfile.processor | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8acdbc9..b9748d4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ -FROM golang:1.26-alpine AS builder +FROM golang:1.26-alpine@sha256:ce864e7223ac17b1775e6fd0b4c0db580c2eb50e7953a427916379e4b92a1628 AS builder WORKDIR /src -RUN apk add --no-cache git ca-certificates +RUN apk add --no-cache git~=2.54.0-r0 ca-certificates~=20260611-r0 COPY go.mod go.sum ./ RUN go mod download @@ -12,7 +12,7 @@ COPY . . RUN CGO_ENABLED=0 GOOS=linux \ go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server -FROM gcr.io/distroless/static-debian12:nonroot AS runtime +FROM gcr.io/distroless/static-debian12:nonroot@sha256:afa5c872c891853ca7fcf1f12c3edb23f7eeef36189728842dd51042ff57f7ab AS runtime WORKDIR /app diff --git a/Dockerfile.processor b/Dockerfile.processor index 30cc2d9..d7aab88 100644 --- a/Dockerfile.processor +++ b/Dockerfile.processor @@ -1,8 +1,8 @@ -FROM golang:1.26-alpine AS builder +FROM golang:1.26-alpine@sha256:ce864e7223ac17b1775e6fd0b4c0db580c2eb50e7953a427916379e4b92a1628 AS builder WORKDIR /src -RUN apk add --no-cache git ca-certificates +RUN apk add --no-cache git~=2.54.0-r0 ca-certificates~=20260611-r0 COPY go.mod go.sum ./ RUN go mod download From c530aae2b1333a8328ec20eae480236b33782396 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Fri, 4 Sep 2026 12:23:18 +0300 Subject: [PATCH 45/78] chore: updated go package --- go.mod | 15 ++++++++++++--- go.sum | 49 +++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index a8704c7..b4d33bd 100644 --- a/go.mod +++ b/go.mod @@ -8,14 +8,19 @@ require ( ) require ( + github.com/jackc/pgx/v5 v5.10.0 github.com/rabbitmq/amqp091-go v1.14.0 + github.com/redis/go-redis/v9 v9.22.0 + github.com/rubenv/sql-migrate v1.8.1 github.com/swaggo/swag/v2 v2.0.0-rc5 golang.org/x/crypto v0.55.0 ) require ( github.com/KyleBanks/depth v1.2.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/go-gorp/gorp/v3 v3.1.0 // indirect github.com/go-openapi/jsonpointer v1.0.0 // indirect github.com/go-openapi/jsonreference v1.0.1 // indirect github.com/go-openapi/spec v0.22.11 // indirect @@ -26,13 +31,17 @@ require ( github.com/go-openapi/swag/stringutils v0.29.1 // indirect github.com/go-openapi/swag/typeutils v0.29.1 // indirect github.com/go-openapi/swag/yamlutils v0.29.1 // indirect - github.com/kr/pretty v0.3.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect - github.com/stretchr/testify v1.11.1 // indirect github.com/sv-tools/openapi v0.4.0 // indirect + go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/mod v0.40.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect golang.org/x/tools v0.49.0 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 9cfcc5e..740b2b5 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,18 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= +github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw= github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= github.com/go-openapi/jsonreference v1.0.1 h1:4zJ7AmYDKNmD3aSpfPnFNCFA5E80/xMHUNKgydaLh38= @@ -32,29 +40,53 @@ github.com/go-openapi/testify/enable/yaml/v2 v2.6.1 h1:Jm+/ze2rMtbD98yen92AhATGL github.com/go-openapi/testify/enable/yaml/v2 v2.6.1/go.mod h1:YDPnwCRDu38/oJBVMBVXOUDiJ9cIeBHWvfImHaXqnv4= github.com/go-openapi/testify/v2 v2.6.1 h1:6CNJhTjMzgaeaH8WhshcsZNPIvRemiOcFpU7seO/y7Q= github.com/go-openapi/testify/v2 v2.6.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw= +github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI= +github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= +github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= github.com/rabbitmq/amqp091-go v1.14.0 h1:RSaT7aOKt/OrkVUyswPDW29lnRz9psuGmfZFBmLqLek= github.com/rabbitmq/amqp091-go v1.14.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= +github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rubenv/sql-migrate v1.8.1 h1:EPNwCvjAowHI3TnZ+4fQu3a915OpnQoPAjTXCGOy2U0= +github.com/rubenv/sql-migrate v1.8.1/go.mod h1:BTIKBORjzyxZDS6dzoiw6eAFYJ1iNlGAtjn4LGeVjS8= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/sv-tools/openapi v0.4.0 h1:UhD9DVnGox1hfTePNclpUzUFgos57FvzT2jmcAuTOJ4= github.com/sv-tools/openapi v0.4.0/go.mod h1:kD/dG+KP0+Fom1r6nvcj/ORtLus8d8enXT6dyRZDirE= github.com/swaggo/swag/v2 v2.0.0-rc5 h1:fK7d6ET9rrEsdB8IyuwXREWMcyQN3N7gawGFbbrjgHk= github.com/swaggo/swag/v2 v2.0.0-rc5/go.mod h1:kCL8Fu4Zl8d5tB2Bgj96b8wRowwrwk175bZHXfuGVFI= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= @@ -65,10 +97,15 @@ golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From e1bc33ed243cb16e61f8f13bac78008a5466b0c6 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Fri, 4 Sep 2026 12:23:33 +0300 Subject: [PATCH 46/78] chore: added sql-migrate cli config (forgor) --- dbconfig.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 dbconfig.yml diff --git a/dbconfig.yml b/dbconfig.yml new file mode 100644 index 0000000..eddbf37 --- /dev/null +++ b/dbconfig.yml @@ -0,0 +1,13 @@ +# sql-migrate CLI config + +development: + dialect: postgres + datasource: ${DATABASE_URL} + dir: migrations + table: gorp_migrations + +production: + dialect: postgres + datasource: ${DATABASE_URL} + dir: migrations + table: gorp_migrations From bc096090408392a1dcd46ba10b5b87e3c9a5c7f9 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Fri, 4 Sep 2026 12:40:03 +0300 Subject: [PATCH 47/78] fix(test): now testing codeprocessor --- tests/hw2.py | 3 ++- tests/hw3.py | 4 +--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/hw2.py b/tests/hw2.py index 1a32daf..8e6fe85 100644 --- a/tests/hw2.py +++ b/tests/hw2.py @@ -54,7 +54,8 @@ def test_create_task(auth_token): task_url = f"{BASE_URL}/task" headers = {'Authorization': f'Bearer {auth_token}'} - response = requests.post(task_url, headers=headers) + payload = {"translator": "python3", "code": "print('hw2 task')"} + response = requests.post(task_url, headers=headers, json=payload) assert response.status_code == 201 data = response.json() diff --git a/tests/hw3.py b/tests/hw3.py index 719648b..424aad6 100644 --- a/tests/hw3.py +++ b/tests/hw3.py @@ -54,11 +54,9 @@ def test_create_task(auth_token): task_url = f"{BASE_URL}/task" headers = {'Authorization': f'Bearer {auth_token}'} - payload = dict() - # payload = get_code_processor_payload() + payload = get_code_processor_payload() # payload = get_image_processor_payload() - if len(payload) == 0: raise NotImplemented("Choose one of the variants for payload!") From 575a45d237d21ce0378b78e038135d8f770b3178 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Fri, 4 Sep 2026 18:31:05 +0300 Subject: [PATCH 48/78] build(dockerfile.processor): pinned runtime image by digest --- Dockerfile.processor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile.processor b/Dockerfile.processor index d7aab88..2431444 100644 --- a/Dockerfile.processor +++ b/Dockerfile.processor @@ -12,7 +12,7 @@ COPY . . RUN CGO_ENABLED=0 GOOS=linux \ go build -trimpath -ldflags="-s -w" -o /out/processor ./cmd/processor -FROM gcr.io/distroless/static-debian12:nonroot AS runtime +FROM gcr.io/distroless/static-debian12:nonroot@sha256:afa5c872c891853ca7fcf1f12c3edb23f7eeef36189728842dd51042ff57f7ab AS runtime WORKDIR /app From af7448237718c8360d40ec9715ecd05d2aaabad4 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Fri, 4 Sep 2026 18:32:56 +0300 Subject: [PATCH 49/78] feat: added sandbox image --- sandbox/Dockerfile | 9 +++++++++ sandbox/run.sh | 29 +++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 sandbox/Dockerfile create mode 100644 sandbox/run.sh diff --git a/sandbox/Dockerfile b/sandbox/Dockerfile new file mode 100644 index 0000000..b7be367 --- /dev/null +++ b/sandbox/Dockerfile @@ -0,0 +1,9 @@ +FROM alpine:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b + +RUN apk add --no-cache python3~=3.14.7-r1 gcc~=15.2.0-r5 clang22~=22.1.3-r2 musl-dev~=1.2.6-r2 libstdc++~=15.2.0-r5 + +COPY --chmod=0755 sandbox/run.sh /run.sh + +USER 65532:65532 + +ENTRYPOINT ["/bin/sh", "/run.sh"] diff --git a/sandbox/run.sh b/sandbox/run.sh new file mode 100644 index 0000000..1724a03 --- /dev/null +++ b/sandbox/run.sh @@ -0,0 +1,29 @@ +#!/bin/sh +# The image's entrypoint is fixed to run.sh. +# This wrapper takes in the TRANSLATOR env var set, decodes the user code from USER_CODE_B64 + +set -u + +if [ -z "${USER_CODE_B64:-}" ]; then + echo "USER_CODE_B64 is not set" >&2 + exit 2 +fi + +case "${TRANSLATOR:-}" in +python3) + printf '%s' "$USER_CODE_B64" | base64 -d > /tmp/main.py + exec python3 /tmp/main.py + ;; +gcc) + printf '%s' "$USER_CODE_B64" | base64 -d > /tmp/main.c + gcc /tmp/main.c -o /tmp/prog && exec /tmp/prog + ;; +clang) + printf '%s' "$USER_CODE_B64" | base64 -d > /tmp/main.cpp + clang++ /tmp/main.cpp -o /tmp/prog && exec /tmp/prog + ;; +*) + echo "unsupported translator: ${TRANSLATOR}" >&2 + exit 2 + ;; +esac From 19bec291d021c89ec4b0cf6856f8264305bce3bc Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Fri, 4 Sep 2026 18:36:37 +0300 Subject: [PATCH 50/78] build(compose): updated docker compose --- docker-compose.yml | 108 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 106 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index ed2fc91..7ca4b8b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,58 @@ services: + postgres: + image: postgres:18.6-alpine + container_name: remote-code-postgres + restart: unless-stopped + + ports: + - "127.0.0.1:${POSTGRES_PORT:-5432}:5432" + + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + POSTGRES_DB: ${POSTGRES_DB:-remote_code} + + volumes: + - postgres-data:/var/lib/postgresql/data + + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 5s + timeout: 3s + retries: 10 + + redis: + image: redis:8.10.1-alpine + container_name: remote-code-redis + restart: unless-stopped + + ports: + - "127.0.0.1:${REDIS_PORT:-6379}:6379" + + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + rabbitmq: + image: rabbitmq:4.3.5-alpine + container_name: remote-code-rabbitmq + restart: unless-stopped + + ports: + - "127.0.0.1:${RABBITMQ_PORT:-5672}:5672" + + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-guest} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-guest} + + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + interval: 10s + timeout: 5s + retries: 10 + remote-code: build: context: . @@ -8,12 +62,60 @@ services: restart: unless-stopped ports: - - "127.0.0.1:8000:8000" + - "127.0.0.1:${HTTP_PORT:-8000}:8000" environment: HTTP_ADDR: ":8000" - TASK_PROCESSING_TIME: "2s" SHUTDOWN_TIMEOUT: "10s" + DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-remote_code}?sslmode=disable + REDIS_ADDR: redis:6379 + RABBITMQ_URL: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq:5672/ + + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + rabbitmq: + condition: service_healthy + + user: "65532:65532" + + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=16m + + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + + mem_limit: 256m + mem_reservation: 128m + cpus: "1.0" + + processor: + build: + context: . + dockerfile: Dockerfile.processor + image: remote-code-processor:latest + container_name: remote-code-processor + restart: unless-stopped + + environment: + DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-remote_code}?sslmode=disable + RABBITMQ_URL: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq:5672/ + PHILHARMONIC_URL: ${PHILHARMONIC_URL:-http://localhost:5555} + PHILHARMONIC_TOKEN: ${PHILHARMONIC_TOKEN:-} + + depends_on: + postgres: + condition: service_healthy + rabbitmq: + condition: service_healthy + # the server owns the migrations, the processor already waits for db readiness + remote-code: + condition: service_started user: "65532:65532" @@ -30,3 +132,5 @@ services: mem_reservation: 128m cpus: "1.0" +volumes: + postgres-data: From 62c329ce379a8a8594869d50167980396a867676 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Fri, 4 Sep 2026 18:59:39 +0300 Subject: [PATCH 51/78] ci: added ci --- .github/workflows/ci.yml | 126 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1f4fcc4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,126 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +env: + SANDBOX_IMAGE: ghcr.io/${{ github.repository }}/sandbox + +jobs: + lint: + name: vet & lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: vet + run: go vet ./... + + - name: golangci-lint + uses: golangci/golangci-lint-action@v8 + with: + version: v2.11.3 + + publish-sandbox: + name: publish sandbox image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: login to GHCR + if: github.event_name == 'push' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: build (and push on push events) + uses: docker/build-push-action@v6 + with: + context: . + file: sandbox/Dockerfile + push: ${{ github.event_name == 'push' }} + tags: | + ${{ env.SANDBOX_IMAGE }}:latest + ${{ env.SANDBOX_IMAGE }}:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + e2e: + name: e2e (compose + philharmonic + pytest) + runs-on: ubuntu-latest + needs: lint + env: + DATABASE_URL: postgres://postgres:postgres@localhost:5432/remote_code?sslmode=disable + REDIS_ADDR: localhost:6379 + RABBITMQ_URL: amqp://guest:guest@localhost:5672/ + PHILHARMONIC_URL: http://localhost:5555 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: infra + run: docker compose up -d --wait postgres redis rabbitmq + + - name: build sandbox image + run: docker build -t sandbox:latest -f sandbox/Dockerfile . + + - name: start philharmonic manager + worker on the runner + run: | + git clone --depth 1 --branch v0.1.0 https://github.com/belyaevedu/philharmonic /tmp/philharmonic + (cd /tmp/philharmonic && CGO_ENABLED=0 go build -o /tmp/phrm .) + + /tmp/phrm manager --host 127.0.0.1 --port 5555 --workers 127.0.0.1:5556 > /tmp/phrm-manager.log 2>&1 & + /tmp/phrm worker --host 127.0.0.1 --port 5556 --name dev-worker > /tmp/phrm-worker.log 2>&1 & + + # wait until the manager reads the worker's stats + for i in $(seq 1 30); do + curl -sf localhost:5555/nodes | grep -q '"Cores":[1-9]' && break + sleep 1 + done + curl -sf localhost:5555/nodes | grep -q '"Cores":[1-9]' + + - name: build & run server and processor + run: | + go build -o /tmp/server ./cmd/server + go build -o /tmp/processor ./cmd/processor + /tmp/server > /tmp/server.log 2>&1 & + /tmp/processor > /tmp/processor.log 2>&1 & + # any HTTP answer (even 401/404) means the server is serving. + # it only serves after the migrations are done + for i in $(seq 1 30); do + code=$(curl -s -o /dev/null -w '%{http_code}' \ + localhost:8000/status/00000000-0000-0000-0000-000000000000) + [ "$code" != "000" ] && break + sleep 1 + done + [ "$code" != "000" ] + + - name: pytest + run: | + python -m pip install pytest requests + python -m pytest tests/hw2.py tests/hw3.py -v + + - name: debug logs + if: failure() + run: | + tail -n 50 /tmp/server.log /tmp/processor.log /tmp/phrm-manager.log /tmp/phrm-worker.log + + - name: teardown + if: always() + run: docker compose down -v From 731b6877ba22429b8eb18c1c678da751858da7de Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Fri, 4 Sep 2026 19:10:28 +0300 Subject: [PATCH 52/78] ci: e2e now runs on PR trigger, sandbox image publishing fixed --- .env.example | 38 ++++++++++++++++++++++++++++++++------ .github/workflows/ci.yml | 20 +++++++++++--------- 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index 0342e73..48f3c11 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,36 @@ +## docker compose + +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DB=remote_code +POSTGRES_PORT=5432 + +REDIS_PORT=6379 + +RABBITMQ_USER=guest +RABBITMQ_PASSWORD=guest +RABBITMQ_PORT=5672 + +HTTP_PORT=8000 + +## runtime - not injected into containers, set via compose + HTTP_ADDR=:8000 -TASK_PROCESSING_TIME=2s SHUTDOWN_TIMEOUT=10s +DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:${POSTGRES_PORT}/${POSTGRES_DB}?sslmode=disable +MIGRATIONS_DIR=migrations + +REDIS_ADDR=localhost:${REDIS_PORT} +REDIS_PASSWORD= +REDIS_DB=0 +SESSION_TTL=168h + +RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@localhost:${RABBITMQ_PORT}/ +TASK_QUEUE_NAME=tasks +TASK_QUEUE_PREFETCH=1 +TASK_QUEUE_RECONNECT_DELAY=1s + PHILHARMONIC_URL=http://localhost:5555 # empty means manager runs without auth PHILHARMONIC_TOKEN= @@ -13,8 +42,5 @@ PHILHARMONIC_POLL_TIMEOUT= PHILHARMONIC_CPU=0.5 # bytes PHILHARMONIC_MEMORY=268435456 - -RABBITMQ_URL=amqp://guest:guest@localhost:5672/ -TASK_QUEUE_NAME=tasks -TASK_QUEUE_PREFETCH=1 -TASK_QUEUE_RECONNECT_DELAY=1s +# CI publishes the sandbox to GHCR on every push to main +PHILHARMONIC_IMAGE=ghcr.io/belyaevedu/backend-final/sandbox:latest diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f4fcc4..54dbf2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,9 +5,6 @@ on: branches: [main] pull_request: -env: - SANDBOX_IMAGE: ghcr.io/${{ github.repository }}/sandbox - jobs: lint: name: vet & lint @@ -28,7 +25,9 @@ jobs: version: v2.11.3 publish-sandbox: - name: publish sandbox image + name: publish sandbox image to ghcr + # push to main only basically + if: github.event_name == 'push' runs-on: ubuntu-latest permissions: contents: read @@ -38,23 +37,26 @@ jobs: - uses: docker/setup-buildx-action@v3 + - id: repo + name: lowercase repo name by ghcr requirement + run: echo "name=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" + - name: login to GHCR - if: github.event_name == 'push' uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: build (and push on push events) + - name: build and push uses: docker/build-push-action@v6 with: context: . file: sandbox/Dockerfile - push: ${{ github.event_name == 'push' }} + push: true tags: | - ${{ env.SANDBOX_IMAGE }}:latest - ${{ env.SANDBOX_IMAGE }}:${{ github.sha }} + ghcr.io/${{ steps.repo.outputs.name }}/sandbox:latest + ghcr.io/${{ steps.repo.outputs.name }}/sandbox:${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max From 45652c8495cc4a7aa893c6823b1c9fcb2a2ba876 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Fri, 4 Sep 2026 19:18:49 +0300 Subject: [PATCH 53/78] build(compose): fixed postgres volume bind --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 7ca4b8b..3da0b65 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,7 +13,7 @@ services: POSTGRES_DB: ${POSTGRES_DB:-remote_code} volumes: - - postgres-data:/var/lib/postgresql/data + - postgres-data:/var/lib/postgresql healthcheck: test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] From 9353069e9e80dd8bb9b69b0c5703a1f2e7503298 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Fri, 4 Sep 2026 19:20:21 +0300 Subject: [PATCH 54/78] ci: rewrote debug logs to avoid 'file not found' errors --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54dbf2e..0bd41ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,7 +121,11 @@ jobs: - name: debug logs if: failure() run: | - tail -n 50 /tmp/server.log /tmp/processor.log /tmp/phrm-manager.log /tmp/phrm-worker.log + docker compose ps + docker compose logs --tail 30 postgres redis rabbitmq || true + for f in /tmp/server.log /tmp/processor.log /tmp/phrm-manager.log /tmp/phrm-worker.log; do + if [ -f "$f" ]; then echo "== $f =="; tail -n 50 "$f"; fi + done - name: teardown if: always() From 47797c34f6df3e69d3a0d3dd5b0f44a3126b5e53 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Fri, 4 Sep 2026 19:28:34 +0300 Subject: [PATCH 55/78] ci(e2e): fixed connection retries --- .github/workflows/ci.yml | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0bd41ee..0dca80f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,13 +105,8 @@ jobs: /tmp/processor > /tmp/processor.log 2>&1 & # any HTTP answer (even 401/404) means the server is serving. # it only serves after the migrations are done - for i in $(seq 1 30); do - code=$(curl -s -o /dev/null -w '%{http_code}' \ - localhost:8000/status/00000000-0000-0000-0000-000000000000) - [ "$code" != "000" ] && break - sleep 1 - done - [ "$code" != "000" ] + curl -s -o /dev/null --retry 30 --retry-delay 1 --retry-all-errors \ + localhost:8000/status/00000000-0000-0000-0000-000000000000 - name: pytest run: | From ba11c0b8d2f445b6ad5616c21720876719860529 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Fri, 4 Sep 2026 20:02:34 +0300 Subject: [PATCH 56/78] feat(service/philharmonic): using new security options --- internal/service/philharmonic.go | 51 +++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/internal/service/philharmonic.go b/internal/service/philharmonic.go index 96bedfd..cf3c27a 100644 --- a/internal/service/philharmonic.go +++ b/internal/service/philharmonic.go @@ -109,13 +109,48 @@ func (e *PhilharmonicExecutor) Execute(ctx context.Context, msg domain.TaskMessa // mirrors the philharmonic task.Task JSON field names type submitTask struct { - Name string `json:"Name"` - Image string `json:"Image"` - Env []string `json:"Env,omitempty"` - RestartPolicy string `json:"RestartPolicy"` - Timeout int64 `json:"Timeout"` // seconds - Cpu float64 `json:"Cpu"` - Memory int64 `json:"Memory"` // bytes + Name string `json:"Name"` + Image string `json:"Image"` + Env []string `json:"Env,omitempty"` + RestartPolicy string `json:"RestartPolicy"` + Timeout int64 `json:"Timeout"` // seconds + Cpu float64 `json:"Cpu"` + Memory int64 `json:"Memory"` // bytes + Security *submitSecurity `json:"Security"` +} + +// mirrors the philharmonic task.Security +type submitSecurity struct { + User string `json:"User"` + CapDrop []string `json:"CapDrop,omitempty"` + Tmpfs []string `json:"Tmpfs,omitempty"` + ReadOnlyRootfs bool `json:"ReadOnlyRootfs"` + NoNewPrivileges bool `json:"NoNewPrivileges"` + PidsLimit int64 `json:"PidsLimit,omitempty"` + Ulimits []submitUlimit `json:"Ulimits,omitempty"` +} + +type submitUlimit struct { + Name string `json:"Name"` + Soft int64 `json:"Soft"` + Hard int64 `json:"Hard"` +} + +func defaultSandboxSecurity() *submitSecurity { + return &submitSecurity{ + User: "65532:65532", + CapDrop: []string{"ALL"}, + ReadOnlyRootfs: true, + Tmpfs: []string{"/tmp:rw,nosuid,nodev,size=64m,mode=1777"}, + NoNewPrivileges: true, + PidsLimit: 128, + Ulimits: []submitUlimit{ + // fd exhaustion guard + {Name: "nofile", Soft: 256, Hard: 256}, + // no core dumps + {Name: "core", Soft: 0, Hard: 0}, + }, + } } type submitEvent struct { @@ -138,6 +173,8 @@ func (e *PhilharmonicExecutor) submit(ctx context.Context, name string, msg doma Cpu: e.cfg.Cpu, Memory: e.cfg.Memory, + + Security: defaultSandboxSecurity(), }, }) if err != nil { From 2a7263ca950a2aa9c00418353785047ea863d6f0 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Fri, 4 Sep 2026 20:13:41 +0300 Subject: [PATCH 57/78] style(service/philharmonic): dropped tags --- internal/service/philharmonic.go | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/internal/service/philharmonic.go b/internal/service/philharmonic.go index cf3c27a..3f0958d 100644 --- a/internal/service/philharmonic.go +++ b/internal/service/philharmonic.go @@ -109,25 +109,25 @@ func (e *PhilharmonicExecutor) Execute(ctx context.Context, msg domain.TaskMessa // mirrors the philharmonic task.Task JSON field names type submitTask struct { - Name string `json:"Name"` - Image string `json:"Image"` - Env []string `json:"Env,omitempty"` - RestartPolicy string `json:"RestartPolicy"` - Timeout int64 `json:"Timeout"` // seconds - Cpu float64 `json:"Cpu"` - Memory int64 `json:"Memory"` // bytes - Security *submitSecurity `json:"Security"` + Name string + Image string + Env []string + RestartPolicy string + Timeout int64 // seconds + Cpu float64 + Memory int64 // bytes + Security *submitSecurity } // mirrors the philharmonic task.Security type submitSecurity struct { - User string `json:"User"` - CapDrop []string `json:"CapDrop,omitempty"` - Tmpfs []string `json:"Tmpfs,omitempty"` - ReadOnlyRootfs bool `json:"ReadOnlyRootfs"` - NoNewPrivileges bool `json:"NoNewPrivileges"` - PidsLimit int64 `json:"PidsLimit,omitempty"` - Ulimits []submitUlimit `json:"Ulimits,omitempty"` + User string + CapDrop []string + Tmpfs []string + ReadOnlyRootfs bool + NoNewPrivileges bool + PidsLimit int64 + Ulimits []submitUlimit } type submitUlimit struct { From e724f22e9c4958e1d0857df7d409f0c3ee3d8123 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Fri, 4 Sep 2026 20:14:53 +0300 Subject: [PATCH 58/78] ci(e2e): updated philharmonic version --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0dca80f..a82406a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,7 +84,7 @@ jobs: - name: start philharmonic manager + worker on the runner run: | - git clone --depth 1 --branch v0.1.0 https://github.com/belyaevedu/philharmonic /tmp/philharmonic + git clone --depth 1 --branch v0.1.1 https://github.com/belyaevedu/philharmonic /tmp/philharmonic (cd /tmp/philharmonic && CGO_ENABLED=0 go build -o /tmp/phrm .) /tmp/phrm manager --host 127.0.0.1 --port 5555 --workers 127.0.0.1:5556 > /tmp/phrm-manager.log 2>&1 & From f6b212c817bf72a7543ed7f24ff63bca2c97dbb6 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Fri, 4 Sep 2026 20:26:08 +0300 Subject: [PATCH 59/78] chore(.dockerignore): added sandbox, dbconfig, .python-version (pyenv quirk) --- .dockerignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.dockerignore b/.dockerignore index f933e00..b00a4b1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,10 +3,12 @@ .vscode/ .DS_Store +.python-version *.md tests/ +sandbox/ __pycache__/ .pytest_cache @@ -16,3 +18,5 @@ Dockerfile .env .env.example + +dbconfig.yml From 7713125d579df1a4759efd413c3d6806eb6319cf Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Fri, 4 Sep 2026 20:28:31 +0300 Subject: [PATCH 60/78] chore(.dockerignore): forgot that sandbox is built within the context of the root folder --- .dockerignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index b00a4b1..965cb15 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,7 +8,6 @@ *.md tests/ -sandbox/ __pycache__/ .pytest_cache From 9f187ded834ad7633932e4e216b323b464d4800f Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Fri, 4 Sep 2026 20:37:31 +0300 Subject: [PATCH 61/78] build: split COPY . . which is the better practice but whatever --- Dockerfile | 4 +++- Dockerfile.processor | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index b9748d4..d8aedfe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,9 @@ RUN apk add --no-cache git~=2.54.0-r0 ca-certificates~=20260611-r0 COPY go.mod go.sum ./ RUN go mod download -COPY . . +COPY cmd ./cmd +COPY internal ./internal +COPY migrations ./migrations RUN CGO_ENABLED=0 GOOS=linux \ go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server diff --git a/Dockerfile.processor b/Dockerfile.processor index 2431444..b638d2a 100644 --- a/Dockerfile.processor +++ b/Dockerfile.processor @@ -7,7 +7,8 @@ RUN apk add --no-cache git~=2.54.0-r0 ca-certificates~=20260611-r0 COPY go.mod go.sum ./ RUN go mod download -COPY . . +COPY cmd ./cmd +COPY internal ./internal RUN CGO_ENABLED=0 GOOS=linux \ go build -trimpath -ldflags="-s -w" -o /out/processor ./cmd/processor From f2b2e4738b57e5f59f5560c153aeaf3faadce82f Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Fri, 4 Sep 2026 20:38:20 +0300 Subject: [PATCH 62/78] build: removed .dockerignore --- .dockerignore | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 965cb15..0000000 --- a/.dockerignore +++ /dev/null @@ -1,21 +0,0 @@ -.git/ -.gitignore - -.vscode/ -.DS_Store -.python-version - -*.md - -tests/ -__pycache__/ -.pytest_cache - -docker-compose.yml -Dockerfile -.dockerignore - -.env -.env.example - -dbconfig.yml From 8895f829aa29be17c11dcb18189cc47aff0b3b3f Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Sat, 5 Sep 2026 00:43:04 +0300 Subject: [PATCH 63/78] feat(service): added a metrics-instrumented executor --- internal/service/metrics.go | 81 +++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 internal/service/metrics.go diff --git a/internal/service/metrics.go b/internal/service/metrics.go new file mode 100644 index 0000000..2048702 --- /dev/null +++ b/internal/service/metrics.go @@ -0,0 +1,81 @@ +package service + +import ( + "context" + "errors" + "time" + + chimetrics "github.com/go-chi/metrics" + + "github.com/belyaevedu/remote-code-service/internal/domain" + "github.com/belyaevedu/remote-code-service/internal/port" +) + +const ( + taskStatusSuccess = "success" // exit code 0 + taskStatusFailure = "failure" // ran but the user code failed + taskStatusTimeout = "timeout" // killed by the execution timout + taskStatusError = "error" // infra failure +) + +type taskLabels struct { + Translator string `label:"translator"` + Status string `label:"status"` +} + +type taskInFlightLabels struct { + Translator string `label:"translator"` +} + +var ( + tasksInFlight = chimetrics.GaugeWith[taskInFlightLabels]( + "tasks_in_flight", + "Number of code tasks currently executing in the sandbox.", + ) + tasksProcessedTotal = chimetrics.CounterWith[taskLabels]( + "tasks_processed_total", + "Total number of code execution tasks processed by the worker.", + ) + taskExecutionDuration = chimetrics.HistogramWith[taskLabels]( + "task_execution_duration_seconds", + "Time a code task spent executing in the sandbox.", + []float64{.05, .1, .25, .5, 1, 2.5, 5, 10, 15, 30, 60}, + ) +) + +type InstrumentedExecutor struct { + inner port.CodeExecutor +} + +var _ port.CodeExecutor = (*InstrumentedExecutor)(nil) + +func NewInstrumentedExecutor(inner port.CodeExecutor) *InstrumentedExecutor { + return &InstrumentedExecutor{inner: inner} +} + +func (e *InstrumentedExecutor) Execute(ctx context.Context, msg domain.TaskMessage) (domain.ExecutionResult, error) { + inflight := taskInFlightLabels{Translator: msg.Translator} + tasksInFlight.Inc(inflight) + defer tasksInFlight.Dec(inflight) + + start := time.Now() + result, err := e.inner.Execute(ctx, msg) + duration := time.Since(start) + + status := taskStatusSuccess + switch { + case err != nil: + status = taskStatusError + if errors.Is(err, domain.ErrExecutionTimeout) { + status = taskStatusTimeout + } + case result.Failed: + status = taskStatusFailure + } + + labels := taskLabels{Translator: msg.Translator, Status: status} + tasksProcessedTotal.Inc(labels) + taskExecutionDuration.Observe(duration.Seconds(), labels) + + return result, err +} From a73fdcc561f9d5ae1ba5da0fd3da0174be568579 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Sat, 5 Sep 2026 00:44:09 +0300 Subject: [PATCH 64/78] feat(config): added a metrics config --- internal/config/metrics.go | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 internal/config/metrics.go diff --git a/internal/config/metrics.go b/internal/config/metrics.go new file mode 100644 index 0000000..75d309a --- /dev/null +++ b/internal/config/metrics.go @@ -0,0 +1,33 @@ +package config + +import ( + "fmt" + "time" +) + +type MetricsConfig struct { + Addr string + ShutdownTimeout time.Duration +} + +const ( + envVarMetricsAddress = "METRICS_ADDR" + envVarMetricsShutdownTimeout = "METRICS_SHUTDOWN_TIMEOUT" +) + +const ( + defaultMetricsAddress = ":9100" + defaultMetricsShutdownTimeout = 5 * time.Second +) + +func LoadMetricsConfig() (MetricsConfig, error) { + metricsCfg := MetricsConfig{ + Addr: envString(envVarMetricsAddress, defaultMetricsAddress), + ShutdownTimeout: envDuration(envVarMetricsShutdownTimeout, defaultMetricsShutdownTimeout), + } + + if metricsCfg.ShutdownTimeout <= 0 { + return MetricsConfig{}, fmt.Errorf("%s must be positive, got %s", envVarMetricsShutdownTimeout, metricsCfg.ShutdownTimeout) + } + return metricsCfg, nil +} From 2a7a72d99d26987828c4f52a9a932c00112dbb4a Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Sat, 5 Sep 2026 00:44:34 +0300 Subject: [PATCH 65/78] feat(controller): added a metrics server --- internal/controller/metrics.go | 62 ++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 internal/controller/metrics.go diff --git a/internal/controller/metrics.go b/internal/controller/metrics.go new file mode 100644 index 0000000..240bcc3 --- /dev/null +++ b/internal/controller/metrics.go @@ -0,0 +1,62 @@ +package controller + +import ( + "context" + "errors" + "log" + "net/http" + "time" + + chimetrics "github.com/go-chi/metrics" +) + +type MetricsServer struct { + httpServer *http.Server + shutdown time.Duration +} + +func NewMetricsServer(address string, shutdownTimeout time.Duration) *MetricsServer { + if shutdownTimeout <= 0 { + shutdownTimeout = 5 * time.Second + } + + mux := http.NewServeMux() + mux.Handle("GET /metrics", chimetrics.Handler()) + + return &MetricsServer{ + httpServer: &http.Server{ + Addr: address, + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + }, + shutdown: shutdownTimeout, + } +} + +func (s *MetricsServer) Start(ctx context.Context) error { + errCh := make(chan error, 1) + go func() { + if err := s.httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + } + }() + + log.Printf("metrics server listening on %s", s.httpServer.Addr) + + select { + case err := <-errCh: + return err + case <-ctx.Done(): + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), s.shutdown) + defer cancel() + + if err := s.httpServer.Shutdown(shutdownCtx); err != nil { + log.Printf("metrics server graceful shutdown failed: %v\n", err) + return err + } + return nil +} From 2203e36b7a737a85605a83be24edf2e56907c7b1 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Sat, 5 Sep 2026 00:45:14 +0300 Subject: [PATCH 66/78] feat(controller/router): instrumented chi with chi's metrics package --- internal/controller/router.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/controller/router.go b/internal/controller/router.go index ab1a8f4..29e44e5 100644 --- a/internal/controller/router.go +++ b/internal/controller/router.go @@ -2,6 +2,7 @@ package controller import ( "github.com/go-chi/chi/v5" + chimetrics "github.com/go-chi/metrics" "github.com/belyaevedu/remote-code-service/internal/controller/handlers" "github.com/belyaevedu/remote-code-service/internal/port" @@ -10,6 +11,8 @@ import ( func NewRouter(t *handlers.TaskHandlers, u *handlers.UserHandlers, auth port.AuthService) *chi.Mux { r := chi.NewRouter() + r.Use(chimetrics.Collector(chimetrics.CollectorOpts{})) + r.Post("/register", u.Register) r.Post("/login", u.Login) From 8f0350997f9c0a1feb57c8dfdf70688afb16201c Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Sat, 5 Sep 2026 00:46:07 +0300 Subject: [PATCH 67/78] feat(cmd/server): loading metrics config, starting metrics server --- cmd/server/main.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cmd/server/main.go b/cmd/server/main.go index d889cdc..e9a93df 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -47,6 +47,10 @@ func main() { if err != nil { log.Fatalf("invalid queue config: %v", err) } + metricsCfg, err := config.LoadMetricsConfig() + if err != nil { + log.Fatalf("invalid metrics config: %v", err) + } ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() @@ -87,6 +91,13 @@ func main() { router := controller.NewRouter(taskHandler, userHandler, userService) server := controller.NewApi(appCfg.HTTPAddr, router, appCfg.ShutdownTimeout) + metricsServer := controller.NewMetricsServer(metricsCfg.Addr, metricsCfg.ShutdownTimeout) + go func() { + if err := metricsServer.Start(ctx); err != nil { + log.Fatalf("metrics server exited with error: %v", err) + } + }() + if err := server.Start(ctx); err != nil { log.Fatalf("server exited with error: %v", err) } From aac0f61814e6689592e27029d0bb0b6a8faa09ff Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Sat, 5 Sep 2026 00:46:30 +0300 Subject: [PATCH 68/78] feat(cmd/processor): loading metrics config, instrumenting executor, starting metrics server --- cmd/processor/main.go | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/cmd/processor/main.go b/cmd/processor/main.go index 035983b..da05faf 100644 --- a/cmd/processor/main.go +++ b/cmd/processor/main.go @@ -9,6 +9,7 @@ import ( "time" "github.com/belyaevedu/remote-code-service/internal/config" + "github.com/belyaevedu/remote-code-service/internal/controller" "github.com/belyaevedu/remote-code-service/internal/domain" "github.com/belyaevedu/remote-code-service/internal/repository/postgres" "github.com/belyaevedu/remote-code-service/internal/repository/queue" @@ -28,6 +29,10 @@ func main() { if err != nil { log.Fatalf("invalid philharmonic config: %v", err) } + metricsCfg, err := config.LoadMetricsConfig() + if err != nil { + log.Fatalf("invalid metrics config: %v", err) + } ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() @@ -42,19 +47,28 @@ func main() { log.Fatalf("waiting for schema: %v", err) } - executor := service.NewPhilharmonicExecutor(philCfg) + phrmExecutor := service.NewPhilharmonicExecutor(philCfg) // pulling the sandbox image on every worker before starting to process prewarmCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) - if err := executor.PreWarm(prewarmCtx); err != nil { + if err := phrmExecutor.PreWarm(prewarmCtx); err != nil { log.Printf("processor: sandbox pre-warm failed: %v (continuing)", err) } else { log.Printf("processor: sandbox image %q pre-warmed on workers", philCfg.SandboxImage) } cancel() + executor := service.NewInstrumentedExecutor(phrmExecutor) + consumer := queue.NewConsumer(queueCfg) + metricsServer := controller.NewMetricsServer(metricsCfg.Addr, metricsCfg.ShutdownTimeout) + go func() { + if err := metricsServer.Start(ctx); err != nil { + log.Fatalf("metrics server exited with error: %v", err) + } + }() + handle := func(ctx context.Context, msg domain.TaskMessage) error { result, err := executor.Execute(ctx, msg) if err != nil { From 4143b0749728c586d79b073759cf5d0bbfee4f6e Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Sat, 5 Sep 2026 00:46:57 +0300 Subject: [PATCH 69/78] docs(.env): updated .env.example --- .env.example | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.env.example b/.env.example index 48f3c11..d53c1a8 100644 --- a/.env.example +++ b/.env.example @@ -12,12 +12,17 @@ RABBITMQ_PASSWORD=guest RABBITMQ_PORT=5672 HTTP_PORT=8000 +METRICS_PORT=9100 # server / producer +PROCESSOR_METRICS_PORT=9101 # consumer ## runtime - not injected into containers, set via compose HTTP_ADDR=:8000 SHUTDOWN_TIMEOUT=10s +METRICS_ADDR=:9100 +METRICS_SHUTDOWN_TIMEOUT=5s + DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:${POSTGRES_PORT}/${POSTGRES_DB}?sslmode=disable MIGRATIONS_DIR=migrations From 3b293455760d35165dcef77d5d6cb4b55b8970e4 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Sat, 5 Sep 2026 00:47:21 +0300 Subject: [PATCH 70/78] build: exposing metrics server ports --- Dockerfile | 1 + Dockerfile.processor | 2 ++ docker-compose.yml | 6 ++++++ 3 files changed, 9 insertions(+) diff --git a/Dockerfile b/Dockerfile index d8aedfe..4ac879f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,6 +22,7 @@ COPY --from=builder /out/server /app/server COPY --from=builder /src/migrations /app/migrations EXPOSE 8000 +EXPOSE 9100 USER 65532:65532 diff --git a/Dockerfile.processor b/Dockerfile.processor index b638d2a..c9bf7d4 100644 --- a/Dockerfile.processor +++ b/Dockerfile.processor @@ -17,6 +17,8 @@ FROM gcr.io/distroless/static-debian12:nonroot@sha256:afa5c872c891853ca7fcf1f12c WORKDIR /app +EXPOSE 9100 + COPY --from=builder /out/processor /app/processor ENTRYPOINT ["/app/processor"] diff --git a/docker-compose.yml b/docker-compose.yml index 3da0b65..371f1a1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -63,10 +63,12 @@ services: ports: - "127.0.0.1:${HTTP_PORT:-8000}:8000" + - "127.0.0.1:${METRICS_PORT:-9100}:9100" environment: HTTP_ADDR: ":8000" SHUTDOWN_TIMEOUT: "10s" + METRICS_ADDR: ":9100" DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-remote_code}?sslmode=disable REDIS_ADDR: redis:6379 RABBITMQ_URL: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq:5672/ @@ -102,7 +104,11 @@ services: container_name: remote-code-processor restart: unless-stopped + ports: + - "127.0.0.1:${PROCESSOR_METRICS_PORT:-9101}:9100" + environment: + METRICS_ADDR: ":9100" DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-remote_code}?sslmode=disable RABBITMQ_URL: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq:5672/ PHILHARMONIC_URL: ${PHILHARMONIC_URL:-http://localhost:5555} From 75b0230a6e4691470947282be6ed32840a46a662 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Sat, 5 Sep 2026 00:47:32 +0300 Subject: [PATCH 71/78] chore: updated go module --- go.mod | 8 ++++++++ go.sum | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/go.mod b/go.mod index b4d33bd..cecef2b 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( ) require ( + github.com/go-chi/metrics v0.1.1 github.com/jackc/pgx/v5 v5.10.0 github.com/rabbitmq/amqp091-go v1.14.0 github.com/redis/go-redis/v9 v9.22.0 @@ -18,6 +19,7 @@ require ( require ( github.com/KyleBanks/depth v1.2.1 // indirect + github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/go-gorp/gorp/v3 v3.1.0 // indirect @@ -34,6 +36,11 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_golang v1.22.0 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.62.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/sv-tools/openapi v0.4.0 // indirect go.uber.org/atomic v1.11.0 // indirect @@ -43,5 +50,6 @@ require ( golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect golang.org/x/tools v0.49.0 // indirect + google.golang.org/protobuf v1.36.5 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 740b2b5..d47d630 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -11,6 +13,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-chi/metrics v0.1.1 h1:CXhbnkAVVjb0k73EBRQ6Z2YdWFnbXZgNtg1Mboguibk= +github.com/go-chi/metrics v0.1.1/go.mod h1:mcGTM1pPalP7WCtb+akNYFO/lwNwBBLCuedepqjoPn4= github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw= github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= @@ -42,6 +46,8 @@ github.com/go-openapi/testify/v2 v2.6.1 h1:6CNJhTjMzgaeaH8WhshcsZNPIvRemiOcFpU7s github.com/go-openapi/testify/v2 v2.6.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -52,20 +58,34 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw= github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI= github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= +github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= +github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/rabbitmq/amqp091-go v1.14.0 h1:RSaT7aOKt/OrkVUyswPDW29lnRz9psuGmfZFBmLqLek= github.com/rabbitmq/amqp091-go v1.14.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= @@ -103,6 +123,8 @@ golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= From e2aea3a25178f108ae53d64dab7b858192ff37a8 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Sat, 5 Sep 2026 00:54:33 +0300 Subject: [PATCH 72/78] ci(e2e): fixed port conflict --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a82406a..f7e6e1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,7 +102,7 @@ jobs: go build -o /tmp/server ./cmd/server go build -o /tmp/processor ./cmd/processor /tmp/server > /tmp/server.log 2>&1 & - /tmp/processor > /tmp/processor.log 2>&1 & + METRICS_ADDR=:9101 /tmp/processor > /tmp/processor.log 2>&1 & # any HTTP answer (even 401/404) means the server is serving. # it only serves after the migrations are done curl -s -o /dev/null --retry 30 --retry-delay 1 --retry-all-errors \ From 46f5786568b200a3297bc92fe5282a8b942a9560 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Mon, 7 Sep 2026 14:04:21 +0300 Subject: [PATCH 73/78] fix(port): passing in contexts --- internal/port/port.go | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/internal/port/port.go b/internal/port/port.go index 9f1016c..afca8b2 100644 --- a/internal/port/port.go +++ b/internal/port/port.go @@ -7,37 +7,37 @@ import ( ) type TaskRepository interface { - SaveTask(task *domain.Task) error - GetTask(id string) (*domain.Task, error) - UpdateTaskStatus(id string, status domain.TaskStatus) error - SaveTaskResult(id string, result *domain.Result) error + SaveTask(ctx context.Context, task *domain.Task) error + GetTask(ctx context.Context, id string) (*domain.Task, error) + UpdateTaskStatus(ctx context.Context, id string, status domain.TaskStatus) error + SaveTaskResult(ctx context.Context, id string, result *domain.Result) error } type TaskService interface { Submit(ctx context.Context, userID string, sub domain.Submission) (string, error) - Status(userID, id string) (domain.TaskStatus, error) - Result(userID, id string) (*domain.Result, error) + Status(ctx context.Context, userID, id string) (domain.TaskStatus, error) + Result(ctx context.Context, userID, id string) (*domain.Result, error) } type UserRepository interface { - SaveUser(user *domain.User) error - GetUserByID(id string) (*domain.User, error) - GetUserByLogin(login string) (*domain.User, error) + SaveUser(ctx context.Context, user *domain.User) error + GetUserByID(ctx context.Context, id string) (*domain.User, error) + GetUserByLogin(ctx context.Context, login string) (*domain.User, error) } type UserService interface { - Register(login, password string) error - Login(login, password string) (string, error) + Register(ctx context.Context, login, password string) error + Login(ctx context.Context, login, password string) (string, error) } type SessionRepository interface { - CreateSession(session *domain.Session) error - GetSession(sessionID string) (*domain.Session, error) - DeleteSession(sessionID string) error + CreateSession(ctx context.Context, session *domain.Session) error + GetSession(ctx context.Context, sessionID string) (*domain.Session, error) + DeleteSession(ctx context.Context, sessionID string) error } type AuthService interface { - Authenticate(token string) (string, error) + Authenticate(ctx context.Context, token string) (string, error) } type CodeExecutor interface { From b52d1d1a544ea52aaa42a6734dacb700e8f2e766 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Mon, 7 Sep 2026 14:04:43 +0300 Subject: [PATCH 74/78] fix(repository): accepting and using passed contexts instead of context.Background() --- internal/repository/postgres/task.go | 16 ++++++++-------- internal/repository/postgres/user.go | 16 ++++++++-------- internal/repository/redis/repository.go | 12 ++++++------ 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/internal/repository/postgres/task.go b/internal/repository/postgres/task.go index ea4842b..63b1af8 100644 --- a/internal/repository/postgres/task.go +++ b/internal/repository/postgres/task.go @@ -10,15 +10,15 @@ import ( "github.com/belyaevedu/remote-code-service/internal/domain" ) -func (r *Repository) SaveTask(task *domain.Task) error { - _, err := r.pool.Exec(context.Background(), +func (r *Repository) SaveTask(ctx context.Context, task *domain.Task) error { + _, err := r.pool.Exec(ctx, `INSERT INTO tasks (id, user_id, status, translator) VALUES ($1, $2, $3, $4)`, task.ID, task.UserID, task.Status, task.Translator, ) return err } -func (r *Repository) GetTask(id string) (*domain.Task, error) { +func (r *Repository) GetTask(ctx context.Context, id string) (*domain.Task, error) { var ( userID string status domain.TaskStatus @@ -26,7 +26,7 @@ func (r *Repository) GetTask(id string) (*domain.Task, error) { result []byte ) - err := r.pool.QueryRow(context.Background(), + err := r.pool.QueryRow(ctx, `SELECT user_id, status, translator, result FROM tasks WHERE id = $1`, id, ).Scan(&userID, &status, &translator, &result) if errors.Is(err, pgx.ErrNoRows) { @@ -52,8 +52,8 @@ func (r *Repository) GetTask(id string) (*domain.Task, error) { return task, nil } -func (r *Repository) UpdateTaskStatus(id string, status domain.TaskStatus) error { - tag, err := r.pool.Exec(context.Background(), +func (r *Repository) UpdateTaskStatus(ctx context.Context, id string, status domain.TaskStatus) error { + tag, err := r.pool.Exec(ctx, `UPDATE tasks SET status = $1 WHERE id = $2`, status, id, ) if err != nil { @@ -65,7 +65,7 @@ func (r *Repository) UpdateTaskStatus(id string, status domain.TaskStatus) error return nil } -func (r *Repository) SaveTaskResult(id string, result *domain.Result) error { +func (r *Repository) SaveTaskResult(ctx context.Context, id string, result *domain.Result) error { if result == nil { result = &domain.Result{} } @@ -75,7 +75,7 @@ func (r *Repository) SaveTaskResult(id string, result *domain.Result) error { return err } - tag, err := r.pool.Exec(context.Background(), + tag, err := r.pool.Exec(ctx, `UPDATE tasks SET status = 'ready', result = $1, finished_at = now() WHERE id = $2`, diff --git a/internal/repository/postgres/user.go b/internal/repository/postgres/user.go index 338463a..871940f 100644 --- a/internal/repository/postgres/user.go +++ b/internal/repository/postgres/user.go @@ -9,31 +9,31 @@ import ( "github.com/belyaevedu/remote-code-service/internal/domain" ) -func (r *Repository) SaveUser(user *domain.User) error { +func (r *Repository) SaveUser(ctx context.Context, user *domain.User) error { // using the UNIQUE constraint on login - _, err := r.pool.Exec(context.Background(), + _, err := r.pool.Exec(ctx, `INSERT INTO users (id, login, password_hash) VALUES ($1, $2, $3)`, user.ID, user.Login, user.Password, ) return mapPgError(err) } -func (r *Repository) GetUserByID(id string) (*domain.User, error) { - return r.getUser("SELECT id, login, password_hash FROM users WHERE id = $1", id) +func (r *Repository) GetUserByID(ctx context.Context, id string) (*domain.User, error) { + return r.getUser(ctx, "SELECT id, login, password_hash FROM users WHERE id = $1", id) } -func (r *Repository) GetUserByLogin(login string) (*domain.User, error) { - return r.getUser("SELECT id, login, password_hash FROM users WHERE login = $1", login) +func (r *Repository) GetUserByLogin(ctx context.Context, login string) (*domain.User, error) { + return r.getUser(ctx, "SELECT id, login, password_hash FROM users WHERE login = $1", login) } -func (r *Repository) getUser(query, arg string) (*domain.User, error) { +func (r *Repository) getUser(ctx context.Context, query, arg string) (*domain.User, error) { var ( id string found string password string ) - err := r.pool.QueryRow(context.Background(), query, arg).Scan(&id, &found, &password) + err := r.pool.QueryRow(ctx, query, arg).Scan(&id, &found, &password) if errors.Is(err, pgx.ErrNoRows) { return nil, domain.ErrUserNotFound } diff --git a/internal/repository/redis/repository.go b/internal/repository/redis/repository.go index 1f1ba04..950a4fc 100644 --- a/internal/repository/redis/repository.go +++ b/internal/repository/redis/repository.go @@ -46,14 +46,14 @@ func (r *Repository) Close() error { return r.client.Close() } -func (r *Repository) CreateSession(session *domain.Session) error { - return r.client.Set(context.Background(), +func (r *Repository) CreateSession(ctx context.Context, session *domain.Session) error { + return r.client.Set(ctx, sessionKeyPrefix+session.SessionID, session.UserID, r.ttl, ).Err() } -func (r *Repository) GetSession(sessionID string) (*domain.Session, error) { - userID, err := r.client.Get(context.Background(), +func (r *Repository) GetSession(ctx context.Context, sessionID string) (*domain.Session, error) { + userID, err := r.client.Get(ctx, sessionKeyPrefix+sessionID, ).Result() if errors.Is(err, redis.Nil) { @@ -69,8 +69,8 @@ func (r *Repository) GetSession(sessionID string) (*domain.Session, error) { }, nil } -func (r *Repository) DeleteSession(sessionID string) error { - removed, err := r.client.Del(context.Background(), +func (r *Repository) DeleteSession(ctx context.Context, sessionID string) error { + removed, err := r.client.Del(ctx, sessionKeyPrefix+sessionID, ).Result() if err != nil { From 7c84a6977fca67a35eef0367f27af77bcafa9e58 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Mon, 7 Sep 2026 14:06:13 +0300 Subject: [PATCH 75/78] fix(service/user): accepting and passing context --- internal/service/user.go | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/internal/service/user.go b/internal/service/user.go index f5ed0ef..8f29da9 100644 --- a/internal/service/user.go +++ b/internal/service/user.go @@ -1,6 +1,7 @@ package service import ( + "context" "crypto/rand" "encoding/hex" "errors" @@ -28,12 +29,12 @@ func NewUserService(users port.UserRepository, sessions port.SessionRepository) return &UserService{users: users, sessions: sessions} } -func (s *UserService) Register(login, password string) error { +func (s *UserService) Register(ctx context.Context, login, password string) error { if login == "" || password == "" { return domain.ErrInvalidCredentials } - if existing, err := s.users.GetUserByLogin(login); err == nil { + if existing, err := s.users.GetUserByLogin(ctx, login); err == nil { // tests basically involve a double register // with the same login:pass, so this logic is here to pass the tests if comparePassword(existing.Password, password) != nil { @@ -55,12 +56,12 @@ func (s *UserService) Register(login, password string) error { Password: hashed, } - if err := s.users.SaveUser(user); err != nil { + if err := s.users.SaveUser(ctx, user); err != nil { if !errors.Is(err, domain.ErrUserAlreadyExists) { return err } // possible concurrent registration race - stored, gErr := s.users.GetUserByLogin(login) + stored, gErr := s.users.GetUserByLogin(ctx, login) if gErr != nil { return gErr } @@ -73,8 +74,8 @@ func (s *UserService) Register(login, password string) error { return nil } -func (s *UserService) Login(login, password string) (string, error) { - user, err := s.users.GetUserByLogin(login) +func (s *UserService) Login(ctx context.Context, login, password string) (string, error) { + user, err := s.users.GetUserByLogin(ctx, login) if err != nil { if errors.Is(err, domain.ErrUserNotFound) { return "", domain.ErrInvalidCredentials @@ -96,15 +97,15 @@ func (s *UserService) Login(login, password string) (string, error) { SessionID: token, } - if err := s.sessions.CreateSession(session); err != nil { + if err := s.sessions.CreateSession(ctx, session); err != nil { return "", err } return token, nil } -func (s *UserService) Authenticate(token string) (string, error) { - session, err := s.sessions.GetSession(token) +func (s *UserService) Authenticate(ctx context.Context, token string) (string, error) { + session, err := s.sessions.GetSession(ctx, token) if err != nil { return "", domain.ErrUnauthorized } From 093fd90734fd95c9647913b74acec4190ab75801 Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Mon, 7 Sep 2026 14:07:10 +0300 Subject: [PATCH 76/78] fix(service/task): using prev. passed context, accepting and passing context --- internal/service/task.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/service/task.go b/internal/service/task.go index b648dc1..b9f02a1 100644 --- a/internal/service/task.go +++ b/internal/service/task.go @@ -44,7 +44,7 @@ func (s *TaskService) Submit(ctx context.Context, userID string, sub domain.Subm Translator: sub.Translator, } - if err := s.repo.SaveTask(task); err != nil { + if err := s.repo.SaveTask(ctx, task); err != nil { return "", err } @@ -61,16 +61,16 @@ func (s *TaskService) Submit(ctx context.Context, userID string, sub domain.Subm return id, nil } -func (s *TaskService) Status(userID, id string) (domain.TaskStatus, error) { - t, err := s.getOwnedTask(userID, id) +func (s *TaskService) Status(ctx context.Context, userID, id string) (domain.TaskStatus, error) { + t, err := s.getOwnedTask(ctx, userID, id) if err != nil { return "", err } return t.Status, nil } -func (s *TaskService) Result(userID, id string) (*domain.Result, error) { - t, err := s.getOwnedTask(userID, id) +func (s *TaskService) Result(ctx context.Context, userID, id string) (*domain.Result, error) { + t, err := s.getOwnedTask(ctx, userID, id) if err != nil { return nil, err } @@ -82,12 +82,12 @@ func (s *TaskService) Result(userID, id string) (*domain.Result, error) { // fetches the task by id and verifies it belongs to a set user // task owned by someone else results in ErrAccessDenied -func (s *TaskService) getOwnedTask(userID, id string) (*domain.Task, error) { +func (s *TaskService) getOwnedTask(ctx context.Context, userID, id string) (*domain.Task, error) { if userID == "" { return nil, domain.ErrAccessDenied } - t, err := s.repo.GetTask(id) + t, err := s.repo.GetTask(ctx, id) if err != nil { return nil, err } From d3355dff43734a092b2616965d89252dabe0524c Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Mon, 7 Sep 2026 14:07:29 +0300 Subject: [PATCH 77/78] fix(controller/handlers): passing request contexts --- internal/controller/handlers/middleware.go | 2 +- internal/controller/handlers/task.go | 4 ++-- internal/controller/handlers/user.go | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/controller/handlers/middleware.go b/internal/controller/handlers/middleware.go index 6688786..4b374ad 100644 --- a/internal/controller/handlers/middleware.go +++ b/internal/controller/handlers/middleware.go @@ -36,7 +36,7 @@ func AuthMiddleware(auth port.AuthService) func(http.Handler) http.Handler { return } - userID, err := auth.Authenticate(token) + userID, err := auth.Authenticate(r.Context(), token) if err != nil { unauthorizedResponseHelper(w, "invalid or expired session") return diff --git a/internal/controller/handlers/task.go b/internal/controller/handlers/task.go index afb1521..1aa1812 100644 --- a/internal/controller/handlers/task.go +++ b/internal/controller/handlers/task.go @@ -106,7 +106,7 @@ func (h *TaskHandlers) Status(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "task_id") - status, err := h.taskSvc.Status(userID, id) + status, err := h.taskSvc.Status(r.Context(), userID, id) if err != nil { writeTaskError(w, err) return @@ -135,7 +135,7 @@ func (h *TaskHandlers) Result(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "task_id") - result, err := h.taskSvc.Result(userID, id) + result, err := h.taskSvc.Result(r.Context(), userID, id) if err != nil { writeTaskError(w, err) return diff --git a/internal/controller/handlers/user.go b/internal/controller/handlers/user.go index 6e07d7c..ef2c407 100644 --- a/internal/controller/handlers/user.go +++ b/internal/controller/handlers/user.go @@ -52,7 +52,7 @@ func (h *UserHandlers) Register(w http.ResponseWriter, r *http.Request) { return } - if err := h.userSvc.Register(req.Username, req.Password); err != nil { + if err := h.userSvc.Register(r.Context(), req.Username, req.Password); err != nil { if errors.Is(err, domain.ErrInvalidCredentials) { WriteJSON(w, http.StatusBadRequest, ErrorResponse{Error: err.Error()}) return @@ -87,7 +87,7 @@ func (h *UserHandlers) Login(w http.ResponseWriter, r *http.Request) { return } - token, err := h.userSvc.Login(req.Username, req.Password) + token, err := h.userSvc.Login(r.Context(), req.Username, req.Password) if err != nil { if errors.Is(err, domain.ErrInvalidCredentials) { WriteJSON(w, http.StatusUnauthorized, ErrorResponse{Error: err.Error()}) From d97484d9f7c293986640b181e9d94ff08726d6fa Mon Sep 17 00:00:00 2001 From: belyaevEDU Date: Mon, 7 Sep 2026 14:08:01 +0300 Subject: [PATCH 78/78] fix(cmd/processor): passing context to db method --- cmd/processor/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/processor/main.go b/cmd/processor/main.go index da05faf..2d1dc2e 100644 --- a/cmd/processor/main.go +++ b/cmd/processor/main.go @@ -75,7 +75,7 @@ func main() { return fmt.Errorf("execute task %s: %w", msg.TaskID, err) } - return db.SaveTaskResult(msg.TaskID, &domain.Result{Output: result.Output}) + return db.SaveTaskResult(ctx, msg.TaskID, &domain.Result{Output: result.Output}) } log.Printf("processor: consuming queue %q", queueCfg.Queue)