Просмотр исходного кода

r3: query layer (arch 14) — the work moves into the ORM with identical results. list_tasks filters (status/project) and sorts (priority, created_at — DateTime columns are unix-epoch ints, so the SQL ORDER BY equals the old .to_unix() compare) inside the query; get_ready_tasks stops loading EVERY task row for predecessor checks and queries only the queued candidates' predecessors; unreachable_task_ids batches (candidate tasks once, their sessions in one id-filtered query, the saddles in another — the newest-open-session-per-task semantics preserved by an id-ordered backward walk); display_map_for loads the wanted tasks' blocks in ONE task_id-filtered query instead of three full-table scans filtered in Vala; TasksState.build materialises the task list ONCE (the triple list_tasks died); TaskDetailState's enqueue dropdown queries exactly the standalone rows it lists (new list_standalone_tasks) instead of every task on every pulse; TimelineService.recent_window stops loading every session twice — the yeehaw session->task map builds from a batched lookup of only the WINDOW newest entries' sessions. The batched WHERE-in is an OR-chain helper (id_in/id_filter) because the expression grammar has no IN operator; id batches carry decimal strings (the Invercargill collections mishandle boxed value types — an int64 vector through to_immutable_buffer corrupts the heap, the exact trap the CreateTaskAction comment warns about). 851 checks, unchanged.

clanker 1 месяц назад
Родитель
Сommit
13d283c0a6

+ 136 - 59
src/Services/TaskService.vala

@@ -259,11 +259,11 @@ namespace ClankerSaddle.Services {
 
         /**
          * Computed display states for a batch of tasks (all lists share
-         * this), keyed by task id string. Unanswered review blocks load
-         * once, not per task. Plan 16 S5 (U3): a goal-run task with a
-         * pending review-family block (plan/completion/review) reads
-         * "awaiting your review" — the run-task card agrees with its
-         * goal.
+         * this), keyed by task id string. Unanswered review-family
+         * blocks load in ONE batched query over the wanted tasks, not
+         * per task. Plan 16 S5 (U3): a goal-run task with a pending
+         * review-family block (plan/completion/review) reads "awaiting
+         * your review" — the run-task card agrees with its goal.
          */
         public async Dictionary<string, TaskDisplay> display_map_for(
                 Lot<TaskEntity> tasks) throws Error {
@@ -278,9 +278,9 @@ namespace ClankerSaddle.Services {
 
             var review_pending = new Invercargill.DataStructures.HashSet<string>();
             var goal_review_pending = new Invercargill.DataStructures.HashSet<string>();
-            foreach (var family in new string[] { BLOCK_REVIEW, BLOCK_COMPLETION, BLOCK_PLAN }) {
+            if (wanted.length > 0) {
                 foreach (var block in yield db.query<TaskBlock>()
-                        .where(expr("type == $0", elem(family)))
+                        .where(id_in("task_id", wanted))
                         .materialise_async()) {
                     if (block.answered_at != null
                             || !wanted.has(block.task_id.to_string())) {
@@ -289,8 +289,12 @@ namespace ClankerSaddle.Services {
                     if (block.block_type == BLOCK_REVIEW) {
                         review_pending.add(block.task_id.to_string());
                     }
-                    if (goal_runs.has(block.task_id.to_string())) {
-                        goal_review_pending.add(block.task_id.to_string());
+                    if (block.block_type == BLOCK_REVIEW
+                            || block.block_type == BLOCK_COMPLETION
+                            || block.block_type == BLOCK_PLAN) {
+                        if (goal_runs.has(block.task_id.to_string())) {
+                            goal_review_pending.add(block.task_id.to_string());
+                        }
                     }
                 }
             }
@@ -330,29 +334,37 @@ namespace ClankerSaddle.Services {
 
         /**
          * Lists tasks, optionally filtered by status and/or project, ordered
-         * by priority (1 first) then creation time.
+         * by priority (1 first) then creation time — filtered and sorted
+         * in the query, one materialisation.
          */
         public async ImmutableLot<TaskEntity> list_tasks(string? status, int64? project_id) throws Error {
-            var tasks = yield db.query<TaskEntity>().materialise_async();
-            var rows = new Vector<TaskEntity>();
-            foreach (var task in tasks) {
-                if (status != null && status.length > 0 && task.status != status) {
-                    continue;
-                }
-                if (project_id != null && task.project_id != project_id) {
-                    continue;
-                }
-                rows.add(task);
+            var query = db.query<TaskEntity>();
+            if (status != null && status.length > 0) {
+                query.where(expr("status == $0", elem(status)));
             }
-            var sorted = rows.sort((a, b) => {
-                if (a.priority != b.priority) {
-                    return (int)(a.priority - b.priority);
-                }
-                int64 a_created = a.created_at != null ? ((!)a.created_at).to_unix() : 0;
-                int64 b_created = b.created_at != null ? ((!)b.created_at).to_unix() : 0;
-                return (int)(a_created - b_created);
-            });
-            return sorted.to_immutable_buffer();
+            if (project_id != null) {
+                query.where(expr("project_id == $0", elem<int64?>(project_id)));
+            }
+            return yield query
+                .order_by(expr("priority"))
+                .order_by(expr("created_at"))
+                .materialise_async();
+        }
+
+        /**
+         * Every standalone human-workable task row (kind task, no
+         * goal), any status, ordered like {@link list_tasks} — the
+         * auto-enqueue-after dropdowns' option list, queried for
+         * exactly the rows they show instead of every task in the
+         * table.
+         */
+        public async ImmutableLot<TaskEntity> list_standalone_tasks() throws Error {
+            return yield db.query<TaskEntity>()
+                .where(expr("kind == $0 && goal_id == $1",
+                    elem(TASK_KIND_TASK), elem<int64?>(0)))
+                .order_by(expr("priority"))
+                .order_by(expr("created_at"))
+                .materialise_async();
         }
 
         /**
@@ -1044,28 +1056,88 @@ namespace ClankerSaddle.Services {
          * Task ids currently `assigned`/`working` whose open session sits
          * on an offline or fenced saddle — the queue marks them
          * "saddle unreachable — requeueing" while recovery runs.
+         * Batched: the candidate tasks load once, their sessions in
+         * one query, the saddles in another.
          */
         public async Invercargill.DataStructures.HashSet<string> unreachable_task_ids() throws Error {
             var ids = new Invercargill.DataStructures.HashSet<string>();
+            var candidate_ids = new Invercargill.DataStructures.HashSet<string>();
             foreach (var status in new string[] { STATUS_ASSIGNED, STATUS_WORKING }) {
                 foreach (var task in yield db.query<TaskEntity>()
                         .where(expr("status == $0", elem(status)))
                         .materialise_async()) {
-                    var session = yield open_session_for_task(task.id);
-                    if (session == null) {
-                        continue;
-                    }
-                    var saddle = yield db.query<Saddle>()
-                        .where(expr("id == $0", elem<int64?>(((!)session).saddle_id)))
-                        .first_async();
-                    if (saddle != null && ((!)saddle).status == SADDLE_OFFLINE) {
-                        ids.add(task.id.to_string());
-                    }
+                    candidate_ids.add(task.id.to_string());
+                }
+            }
+            if (candidate_ids.length == 0) {
+                return ids;
+            }
+
+            // The newest open session per task: sessions ordered by id
+            // ascending, the LAST open row per task wins (the
+            // pre-batch semantics) — walked backwards, first hit per
+            // task keeps the slot.
+            var row_tasks = new Vector<string>();
+            var row_saddles = new Vector<string>();
+            foreach (var session in yield db.query<Session>()
+                    .where(id_in("task_id", candidate_ids))
+                    .order_by(expr("id"))
+                    .materialise_async()) {
+                if (session.ended_at == null) {
+                    row_tasks.add(session.task_id.to_string());
+                    row_saddles.add(session.saddle_id.to_string());
+                }
+            }
+            var open_task_ids = new Vector<string>();
+            var open_saddle_ids = new Vector<string>();
+            var taken_tasks = new Invercargill.DataStructures.HashSet<string>();
+            for (int index = (int) row_tasks.length - 1; index >= 0; index--) {
+                if (taken_tasks.add(row_tasks.get(index))) {
+                    open_task_ids.add(row_tasks.get(index));
+                    open_saddle_ids.add(row_saddles.get(index));
+                }
+            }
+            if (open_task_ids.length == 0) {
+                return ids;
+            }
+
+            var saddle_ids = new Invercargill.DataStructures.HashSet<string>();
+            foreach (var saddle_id in open_saddle_ids) {
+                saddle_ids.add(saddle_id);
+            }
+            var offline = new Invercargill.DataStructures.HashSet<string>();
+            foreach (var saddle in yield db.query<Saddle>()
+                    .where(id_in("id", saddle_ids))
+                    .materialise_async()) {
+                if (saddle.status == SADDLE_OFFLINE) {
+                    offline.add(saddle.id.to_string());
+                }
+            }
+            for (int index = 0; index < open_task_ids.length; index++) {
+                if (offline.has(open_saddle_ids.get(index))) {
+                    ids.add(open_task_ids.get(index));
                 }
             }
             return ids;
         }
 
+        /**
+         * An OR-chain of `column == id` comparisons for a batch of ids
+         * (the grammar has no IN operator), or null for an empty
+         * batch — the WHERE-in the batched lookups build.
+         */
+        private static Expression? id_in(string column,
+                Invercargill.DataStructures.HashSet<string> ids) throws Error {
+            Expression? chain = null;
+            foreach (var id in ids) {
+                var comparison = expr(column + " == $0", elem(id));
+                chain = chain == null
+                    ? comparison : new BinaryExpression((!)chain, comparison,
+                        BinaryOperator.OR);
+            }
+            return chain;
+        }
+
         /**
          * The resume_notice wording for an answered block (the plan-11
          * stable verdicts): review and plan answers are already stored
@@ -1403,13 +1475,30 @@ namespace ClankerSaddle.Services {
         /**
          * Queued tasks whose auto-enqueue predecessor (if any) is
          * done, ordered by priority (1 first) then creation time —
-         * the dispatcher's candidate list.
+         * the dispatcher's candidate list. Only the CANDIDATES'
+         * predecessors are queried (not every task row).
          */
         public async ImmutableLot<TaskEntity> get_ready_tasks() throws Error {
             var queued = yield db.query<TaskEntity>()
                 .where(expr("status == $0", elem(STATUS_QUEUED)))
+                .order_by(expr("priority"))
+                .order_by(expr("created_at"))
                 .materialise_async();
-            var all_tasks = yield db.query<TaskEntity>().materialise_async();
+
+            var predecessor_ids = new Invercargill.DataStructures.HashSet<string>();
+            foreach (var task in queued) {
+                if (task.enqueue_after_task_id > 0) {
+                    predecessor_ids.add(task.enqueue_after_task_id.to_string());
+                }
+            }
+            var predecessor_status = new Dictionary<string, string>();
+            if (predecessor_ids.length > 0) {
+                foreach (var predecessor in yield db.query<TaskEntity>()
+                        .where(id_in("id", predecessor_ids))
+                        .materialise_async()) {
+                    predecessor_status.set(predecessor.id.to_string(), predecessor.status);
+                }
+            }
 
             var rows = new Vector<TaskEntity>();
             foreach (var task in queued) {
@@ -1418,29 +1507,17 @@ namespace ClankerSaddle.Services {
                     // The auto-enqueue predecessor also gates dispatch:
                     // a dependent that reached the queue some other way
                     // (a predecessor set after a manual enqueue) still
-                    // waits for it to finish.
-                    ready = false;
-                    foreach (var other in all_tasks) {
-                        if (other.id == task.enqueue_after_task_id) {
-                            ready = other.status == STATUS_DONE;
-                            break;
-                        }
-                    }
+                    // waits for it to finish. A predecessor row that
+                    // does not resolve keeps the dependent waiting.
+                    string? status = predecessor_status.get_or_default(
+                        task.enqueue_after_task_id.to_string());
+                    ready = status != null && status == STATUS_DONE;
                 }
                 if (ready) {
                     rows.add(task);
                 }
             }
-
-            var sorted = rows.sort((a, b) => {
-                if (a.priority != b.priority) {
-                    return (int)(a.priority - b.priority);
-                }
-                int64 a_created = a.created_at != null ? ((!)a.created_at).to_unix() : 0;
-                int64 b_created = b.created_at != null ? ((!)b.created_at).to_unix() : 0;
-                return (int)(a_created - b_created);
-            });
-            return sorted.to_immutable_buffer();
+            return rows.to_immutable_buffer();
         }
 
         /** Comments on a task, oldest first. */

+ 40 - 7
src/Services/TimelineService.vala

@@ -203,18 +203,34 @@ namespace ClankerSaddle.Services {
             // Agent celebrations (`saddle yeehaw`): the stored
             // session_commands rows surface as their own event kind —
             // "🤖 yeehaw'd: <first line>" — keyed on the owning session's
-            // task. Entries without a timestamp cannot sort and are
+            // task. Only the WINDOW newest entries load, and only
+            // THEIR sessions (a batched id lookup — not every session
+            // row). Entries without a timestamp cannot sort and are
             // skipped like every other timestamp-less source row.
-            var session_tasks = new Dictionary<string, string>();
-            foreach (var session in yield db.query<Session>().materialise_async()) {
-                session_tasks.set(session.id.to_string(), session.task_id.to_string());
-            }
+            var yeehaw_entries = new Vector<SessionCommand>();
             foreach (var entry in yield db.query<SessionCommand>()
                     .where(expr("type == $0", elem(ENTRY_YEEHAW)))
                     .order_by_desc(expr("id")).limit(WINDOW).materialise_async()) {
-                if (entry.started_at == null) {
-                    continue;
+                if (entry.started_at != null) {
+                    yeehaw_entries.add(entry);
+                }
+            }
+            var session_tasks = new Dictionary<string, string>();
+            if (yeehaw_entries.length > 0) {
+                var wanted_session_ids = new Invercargill.DataStructures.HashSet<string>();
+                foreach (var entry in yeehaw_entries) {
+                    wanted_session_ids.add(entry.session_id.to_string());
+                }
+                var session_filter = id_filter("id", wanted_session_ids);
+                if (session_filter != null) {
+                    foreach (var session in yield db.query<Session>()
+                            .where((!)session_filter)
+                            .materialise_async()) {
+                        session_tasks.set(session.id.to_string(), session.task_id.to_string());
+                    }
                 }
+            }
+            foreach (var entry in yeehaw_entries) {
                 string? task_key = session_tasks.get_or_default(entry.session_id.to_string());
                 if (task_key == null || run_tasks.has((!)task_key)) {
                     continue;
@@ -290,6 +306,23 @@ namespace ClankerSaddle.Services {
             return flat.length > SNIPPET_MAX ? flat.substring(0, SNIPPET_MAX) + "…" : flat;
         }
 
+        /**
+         * An OR-chain of `column == id` comparisons for a batch of ids
+         * (the grammar has no IN operator), or null for an empty
+         * batch — the WHERE-in the batched lookups build.
+         */
+        private static Expression? id_filter(string column,
+                Invercargill.DataStructures.HashSet<string> ids) throws Error {
+            Expression? chain = null;
+            foreach (var id in ids) {
+                var comparison = expr(column + " == $0", elem(id));
+                chain = chain == null
+                    ? comparison : new BinaryExpression((!)chain, comparison,
+                        BinaryOperator.OR);
+            }
+            return chain;
+        }
+
         /**
          * The first non-empty line of a summary (a markdown report's
          * headline), or null when the summary holds nothing usable.

+ 6 - 8
src/entrypoints/TaskDetailEntrypoint.vala

@@ -359,16 +359,14 @@ namespace ClankerSaddle {
             }
 
             // Plan 12: the auto-enqueue-after dropdown lists every
-            // OTHER human-created task of the global list (any status
-            // eligible as predecessor); goal tasks are excluded — the
-            // goal agent sequences its own.
+            // OTHER standalone task (any status eligible as
+            // predecessor); goal tasks are excluded — the goal agent
+            // sequences its own. The dropdown's own query loads
+            // exactly those rows, not every task on every pulse.
             var enqueue_options = new Vector<TaskOptionView>();
             string enqueue_after_title = "";
-            foreach (var row in yield tasks.list_tasks(null, null)) {
-                if (row.id == task.id || row.kind == TASK_KIND_GOAL_RUN) {
-                    continue;
-                }
-                if (row.goal_id > 0) {
+            foreach (var row in yield tasks.list_standalone_tasks()) {
+                if (row.id == task.id) {
                     continue;
                 }
                 enqueue_options.add(new TaskOptionView() {

+ 11 - 10
src/entrypoints/TasksEntrypoint.vala

@@ -121,12 +121,20 @@ namespace ClankerSaddle {
                 project_names.set(project.id.to_string(), project.name);
             }
 
+            // ONE materialisation of the task list feeds every consumer
+            // below (the status lookup, the goal-filtered strip and the
+            // enqueue dropdown) — the triple list_tasks died with arch
+            // item 14.
             var tasks_all = yield task_service.list_tasks(null, null);
             var statuses = new Dictionary<string, string>();
             foreach (var task in tasks_all) {
                 statuses.set(task.id.to_string(), task.status);
             }
             var ready = yield task_service.get_ready_tasks();
+            var ready_ids = new Invercargill.DataStructures.HashSet<string>();
+            foreach (var ready_task in ready) {
+                ready_ids.add(ready_task.id.to_string());
+            }
 
             // Computed display state for every listed task (review
             // requested / delegating / suspended surface here). The
@@ -145,7 +153,7 @@ namespace ClankerSaddle {
                 goal_filter_id = int64.parse(goal_filter);
             }
             var listed = new Vector<ClankerSaddle.Data.TaskEntity>();
-            foreach (var task in yield task_service.list_tasks(null, null)) {
+            foreach (var task in tasks_all) {
                 // Goal-agent runs are internal: they never list here —
                 // goals are browsed from the goal page, which alone
                 // exposes the run task (its terminals, its progress).
@@ -194,13 +202,6 @@ namespace ClankerSaddle {
 
             var rows = new Vector<TaskRowView>();
             foreach (var task in listed) {
-                bool is_ready = false;
-                foreach (var ready_task in ready) {
-                    if (ready_task.id == task.id) {
-                        is_ready = true;
-                        break;
-                    }
-                }
                 var display = display_map.get_or_default(task.id.to_string());
                 var view = new TaskRowView() {
                     id = task.id,
@@ -217,7 +218,7 @@ namespace ClankerSaddle {
                     goal_id = task.goal_id,
                     goal_title = goal_titles.get_or_default(task.goal_id.to_string()) ?? "",
                     enqueue_after_task_id = task.enqueue_after_task_id,
-                    ready = is_ready
+                    ready = ready_ids.has(task.id.to_string())
                 };
                 // Plan 12: the dependency summary became the
                 // auto-enqueue-after predecessor note.
@@ -291,7 +292,7 @@ namespace ClankerSaddle {
                 });
             }
             var enqueue_options = new Vector<TaskProjectOptionView>();
-            foreach (var task in yield task_service.list_tasks(null, null)) {
+            foreach (var task in tasks_all) {
                 if (task.kind != TASK_KIND_TASK || task.goal_id > 0
                         || task.creator != CREATOR_HUMAN) {
                     continue;