Files
multica/server/internal/handler/issue_duplicate.go
T
4469bac632 fix(issues): duplicate mark follow-ups from #8741 review (MUL-7349) (#8752)
* perf(issues): resolve a page's duplicate originals in one read (MUL-7349)

Filling duplicate_of read each distinct original with its own primary-key
lookup, so a page of duplicates of different issues cost one round trip
per row. Pages now pass the originals their rows point at to
newStatusCategoryFiller, which loads them with one ListIssueRefsInWorkspace
read, the same way labelsByIssue loads a page's labels. List, open issues,
grouped, search and table rows do this; a response built on its own still
resolves its original, so a caller that passes nothing is slower, never
wrong.

A pointer left on a reopened issue is not looked up at all, and an
original that no longer exists is looked up once and left off.

Co-authored-by: multica-agent <[email protected]>

* fix(issues): stop logging stale duplicate pointers as unmarks (MUL-7349)

Servers from before the duplicate column reopened duplicates and deleted
originals without clearing the pointer. After an upgrade, the next write
to such an issue clears it, and the activity log compared raw pointers,
so it logged "removed duplicate mark" for a mark that no longer counted
and dropped the real status row in its place.

Event publishers now send only live marks: a pointer counts while the
issue is cancelled, the same rule reads apply. Separately, the status row
is dropped only when a duplicate row was actually written to stand in for
it; when the original is gone there is no duplicate row, and the reopen
used to leave no activity at all.

Co-authored-by: multica-agent <[email protected]>

* fix(issues): keep duplicate marks in background issue snapshots (MUL-7349)

IssueToMap hardcoded duplicate_of to null on the assumption that no
background publisher renders a marked issue. PublishAttachmentsChanged
does: a channel /issue with media re-reads the issue after a download of
up to 45 seconds, and a mark made in that window was broadcast as null.
Clients patch their cache with that snapshot, so the list and board
marker disappeared until the next refetch.

IssueToMapResolved now resolves duplicate_of the way the HTTP rendering
does; only a cancelled issue with a pointer costs a read.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-23 17:41:27 +08:00

187 lines
7.2 KiB
Go

package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/issuestatus"
"github.com/multica-ai/multica/server/internal/logger"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// Duplicate marks (MUL-7349). A duplicate is an ordinary cancelled issue that
// remembers which issue it duplicates: there is no duplicate status. Setting
// the mark goes through UpdateIssue as a status write, and any status change
// away from cancelled clears it in SQL (UpdateIssue / UpdateIssueStatus).
//
// The relation stays one level deep in both directions: a target must not be
// a duplicate itself, and an issue that others are marked as duplicates of
// cannot be marked. Nothing is collapsed or re-pointed automatically.
//
// The column and the code that maintains it shipped one release earlier, so a
// rollback lands on a server that still clears the pointer when an issue is
// reopened or its original deleted. Reads below additionally require the
// duplicate to be cancelled and its original to exist, which keeps a rollback
// that goes back further than that release readable rather than wrong.
var (
errDuplicateTargetNotFound = errors.New("duplicate target not found in this workspace")
errDuplicateTargetIsDuplicate = errors.New("duplicate target is itself a duplicate")
errIssueHasDuplicates = errors.New("issue has duplicates")
)
// parseDuplicateMark reads duplicate_of_issue_id from an update request. When
// present it turns the request into a status write to cancelled, so the mark
// shares the status resolution, status_changed event and stage-barrier
// handling of cancelling by hand. It returns an invalid UUID when the request
// does not mark, and false after writing an error response.
func parseDuplicateMark(w http.ResponseWriter, req *UpdateIssueRequest, rawFields map[string]json.RawMessage, issueID pgtype.UUID) (pgtype.UUID, bool) {
if _, present := rawFields["duplicate_of_issue_id"]; !present {
return pgtype.UUID{}, true
}
if req.DuplicateOfIssueID == nil {
writeError(w, http.StatusBadRequest, "duplicate_of_issue_id cannot be null; change the status to remove a duplicate mark")
return pgtype.UUID{}, false
}
targetID, ok := parseUUIDOrBadRequest(w, *req.DuplicateOfIssueID, "duplicate_of_issue_id")
if !ok {
return pgtype.UUID{}, false
}
if targetID == issueID {
writeError(w, http.StatusBadRequest, "an issue cannot be a duplicate of itself")
return pgtype.UUID{}, false
}
if req.Status != nil && *req.Status != issuestatus.Cancelled {
writeError(w, http.StatusBadRequest, "a duplicate is cancelled; status must be cancelled or omitted")
return pgtype.UUID{}, false
}
// The description/attachment path locks attachments before the issue row,
// while a mark locks two issue rows first. Keeping them apart keeps each
// path's lock order fixed.
if req.Description != nil || req.TitleBase != nil || req.DescriptionBase != nil || len(req.AttachmentIDs) > 0 {
writeError(w, http.StatusBadRequest, "duplicate_of_issue_id cannot be combined with description or attachment changes")
return pgtype.UUID{}, false
}
cancelled := issuestatus.Cancelled
req.Status = &cancelled
return targetID, true
}
// lockAndCheckDuplicateMark validates a mark inside the update transaction.
// Both issues are locked first, so marks that share an issue run one after
// the other and each validates what the previous one wrote. Deleting the
// target also locks it, so a mark never commits a pointer to a deleted issue.
func lockAndCheckDuplicateMark(ctx context.Context, q *db.Queries, workspaceID, issueID, targetID pgtype.UUID) error {
rows, err := q.LockIssuesForDuplicateMark(ctx, db.LockIssuesForDuplicateMarkParams{
WorkspaceID: workspaceID,
IssueIds: []pgtype.UUID{issueID, targetID},
})
if err != nil {
return fmt.Errorf("lock issues for duplicate mark: %w", err)
}
targetFound := false
for _, row := range rows {
if row.ID != targetID {
continue
}
targetFound = true
if row.IsDuplicate {
return errDuplicateTargetIsDuplicate
}
}
if !targetFound {
return errDuplicateTargetNotFound
}
hasDuplicates, err := q.IssueHasDuplicates(ctx, db.IssueHasDuplicatesParams{
WorkspaceID: workspaceID,
IssueID: issueID,
})
if err != nil {
return fmt.Errorf("check issue duplicates: %w", err)
}
if hasDuplicates {
return errIssueHasDuplicates
}
return nil
}
func writeDuplicateMarkError(w http.ResponseWriter, err error) bool {
switch {
case errors.Is(err, errDuplicateTargetNotFound):
writeError(w, http.StatusBadRequest, "duplicate target not found in this workspace")
case errors.Is(err, errDuplicateTargetIsDuplicate):
writeErrorCode(w, http.StatusConflict, "duplicate_target_is_duplicate",
"the target issue is itself a duplicate; mark this issue as a duplicate of its original instead")
case errors.Is(err, errIssueHasDuplicates):
writeErrorCode(w, http.StatusConflict, "issue_has_duplicates",
"other issues are marked as duplicates of this issue; remove those marks first")
default:
return false
}
return true
}
// liveDuplicateMark renders one end of a mark change for an issue:updated
// payload: the pointer only while it counts, i.e. the issue is cancelled. A
// pointer an older server left on a reopened issue is no mark, so a write
// that clears it is not a mark being removed.
func liveDuplicateMark(status string, id pgtype.UUID) *string {
return uuidToPtr(duplicateOfPointer(status, id))
}
// ListIssueDuplicates returns both sides of an issue's duplicate relation: the
// original it duplicates, if any, and the issues marked as its duplicates.
func (h *Handler) ListIssueDuplicates(w http.ResponseWriter, r *http.Request) {
issue, ok := h.loadIssueForUser(w, r, chi.URLParam(r, "id"))
if !ok {
return
}
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
fill := h.newStatusCategoryFiller(r.Context(), issue.WorkspaceID)
var duplicateOf *IssueResponse
if issue.Status == issuestatus.Cancelled && issue.DuplicateOfIssueID.Valid {
original, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
ID: issue.DuplicateOfIssueID,
WorkspaceID: issue.WorkspaceID,
})
switch {
case err == nil:
resp := issueToResponse(original, prefix)
fill(&resp)
duplicateOf = &resp
case !isNotFound(err):
slog.Warn("load duplicate original failed", append(logger.RequestAttrs(r), "error", err, "issue_id", uuidToString(issue.ID))...)
writeError(w, http.StatusInternalServerError, "failed to load duplicate original")
return
}
}
rows, err := h.Queries.ListIssueDuplicates(r.Context(), db.ListIssueDuplicatesParams{
WorkspaceID: issue.WorkspaceID,
IssueID: issue.ID,
})
if err != nil {
slog.Warn("list issue duplicates failed", append(logger.RequestAttrs(r), "error", err, "issue_id", uuidToString(issue.ID))...)
writeError(w, http.StatusInternalServerError, "failed to list duplicates")
return
}
duplicates := make([]IssueResponse, 0, len(rows))
for _, row := range rows {
resp := issueToResponse(row, prefix)
fill(&resp)
duplicates = append(duplicates, resp)
}
writeJSON(w, http.StatusOK, map[string]any{
"duplicate_of": duplicateOf,
"duplicates": duplicates,
})
}