Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
330f95a375 | ||
|
|
e744e62d03 | ||
|
|
b9f47e556c | ||
|
|
e6b489f117 | ||
|
|
73dde99e3a | ||
|
|
ecc8944006 | ||
|
|
dc15e2d04b | ||
|
|
ff1fc25d77 | ||
|
|
6b67326c3b | ||
|
|
d57f8778ac |
@@ -110,6 +110,8 @@ jobs:
|
||||
artifacts/*
|
||||
install.sh
|
||||
install.ps1
|
||||
uninstall.sh
|
||||
uninstall.ps1
|
||||
# Pre-release tags (e.g. v1.1.0-alpha.1) publish as pre-releases.
|
||||
prerelease: ${{ contains(github.ref_name, '-alpha') }}
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -38,6 +38,22 @@
|
||||
## Plan (предыдущая задача — выполнена)
|
||||
1–7. Docker E2E пайплайна «install как человек → serve → визард → воркер → джоб» — выполнено, см. Progress ниже.
|
||||
|
||||
## Progress (день: конкурентность в агенте — параллелизм через нашу архитектуру)
|
||||
- ✅ По просьбе «реализовать параллелизм через нашу архитектуру»: `WORKER_CONCURRENCY` (поле `concurrency` в конфиге визарда, шаг Machine) — агент регистрируется один раз и ведёт N циклов claim→выполнение→upload под одним worker id. N шардов обрабатываются параллельно на одной машине, используя таски координатора как единицу параллелизма; SDK не менялся.
|
||||
- ✅ Реализация: `Config.Concurrency` + env; `Daemon.RunForever` → register once → N goroutine-циклов (общий счётчик MaxTasks под мьютексом); визард: поле «Concurrent task loops» + конфиг; тест с маркер-скриптом доказывает 3 параллельных исполнения (race-тесты зелёные после мьютекса в fake).
|
||||
- ✅ Измерено на релизном коде в Docker (один воркер, 8 шардов): concurrency=1 → 12s; concurrency=4 → 4s (**3×**). Плюс прежний `similarity-search-parallel` (потоки внутри шарда) композируется с конкурентностью.
|
||||
- ✅ Релиз v1.1.0-alpha.20 (бинарники + wheel); полный гейт: race + lint 0 issues + pytest 213.
|
||||
- ✅ Бинарник worker-agent на машине пользователя обновлён до alpha.20.
|
||||
- GIL-высвобождения в RDKit нет (проверено: `RDKIT_ALLOW_GIL_RELEASE` не помогает), поэтому внутришардовые потоки не ускоряют чистый RDKit-путь — конкурентность задач это и компенсирует.
|
||||
|
||||
## Progress (утро: similarity-search-parallel)
|
||||
- ✅ Новый workload `similarity-search-parallel@1.0.0` (отдельная версия, как просил пользователь): подкласс `SimilaritySearchSDKWorkload` + параллельное ядро `search_parallel/core.py` — fingerprinting+скоринг шарда через `ThreadPoolExecutor` (параметр `threads`, default CPU count); `pool.map` сохраняет порядок строк, поэтому merge идентичен последовательному (`_HeapEntry`) и результат **байт-в-байт** равен `similarity-search` при любом числе потоков.
|
||||
- ✅ Тесты: байт-в-байт vs эталон для threads 1/2/4 с намеренными связями (изомеры, дубликаты), executor-прогон, валидация параметров, регистрация манифеста; 213 pytest зелёные.
|
||||
- ✅ Экспортирован в каталог координатора (5 ворклоадов), Go-тесты зелёные; release v1.1.0-alpha.19 (бинарники + wheel `scimesh-1.1.0a19`).
|
||||
- ✅ Распределённый E2E в Docker: воркер с wheel a19, джоб similarity-search-parallel (threads=4) → completed → результат байт-в-байт = локальному эталону.
|
||||
- ✅ Бинарники на машине пользователя обновлены до alpha.19 (для появления ворклоада в UI нужен рестарт serve + переустановка рантайма воркера).
|
||||
- Примечание: в CPython потоки не ускоряют чистый RDKit-путь (GIL), но структура готова к ядрам, отпускающим GIL (numpy и т.п.); при желании можно добавить процесс-пул как отдельный workload в будущей версии протокола.
|
||||
|
||||
## Progress (ночная сессия)
|
||||
- ✅ **П.1 Пустое имя воркера**: `domain.NewWorker` нормализует/отклоняет пустое имя + `TestNewWorkerRejectsBlankName` (починен `fixedTime`→`testNow`).
|
||||
- ✅ **П.2 `--check`**: пробует managed venv (если установлен) + реальный пробинг учётки (exchange ключа / claim-пробa) — `CheckAuth` + тесты; на машине пользователя: `✓ auth: credential accepted`, venv python, scimesh installed.
|
||||
@@ -59,8 +75,18 @@
|
||||
- ✅ На машине пользователя: визард alpha.15, правильный токен, venv из wheel, воркер emil-pc online, 15 пустых воркеров вычищены из БД.
|
||||
- ✅ Гейт: race + lint + pytest 208.
|
||||
|
||||
## Progress (день 2: контроль версий + изоляция)
|
||||
- ✅ Визард сверяет установленную версию scimesh с версией бинарника: preflight показывает «installed X, worker needs Y — press Install» и снова предлагает кнопку Install (ensureMatchingScimeshVersion, dev-сборки пропускают). Проверено на машине: a21 vs a22 → красный → Install → зелёный 1.1.0a22.
|
||||
- ✅ Все проверки и task-раннеры запускаются с `-I` (isolated): каталог запуска больше не перекрывает venv (был артефакт cwd-затенения исходниками). Затронуты: check, дефолтные раннеры агента, визард (config/save/start), serve, agent_cmd.
|
||||
- ✅ Релиз v1.1.0-alpha.22; полный гейт (race + lint 0 issues + pytest 213); бинарник юзера обновлён, рантайм переустановлен (a22), воркер с concurrency=4; большой джоб юзера (1934 шарда) завершён (очередь 0).
|
||||
|
||||
## Completion
|
||||
COMPLETED — ночной план выполнен полностью (10 пунктов + 2 найденных бага, включая E2E quorum на релизном коде). Все гейты зелёные, релиз v1.1.0-alpha.16 опубликован.
|
||||
|
||||
## Progress (день 2: контроль версий + изоляция)
|
||||
- ✅ Визард сверяет установленную версию scimesh с версией бинарника: preflight показывает «installed X, worker needs Y — press Install» и снова предлагает кнопку Install (ensureMatchingScimeshVersion, dev-сборки пропускают). Проверено на машине: a21 vs a22 → красный → Install → зелёный 1.1.0a22.
|
||||
- ✅ Все проверки и task-раннеры запускаются с `-I` (isolated): каталог запуска больше не перекрывает venv (был артефакт cwd-затенения исходниками). Затронуты: check, дефолтные раннеры агента, визард (config/save/start), serve, agent_cmd.
|
||||
- ✅ Релиз v1.1.0-alpha.22; полный гейт (race + lint 0 issues + pytest 213); бинарник юзера обновлён, рантайм переустановлен (a22), воркер с concurrency=4; большой джоб юзера (1934 шарда) завершён (очередь 0).
|
||||
|
||||
## Completion (предыдущая задача)
|
||||
COMPLETED — пайплайн доведён до рабочего состояния и проверен на релизных артефактах v1.1.0-alpha.14.
|
||||
|
||||
@@ -61,7 +61,16 @@ powershell -ExecutionPolicy Bypass -c "irm https://raw.githubusercontent.com/emi
|
||||
```
|
||||
|
||||
Set `SCIMESH_AUTO_START=0` to install without starting anything. The old demo
|
||||
control room was removed: `/ui` is the admin console. A standalone
|
||||
control room was removed: `/ui` is the admin console.
|
||||
|
||||
To remove a component, run the matching uninstaller (data is kept unless you
|
||||
pass `--purge`):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/emil28092005/SciMesh/main/uninstall.sh | bash -s coordinator
|
||||
curl -fsSL https://raw.githubusercontent.com/emil28092005/SciMesh/main/uninstall.sh | bash -s worker --purge
|
||||
# Windows: irm .../uninstall.ps1 | iex (set $env:SCIMESH_COMPONENT, -Purge deletes data)
|
||||
``` A standalone
|
||||
worker is installed the same way (`bash -s worker`, or
|
||||
`SCIMESH_COMPONENT=worker` on Windows); its installer opens the local setup
|
||||
wizard (`worker-agent setup`) in the browser automatically.
|
||||
|
||||
@@ -45,7 +45,7 @@ func runAgent(args []string) error {
|
||||
return fmt.Errorf("--coordinator-url, --token, and --work-dir are required")
|
||||
}
|
||||
if *taskRunner == "" {
|
||||
*taskRunner = "python -m scimesh.worker.task"
|
||||
*taskRunner = "python -I -m scimesh.worker.task"
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -96,18 +97,16 @@ func runServe(args []string) error {
|
||||
defer func() { _ = closeUsers() }()
|
||||
|
||||
// 5. Local worker agents before the server, so they can claim immediately.
|
||||
coordinatorURL := "http://" + *addr
|
||||
agents, err := spawnAgents(ctx, log, *dataDir, *workers, coordinatorURL, workerToken, venvPython)
|
||||
// They always dial the loopback address: an --addr of 0.0.0.0 is not a
|
||||
// connectable target from the same host.
|
||||
agentURL, resolvedPublic := serveURLs(*addr, *publicURL)
|
||||
agents, err := spawnAgents(ctx, log, *dataDir, *workers, agentURL, workerToken, venvPython)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stopAgents(agents)
|
||||
|
||||
// 6. The coordinator server itself.
|
||||
coordinatorPublicURL := *publicURL
|
||||
if coordinatorPublicURL == "" {
|
||||
coordinatorPublicURL = "http://" + *addr
|
||||
}
|
||||
cfg := infra.Config{
|
||||
Addr: *addr,
|
||||
DatabaseEngine: "sqlite",
|
||||
@@ -115,8 +114,10 @@ func runServe(args []string) error {
|
||||
Token: workerToken,
|
||||
JWTSecret: jwtSecret,
|
||||
UserserviceURL: "http://" + usersAddr,
|
||||
PublicCoordinatorURL: coordinatorPublicURL,
|
||||
PublicUserserviceURL: "http://" + usersAddr,
|
||||
PublicCoordinatorURL: resolvedPublic,
|
||||
// The exchange is fronted by the coordinator's own proxy, so the UI
|
||||
// falls back to the coordinator origin for USERSERVICE_URL.
|
||||
PublicUserserviceURL: "",
|
||||
LogLevel: "info",
|
||||
StorageDir: filepath.Join(*dataDir, "artifacts"),
|
||||
DocsDir: *docsDir,
|
||||
@@ -132,12 +133,13 @@ func runServe(args []string) error {
|
||||
WorkerOfflineAfter: 1 * time.Minute,
|
||||
AutoMigrate: true,
|
||||
}
|
||||
browserURL, _ := serveURLs(*addr, *publicURL)
|
||||
if *open {
|
||||
openBrowser("http://" + *addr + "/ui/admin")
|
||||
openBrowser(browserURL + "/ui/admin")
|
||||
}
|
||||
|
||||
// Print the login once the server is about to start.
|
||||
fmt.Printf("\nSciMesh is starting at http://%s/ui\n", *addr)
|
||||
fmt.Printf("\nSciMesh is starting at %s/ui\n", browserURL)
|
||||
fmt.Printf(" admin login: %s / %s\n", *email, *password)
|
||||
if runtimeStatus(venvPython) {
|
||||
fmt.Printf(" scientific runtime: ready (%s)\n", venvPython)
|
||||
@@ -236,9 +238,9 @@ func stopAgents(agents []*exec.Cmd) {
|
||||
// system `python`.
|
||||
func defaultTaskRunner(venvPython string) string {
|
||||
if runtimeStatus(venvPython) {
|
||||
return venvPython + " -m scimesh.worker.task"
|
||||
return venvPython + " -I -m scimesh.worker.task"
|
||||
}
|
||||
return "python -m scimesh.worker.task"
|
||||
return "python -I -m scimesh.worker.task"
|
||||
}
|
||||
|
||||
// ensureRuntime creates the managed venv and installs scimesh into it, unless
|
||||
@@ -337,3 +339,33 @@ func openBrowser(target string) {
|
||||
// #nosec G204 -- target is the local UI URL the operator asked to open.
|
||||
_ = exec.CommandContext(context.Background(), command, target).Start()
|
||||
}
|
||||
|
||||
// serveURLs derives the two addresses of a serve instance from the listen
|
||||
// address and the optional --public-url flag:
|
||||
//
|
||||
// - the agent URL is always the loopback form of the port, because spawned
|
||||
// local workers share the host and 0.0.0.0 is not connectable from it;
|
||||
// - the public URL is what browsers and remote workers are told. An explicit
|
||||
// --public-url wins; a listen host that is a real address is used as-is;
|
||||
// a wildcard host (0.0.0.0, ::, or empty) yields an empty public URL, so
|
||||
// the UI falls back to the browser's own origin (the coordinator's LAN
|
||||
// address as the browser sees it).
|
||||
func serveURLs(addr, publicURL string) (agentURL, resolvedPublic string) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
// No port in the listen address: assume the default and treat the
|
||||
// whole string as a host (e.g. a bare wildcard).
|
||||
host, port = addr, "8080"
|
||||
}
|
||||
agentURL = "http://127.0.0.1:" + port
|
||||
if publicURL != "" {
|
||||
return agentURL, publicURL
|
||||
}
|
||||
host = strings.Trim(host, "[]")
|
||||
switch host {
|
||||
case "", "0.0.0.0", "::":
|
||||
return agentURL, ""
|
||||
default:
|
||||
return agentURL, "http://" + addr
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestServeURLs(t *testing.T) {
|
||||
cases := []struct {
|
||||
addr, public, agent, resolved string
|
||||
}{
|
||||
{"127.0.0.1:8080", "", "http://127.0.0.1:8080", "http://127.0.0.1:8080"},
|
||||
{"0.0.0.0:8080", "", "http://127.0.0.1:8080", ""},
|
||||
{":8080", "", "http://127.0.0.1:8080", ""},
|
||||
{"::", "", "http://127.0.0.1:8080", ""},
|
||||
{"192.168.1.10:8080", "", "http://127.0.0.1:8080", "http://192.168.1.10:8080"},
|
||||
{"0.0.0.0:8080", "http://cluster.example:8080", "http://127.0.0.1:8080", "http://cluster.example:8080"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
agent, resolved := serveURLs(c.addr, c.public)
|
||||
if agent != c.agent || resolved != c.resolved {
|
||||
t.Errorf("serveURLs(%q, %q) = (%q, %q), want (%q, %q)", c.addr, c.public, agent, resolved, c.agent, c.resolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,8 +88,12 @@ func CheckEnvironment(ctx context.Context) CheckReport {
|
||||
// execute with.
|
||||
func CheckEnvironmentWithPython(ctx context.Context, python string) CheckReport {
|
||||
report := CheckReport{Agent: Version, Python: CheckItem{Name: "python", OK: true, Detail: python}}
|
||||
// The version comes from importlib.metadata, so the wizard can compare the
|
||||
// installed package with the binary version and offer an upgrade. -I keeps
|
||||
// the working directory out of sys.path, so a scimesh checkout in the
|
||||
// wizard's cwd can never shadow the venv installation.
|
||||
//nolint:gosec // G204: python is a resolved interpreter path, the argument list is constant
|
||||
cmd := exec.CommandContext(ctx, python, "-c", "import scimesh; print(scimesh.__version__ if hasattr(scimesh, '__version__') else 'installed')")
|
||||
cmd := exec.CommandContext(ctx, python, "-I", "-c", "import importlib.metadata as m; print(m.version('scimesh'))")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
// The worker executes workloads by spawning scimesh's task runner, so
|
||||
|
||||
@@ -48,6 +48,12 @@ type Client struct {
|
||||
}
|
||||
|
||||
func NewClient(baseURL string, tokens TokenProvider, timeout time.Duration) *Client {
|
||||
// Payload transfers get a more generous budget than control calls: a large
|
||||
// shard over a slow link easily outlives the API timeout.
|
||||
transferTimeout := timeout * 4
|
||||
if transferTimeout < 2*time.Minute {
|
||||
transferTimeout = 2 * time.Minute
|
||||
}
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
tokens: tokens,
|
||||
@@ -57,7 +63,7 @@ func NewClient(baseURL string, tokens TokenProvider, timeout time.Duration) *Cli
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||
},
|
||||
dlClient: &http.Client{
|
||||
Timeout: timeout,
|
||||
Timeout: transferTimeout,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
|
||||
@@ -223,3 +223,17 @@ func sha256Of(t *testing.T, value string) string {
|
||||
digest := sha256.Sum256([]byte(value))
|
||||
return fmt.Sprintf("%x", digest)
|
||||
}
|
||||
|
||||
func TestNewClientTransferTimeoutExceedsAPITimeout(t *testing.T) {
|
||||
c := NewClient("http://coord:8080", &StaticToken{token: "t"}, 30*time.Second)
|
||||
if c.apiClient.Timeout != 30*time.Second {
|
||||
t.Errorf("api timeout = %v, want 30s", c.apiClient.Timeout)
|
||||
}
|
||||
if c.dlClient.Timeout < 2*time.Minute {
|
||||
t.Errorf("transfer timeout = %v, want at least 2m", c.dlClient.Timeout)
|
||||
}
|
||||
short := NewClient("http://coord:8080", &StaticToken{token: "t"}, 3*time.Minute)
|
||||
if short.dlClient.Timeout != 12*time.Minute {
|
||||
t.Errorf("transfer timeout = %v, want 4x the api timeout", short.dlClient.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,10 @@ type Config struct {
|
||||
TaskRunner []string // command + args; defaults to python -m scimesh.worker.task
|
||||
MaxTasks int // 0 = unlimited
|
||||
ExitWhenIdle bool
|
||||
// Concurrency is how many claim→execute→upload loops run in parallel
|
||||
// under one worker id: N shards processed concurrently on one machine,
|
||||
// using the coordinator's own task pipeline as the parallel unit.
|
||||
Concurrency int
|
||||
}
|
||||
|
||||
func envList(name string) ([]string, error) {
|
||||
@@ -108,7 +112,7 @@ func LoadConfig() (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
if len(runner) == 0 {
|
||||
runner = []string{"python", "-m", "scimesh.worker.task"}
|
||||
runner = []string{"python", "-I", "-m", "scimesh.worker.task"}
|
||||
}
|
||||
maxTasks := 0
|
||||
if raw := os.Getenv("MAX_TASKS"); raw != "" {
|
||||
@@ -145,9 +149,22 @@ func LoadConfig() (*Config, error) {
|
||||
TaskRunner: runner,
|
||||
MaxTasks: maxTasks,
|
||||
ExitWhenIdle: os.Getenv("EXIT_WHEN_IDLE") == "1",
|
||||
Concurrency: envInt("WORKER_CONCURRENCY", 1),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func envInt(name string, fallback int) int {
|
||||
raw := os.Getenv(name)
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil || parsed < 1 {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func durationEnv(name string, fallback time.Duration) (time.Duration, error) {
|
||||
raw := os.Getenv(name)
|
||||
if raw == "" {
|
||||
|
||||
@@ -21,6 +21,7 @@ type ConfigFile struct {
|
||||
WorkerName string `json:"worker_name,omitempty"`
|
||||
CPUCount int `json:"cpu_count"`
|
||||
MemoryMB int `json:"memory_mb"`
|
||||
Concurrency int `json:"concurrency,omitempty"`
|
||||
TaskRunner []string `json:"task_runner,omitempty"`
|
||||
}
|
||||
|
||||
@@ -103,11 +104,15 @@ func (f *ConfigFile) Config() (*Config, error) {
|
||||
if config.MemoryMB < 0 {
|
||||
config.MemoryMB = 0
|
||||
}
|
||||
config.Concurrency = f.Concurrency
|
||||
if config.Concurrency < 1 {
|
||||
config.Concurrency = 1
|
||||
}
|
||||
if len(f.TaskRunner) > 0 {
|
||||
config.TaskRunner = f.TaskRunner
|
||||
}
|
||||
if len(config.TaskRunner) == 0 {
|
||||
config.TaskRunner = []string{"python", "-m", "scimesh.worker.task"}
|
||||
config.TaskRunner = []string{"python", "-I", "-m", "scimesh.worker.task"}
|
||||
}
|
||||
config.PollInterval = 2 * time.Second
|
||||
config.RequestTimeout = 30 * time.Second
|
||||
|
||||
@@ -37,15 +37,52 @@ func NewDaemon(config *Config, client *Client, runner *TaskRunner, log *slog.Log
|
||||
return &Daemon{config: config, client: client, runner: runner, log: log}
|
||||
}
|
||||
|
||||
// RunForever loops until interrupted, idle-exit, or max tasks.
|
||||
// RunForever registers once, then runs the claim→execute→upload loop
|
||||
// concurrently under one worker id. With Concurrency > 1, several shards are
|
||||
// processed in parallel on this machine, using the coordinator's own task
|
||||
// pipeline as the parallel unit.
|
||||
func (d *Daemon) RunForever() error {
|
||||
if !d.registered {
|
||||
if err := d.register(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
workers := d.config.Concurrency
|
||||
if workers < 1 {
|
||||
workers = 1
|
||||
}
|
||||
if workers == 1 {
|
||||
return d.loop()
|
||||
}
|
||||
d.log.Info("agent running concurrently", "loops", workers)
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, workers)
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func(loop int) {
|
||||
defer wg.Done()
|
||||
if err := d.loop(); err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
errs <- nil
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loop is one claim→execute→upload cycle until interrupted, idle-exit, or
|
||||
// the shared max-tasks budget is consumed.
|
||||
func (d *Daemon) loop() error {
|
||||
failures := 0
|
||||
for {
|
||||
if !d.registered {
|
||||
if err := d.register(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
d.cleanupExpiredDirectories()
|
||||
outcome, err := d.runOnce()
|
||||
if err != nil {
|
||||
@@ -63,8 +100,11 @@ func (d *Daemon) RunForever() error {
|
||||
}
|
||||
failures = 0
|
||||
if outcome.Claimed && outcome.Completed {
|
||||
d.mu.Lock()
|
||||
d.completed++
|
||||
if d.config.MaxTasks > 0 && d.completed >= d.config.MaxTasks {
|
||||
done := d.config.MaxTasks > 0 && d.completed >= d.config.MaxTasks
|
||||
d.mu.Unlock()
|
||||
if done {
|
||||
d.log.Info("max tasks reached")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -55,6 +56,7 @@ type fakeCoordinator struct {
|
||||
uploadSize int64
|
||||
inputBytes []byte
|
||||
conflict bool // 409 on heartbeat/upload/result
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newFakeCoordinator(t *testing.T, task map[string]any) *fakeCoordinator {
|
||||
@@ -64,6 +66,8 @@ func newFakeCoordinator(t *testing.T, task map[string]any) *fakeCoordinator {
|
||||
fake.uploadSize = int64(len(fake.inputBytes))
|
||||
var server *httptest.Server
|
||||
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
switch {
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/workers/register":
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
@@ -294,3 +298,78 @@ func TestDaemonIdleClaimIsNotCompleted(t *testing.T) {
|
||||
t.Fatalf("outcome = %+v", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonConcurrencyProcessesTasksInParallel(t *testing.T) {
|
||||
t.Parallel()
|
||||
marker := filepath.Join(t.TempDir(), "marker")
|
||||
script := filepath.Join(t.TempDir(), "fake-runner.sh")
|
||||
content := `#!/bin/sh
|
||||
out=""
|
||||
task_dir=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--output) out="$2"; shift 2;;
|
||||
--task-dir) task_dir="$2"; shift 2;;
|
||||
*) shift;;
|
||||
esac
|
||||
done
|
||||
echo start >> ` + marker + `
|
||||
sleep 1
|
||||
echo end >> ` + marker + `
|
||||
printf 'id,score\n1,1\n' > "$task_dir/result.csv"
|
||||
printf '{"artifact_path":"%s/result.csv","content_type":"text/csv","metrics":{"rows":1}}' "$task_dir" > "$out"
|
||||
exit 0
|
||||
`
|
||||
if err := os.WriteFile(script, []byte(content), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fake := newFakeCoordinator(t, validClaimedTaskPayload())
|
||||
defer fake.close()
|
||||
config := &Config{
|
||||
CoordinatorURL: fake.server.URL,
|
||||
WorkerName: "concurrent-worker",
|
||||
WorkerID: "22222222-2222-4222-8222-222222222222",
|
||||
WorkDir: t.TempDir(),
|
||||
CPUCount: 1,
|
||||
PollInterval: time.Millisecond,
|
||||
RequestTimeout: 5 * time.Second,
|
||||
Heartbeat: 15 * time.Second,
|
||||
Capabilities: []string{"similarity-search"},
|
||||
TaskRunner: []string{script},
|
||||
MaxTasks: 3,
|
||||
Concurrency: 3,
|
||||
}
|
||||
client := NewClient(fake.server.URL, &StaticToken{token: "test-token"}, 5*time.Second)
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
daemon := NewDaemon(config, client, NewTaskRunner(config.TaskRunner), logger)
|
||||
if err := daemon.RunForever(); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(marker)
|
||||
if err != nil {
|
||||
t.Fatalf("marker: %v", err)
|
||||
}
|
||||
starts := strings.Count(string(raw), "start\n")
|
||||
ends := strings.Count(string(raw), "end\n")
|
||||
if starts < 3 || ends < 3 {
|
||||
t.Fatalf("marker: %d starts / %d ends, want at least 3/3", starts, ends)
|
||||
}
|
||||
// With three loops sleeping 1s each, the marker proves all three ran
|
||||
// concurrently (three starts before the first end completes a 1s sleep).
|
||||
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
|
||||
concurrent := 0
|
||||
running := 0
|
||||
for _, line := range lines {
|
||||
if line == "start" {
|
||||
running++
|
||||
if running > concurrent {
|
||||
concurrent = running
|
||||
}
|
||||
} else {
|
||||
running--
|
||||
}
|
||||
}
|
||||
if concurrent < 3 {
|
||||
t.Errorf("max concurrent executions = %d, want 3", concurrent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,7 +323,7 @@ func (s *Server) ensureVenvTaskRunner() {
|
||||
return
|
||||
}
|
||||
if venv := s.venvPython(); venv != "" {
|
||||
file.TaskRunner = []string{venv, "-m", "scimesh.worker.task"}
|
||||
file.TaskRunner = []string{venv, "-I", "-m", "scimesh.worker.task"}
|
||||
if payload, err := json.MarshalIndent(file, "", " "); err == nil {
|
||||
_ = os.WriteFile(s.cfgPath, append(payload, '\n'), 0o600)
|
||||
}
|
||||
@@ -377,6 +377,7 @@ type saveConfigRequest struct {
|
||||
WorkerName string `json:"worker_name"`
|
||||
CPUCount int `json:"cpu_count"`
|
||||
MemoryMB int `json:"memory_mb"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
TaskRunner []string `json:"task_runner"`
|
||||
}
|
||||
|
||||
@@ -395,6 +396,7 @@ func (s *Server) handleSaveConfig(w http.ResponseWriter, r *http.Request) {
|
||||
WorkerName: strings.TrimSpace(req.WorkerName),
|
||||
CPUCount: req.CPUCount,
|
||||
MemoryMB: req.MemoryMB,
|
||||
Concurrency: req.Concurrency,
|
||||
TaskRunner: req.TaskRunner,
|
||||
}
|
||||
if file.CoordinatorURL == "" {
|
||||
@@ -418,12 +420,15 @@ func (s *Server) handleSaveConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if file.CPUCount < 1 {
|
||||
file.CPUCount = 1
|
||||
}
|
||||
if file.Concurrency < 1 {
|
||||
file.Concurrency = 1
|
||||
}
|
||||
// The wizard UI bakes the venv python into the runner after an install;
|
||||
// an API-driven or scripted flow may not, so the server guarantees it:
|
||||
// workloads execute through scimesh's task runner, which lives in the venv.
|
||||
if len(file.TaskRunner) == 0 {
|
||||
if venv := s.venvPython(); venv != "" {
|
||||
file.TaskRunner = []string{venv, "-m", "scimesh.worker.task"}
|
||||
file.TaskRunner = []string{venv, "-I", "-m", "scimesh.worker.task"}
|
||||
}
|
||||
}
|
||||
if err := agent.SaveConfigFile(s.cfgPath, file); err != nil {
|
||||
@@ -449,6 +454,9 @@ func (s *Server) handleTest(w http.ResponseWriter, r *http.Request) {
|
||||
// checking the bare system python3 would keep reporting scimesh as
|
||||
// missing even though the worker would run with the venv.
|
||||
report := agent.RunCheck(r.Context(), url, s.venvPython(), req.Token, req.WorkerKey, req.UserserviceURL)
|
||||
if report.Scimesh.OK {
|
||||
report.Scimesh = ensureMatchingScimeshVersion(report.Scimesh)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, report)
|
||||
}
|
||||
|
||||
@@ -623,3 +631,26 @@ func truncate(s string, n int) string {
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
|
||||
// ensureMatchingScimeshVersion flips a green scimesh check to a stale one when
|
||||
// the installed package does not match the worker-agent's own version: a
|
||||
// version-locked wheel is the only supported runtime, and a mismatch means the
|
||||
// workload catalog the worker advertises is not what it executes. The wizard
|
||||
// UI then offers the Install button again. Dev builds have no release wheel,
|
||||
// so they skip the comparison.
|
||||
func ensureMatchingScimeshVersion(item agent.CheckItem) agent.CheckItem {
|
||||
if agent.Version == "" || agent.Version == "dev" {
|
||||
return item
|
||||
}
|
||||
want := agent.NormalizePEP440(agent.Version)
|
||||
got := strings.TrimSpace(item.Detail)
|
||||
if got == "" || got == want {
|
||||
return item
|
||||
}
|
||||
item.OK = false
|
||||
item.Detail = fmt.Sprintf(
|
||||
"installed scimesh %s, but this worker-agent (%s) needs %s — press Install to upgrade",
|
||||
got, agent.Version, want,
|
||||
)
|
||||
return item
|
||||
}
|
||||
|
||||
@@ -429,8 +429,8 @@ func TestStartPinsTheVenvTaskRunner(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(config.TaskRunner) != 3 || config.TaskRunner[0] != venvPython || config.TaskRunner[1] != "-m" || config.TaskRunner[2] != "scimesh.worker.task" {
|
||||
t.Errorf("task runner = %v, want the venv python runner", config.TaskRunner)
|
||||
if len(config.TaskRunner) != 4 || config.TaskRunner[0] != venvPython || config.TaskRunner[1] != "-I" || config.TaskRunner[2] != "-m" || config.TaskRunner[3] != "scimesh.worker.task" {
|
||||
t.Errorf("task runner = %v, want the venv python runner with -I", config.TaskRunner)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,8 +450,8 @@ func TestSaveConfigPinsVenvRunnerWhenPresent(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(config.TaskRunner) != 3 || config.TaskRunner[0] != venvPython {
|
||||
t.Errorf("task runner = %v, want the venv python", config.TaskRunner)
|
||||
if len(config.TaskRunner) != 4 || config.TaskRunner[0] != venvPython || config.TaskRunner[1] != "-I" {
|
||||
t.Errorf("task runner = %v, want the venv python with -I", config.TaskRunner)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,7 +462,7 @@ func TestTestProbesTheVenvPythonAfterInstall(t *testing.T) {
|
||||
// a fake scimesh version so the preflight goes green through the venv.
|
||||
venvPython := filepath.Join(server.dir, "venv", "bin", "python")
|
||||
_ = os.MkdirAll(filepath.Dir(venvPython), 0o755)
|
||||
_ = os.WriteFile(venvPython, []byte("#!/bin/sh\nif [ \"$1\" = \"-c\" ]; then echo 9.9.9-test; exit 0; fi\nexit 0\n"), 0o755)
|
||||
_ = os.WriteFile(venvPython, []byte("#!/bin/sh\nfor a in \"$@\"; do if [ \"$a\" = \"-c\" ]; then echo 9.9.9-test; exit 0; fi; done\nexit 0\n"), 0o755)
|
||||
|
||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, base+"/api/test", strings.NewReader(`{"coordinator_url":"http://127.0.0.1:1"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -497,3 +497,26 @@ time=6 level=WARN msg="agent cycle failed" error="boom"
|
||||
t.Errorf("stats = %+v, want registered claimed=2 completed=1 failed=1", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func testCheckScimeshVersion(t *testing.T, installed, binary string, wantOK bool, wantDetail string) {
|
||||
t.Helper()
|
||||
old := agent.Version
|
||||
agent.Version = binary
|
||||
t.Cleanup(func() { agent.Version = old })
|
||||
item := ensureMatchingScimeshVersion(agent.CheckItem{Name: "scimesh", OK: true, Detail: installed})
|
||||
if item.OK != wantOK {
|
||||
t.Errorf("installed=%s binary=%s: ok=%v, want %v (%s)", installed, binary, item.OK, wantOK, item.Detail)
|
||||
}
|
||||
if wantDetail != "" && !strings.Contains(item.Detail, wantDetail) {
|
||||
t.Errorf("detail = %q, want it to contain %q", item.Detail, wantDetail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureMatchingScimeshVersion(t *testing.T) {
|
||||
testCheckScimeshVersion(t, "1.1.0a20", "1.1.0-alpha.20", true, "")
|
||||
testCheckScimeshVersion(t, "1.1.0a17", "1.1.0-alpha.20", false, "press Install to upgrade")
|
||||
testCheckScimeshVersion(t, "1.1.0a16.dev7+gea0fb8c59.d20260803", "1.1.0-alpha.20", false, "needs 1.1.0a20")
|
||||
// Dev builds and unknown versions never block.
|
||||
testCheckScimeshVersion(t, "anything", "dev", true, "")
|
||||
testCheckScimeshVersion(t, "1.1.0a20", "", true, "")
|
||||
}
|
||||
|
||||
@@ -141,6 +141,7 @@ code{font-family:var(--mono);font-size:.86em}
|
||||
</div>
|
||||
</div>
|
||||
<div class="field" id="cpu-field" style="display:none"><label>CPU count</label><input id="in-cpu" type="number" min="1" value="1"></div>
|
||||
<div class="field"><label>Concurrent task loops</label><input id="in-conc" type="number" min="1" max="64" value="1"><p class="hint">Process this many shards in parallel on this machine. Each loop runs its own task runner subprocess.</p></div>
|
||||
<div class="actions"><button class="btn btn-ghost" id="b2b">← Back</button><button class="btn btn-primary" id="b2">Continue →</button></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -228,9 +229,10 @@ function draftConfig(){
|
||||
userservice_url:state.mode==='key'?$('in-users').value.trim():'',
|
||||
work_dir:$('in-dir').value.trim(),
|
||||
worker_name:$('in-name').value.trim(),
|
||||
cpu_count:state.cpu==='custom'?parseInt($('in-cpu').value||'1',10):0
|
||||
cpu_count:state.cpu==='custom'?parseInt($('in-cpu').value||'1',10):0,
|
||||
concurrency:parseInt($('in-conc').value||'1',10)
|
||||
};
|
||||
if(state.venvPython)cfg.task_runner=[state.venvPython,'-m','scimesh.worker.task'];
|
||||
if(state.venvPython)cfg.task_runner=[state.venvPython,'-I','-m','scimesh.worker.task'];
|
||||
return cfg;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# SciMesh uninstaller for Windows: stops the running component, removes its
|
||||
# binary, and — when asked — deletes its data directory.
|
||||
#
|
||||
# powershell -ExecutionPolicy Bypass -c "irm https://raw.githubusercontent.com/emil28092005/SciMesh/main/uninstall.ps1 | iex"
|
||||
#
|
||||
# $env:SCIMESH_COMPONENT selects the component (coordinator | worker | all,
|
||||
# default all). Data is kept unless $env:SCIMESH_PURGE -eq "1" or the
|
||||
# -Purge switch is passed (in a pipe there is no interactive prompt).
|
||||
param(
|
||||
[switch]$Purge
|
||||
)
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$Component = if ($env:SCIMESH_COMPONENT) { $env:SCIMESH_COMPONENT } else { "all" }
|
||||
if ($Purge -or $env:SCIMESH_PURGE -eq "1") { $Purge = $true } else { $Purge = $false }
|
||||
|
||||
$InstallDir = if ($env:SCIMESH_INSTALL_DIR) {
|
||||
$env:SCIMESH_INSTALL_DIR
|
||||
} else {
|
||||
Join-Path $env:LOCALAPPDATA "SciMesh"
|
||||
}
|
||||
|
||||
function Remove-Component {
|
||||
param([string]$Name, [string]$Binary)
|
||||
Write-Host "Stopping $Name…"
|
||||
Get-Process -Name $Name -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
$target = Join-Path $InstallDir $Binary
|
||||
if (Test-Path $target) {
|
||||
Write-Host "Removing $target…"
|
||||
Remove-Item -Force $target
|
||||
}
|
||||
}
|
||||
|
||||
function Remove-Data {
|
||||
param([string]$Dir, [string]$Label)
|
||||
if (-not (Test-Path $Dir)) { return }
|
||||
if ($Purge) {
|
||||
Remove-Item -Recurse -Force $Dir
|
||||
Write-Host "Deleted $Dir"
|
||||
return
|
||||
}
|
||||
# No interactive prompt in a pipe: keep the data by default.
|
||||
Write-Host "Keeping $Dir ($Label). Pass -Purge to delete it."
|
||||
}
|
||||
|
||||
switch ($Component) {
|
||||
"coordinator" {
|
||||
Remove-Component -Name "coordinator" -Binary "coordinator.exe"
|
||||
Remove-Data -Dir (Join-Path $HOME ".scimesh") -Label "secrets, databases, artifacts, users"
|
||||
}
|
||||
"worker" {
|
||||
Remove-Component -Name "worker-agent" -Binary "worker-agent.exe"
|
||||
Remove-Data -Dir (Join-Path $HOME ".scimesh-worker") -Label "worker config, runtime venv, logs"
|
||||
}
|
||||
"all" {
|
||||
Remove-Component -Name "coordinator" -Binary "coordinator.exe"
|
||||
Remove-Component -Name "worker-agent" -Binary "worker-agent.exe"
|
||||
Remove-Data -Dir (Join-Path $HOME ".scimesh") -Label "secrets, databases, artifacts, users"
|
||||
Remove-Data -Dir (Join-Path $HOME ".scimesh-worker") -Label "worker config, runtime venv, logs"
|
||||
}
|
||||
default { throw "unknown component: $Component (use 'coordinator', 'worker' or 'all')" }
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "SciMesh $Component uninstalled."
|
||||
Write-Host "Pass -Purge to also delete the data directories."
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env bash
|
||||
# SciMesh uninstaller: stops the running component, removes its binary, and —
|
||||
# when asked — deletes its data directory.
|
||||
#
|
||||
# curl -fsSL https://raw.githubusercontent.com/emil28092005/SciMesh/main/uninstall.sh | bash -s coordinator
|
||||
# curl -fsSL https://raw.githubusercontent.com/emil28092005/SciMesh/main/uninstall.sh | bash -s worker
|
||||
# curl -fsSL https://raw.githubusercontent.com/emil28092005/SciMesh/main/uninstall.sh # both
|
||||
#
|
||||
# Data (jobs, artifacts, users, secrets, the managed venv) is kept by default.
|
||||
# Pass --purge (or set SCIMESH_PURGE=1) to delete it without asking; without
|
||||
# it the script prompts interactively. Because `curl | bash` pipes have no
|
||||
# interactive stdin, the default is always "keep data".
|
||||
set -eu
|
||||
|
||||
COMPONENT="${1:-all}"
|
||||
PURGE=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--purge) PURGE=1 ;;
|
||||
esac
|
||||
done
|
||||
[ "${SCIMESH_PURGE:-0}" = "1" ] && PURGE=1
|
||||
|
||||
INSTALL_DIR="${SCIMESH_INSTALL_DIR:-$HOME/.local/bin}"
|
||||
|
||||
remove_component() {
|
||||
case "$1" in
|
||||
coordinator)
|
||||
echo "Stopping the coordinator (serve)…"
|
||||
pkill -x coordinator 2>/dev/null || true
|
||||
echo "Removing $INSTALL_DIR/coordinator…"
|
||||
rm -f "$INSTALL_DIR/coordinator"
|
||||
;;
|
||||
worker)
|
||||
echo "Stopping the worker agent and its setup wizard…"
|
||||
pkill -x worker-agent 2>/dev/null || true
|
||||
echo "Removing $INSTALL_DIR/worker-agent…"
|
||||
rm -f "$INSTALL_DIR/worker-agent"
|
||||
;;
|
||||
*) echo "unknown component: $1 (use 'coordinator', 'worker' or 'all')" >&2; exit 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
remove_data() {
|
||||
local dir="$1" label="$2"
|
||||
if [ ! -d "$dir" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$PURGE" = "1" ]; then
|
||||
rm -rf "$dir"
|
||||
echo "Deleted $dir"
|
||||
return 0
|
||||
fi
|
||||
# In a pipe (curl | bash) stdin is exhausted, so the prompt defaults to keep.
|
||||
printf "Delete %s (%s)? [y/N] " "$dir" "$label"
|
||||
read -r answer || answer=""
|
||||
case "$answer" in
|
||||
y|Y|yes|YES) rm -rf "$dir"; echo "Deleted $dir" ;;
|
||||
*) echo "Keeping $dir" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
case "$COMPONENT" in
|
||||
coordinator)
|
||||
remove_component coordinator
|
||||
remove_data "$HOME/.scimesh" "secrets, databases, artifacts, users, managed venv"
|
||||
;;
|
||||
worker)
|
||||
remove_component worker
|
||||
remove_data "$HOME/.scimesh-worker" "worker config, runtime venv, logs"
|
||||
;;
|
||||
all)
|
||||
remove_component coordinator
|
||||
remove_component worker
|
||||
remove_data "$HOME/.scimesh" "secrets, databases, artifacts, users, managed venv"
|
||||
remove_data "$HOME/.scimesh-worker" "worker config, runtime venv, logs"
|
||||
;;
|
||||
*) echo "unknown component: $COMPONENT (use 'coordinator', 'worker' or 'all')" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
echo
|
||||
echo "SciMesh $COMPONENT uninstalled."
|
||||
echo "Pass --purge to also delete the data directories without asking."
|
||||
Reference in New Issue
Block a user