{"record":{"id":"45593ac32ec4fff2","repo":"usememos/memos","slug":"memo-state-changed","errorCode":null,"errorMessage":"memo state changed","messagePattern":"memo state changed","errorType":"error_code","errorClass":"ErrMemoMutationConflict","httpStatus":409,"severity":"error","filePath":"store/memo_attachment.go","lineNumber":10,"sourceCode":"package store\n\nimport (\n\t\"context\"\n\t\"errors\"\n)\n\n// ErrMemoMutationConflict indicates that memo or attachment state changed\n// after an API request prepared its mutation.\nvar ErrMemoMutationConflict = errors.New(\"memo state changed\")\n\n// MemoAttachmentBinding describes one attachment that should be bound to a\n// memo. WasBoundToMemo distinguishes an existing binding from a new one so the\n// driver can reject ownership transfers while preserving legacy rows already\n// attached to the memo.\ntype MemoAttachmentBinding struct {\n\tID             int32\n\tUID            string\n\tUpdatedTs      int64\n\tWasBoundToMemo bool\n}\n\n// MemoMutation atomically updates a memo, its attachment bindings, and its\n// reference relations. Removed attachment rows are detached in the transaction\n// and are deleted from storage separately, so a storage failure remains\n// retriable.\ntype MemoMutation struct {\n\tMemoID                    int32","sourceCodeStart":1,"sourceCodeEnd":28,"githubUrl":"https://github.com/usememos/memos/blob/14d757ce1fb31c78590f374bc042f8dbedbc20d7/store/memo_attachment.go#L1-L28","documentation":"ErrMemoMutationConflict (store/memo_attachment.go:10) is an optimistic-concurrency sentinel returned by Store.ApplyMemoMutation. Inside one transaction, each DB driver (store/db/{sqlite,mysql,postgres}/memo_attachment.go) rechecks the memo's existence, content, and attachment bindings that the API layer validated earlier; if another writer changed them in between, the transaction aborts with this error instead of overwriting concurrent state. The v1 memo attachment service maps it to codes.FailedPrecondition (server/router/api/v1/memo_attachment_service.go:181).","triggerScenarios":"Two concurrent UpdateMemoWithAttachments / SetMemo calls on the same memo; deleting or rebinding an attachment (DetachAttachment, ownership transfer to another memo) between the request's validation read and ApplyMemoMutation; a client retrying a stale request built from an old memo snapshot; memo deleted by another session while the mutation is in flight.","commonSituations":"Multi-tab or multi-device editing of the same memo, automation/scripts racing with the web UI, mobile clients with offline queues replaying old updates, or background runners touching attachments concurrently with a user edit.","solutions":["Retry the whole operation: re-fetch the memo and attachments, rebuild the mutation from fresh state, and re-apply (the design keeps storage deletions outside the transaction precisely so retries are safe).","Check for the sentinel with errors.Is(err, store.ErrMemoMutationConflict) at the service boundary and surface codes.FailedPrecondition so clients know to refresh rather than blindly repeat.","Serialize concurrent edits client-side (e.g. disable submit while an edit is in flight) to avoid lost-update races.","If it persists for one row, inspect the wrap message (\"memo no longer exists\", \"attachment X is no longer bound\") to see which precondition broke."],"exampleFix":"// before\nif err := s.Store.ApplyMemoMutation(ctx, mutation); err != nil {\n    return status.Errorf(codes.Internal, \"failed: %v\", err)\n}\n\n// after\nif err := s.Store.ApplyMemoMutation(ctx, mutation); err != nil {\n    if stderrors.Is(err, store.ErrMemoMutationConflict) {\n        return status.Errorf(codes.FailedPrecondition, \"memo state changed: %v\", err)\n    }\n    return status.Errorf(codes.Internal, \"failed to apply memo mutation: %v\", err)\n}","handlingStrategy":"retry","validationCode":"// Re-read the memo and attachments immediately before building the mutation,\n// and include ExpectedMemoContent from that fresh read:\n// mutation.ExpectedMemoContent = memo.Content (fetched in the same request).","typeGuard":"func isMemoMutationConflict(err error) bool {\n    return errors.Is(err, store.ErrMemoMutationConflict)\n}","tryCatchPattern":"if err := s.Store.ApplyMemoMutation(ctx, mutation); err != nil {\n    if stderrors.Is(err, store.ErrMemoMutationConflict) {\n        // reload memo + attachments, rebuild mutation, retry once;\n        // surface FailedPrecondition to the client if the retry also conflicts\n        return status.Errorf(codes.FailedPrecondition, \"memo state changed: %v\", err)\n    }\n    return err\n}","preventionTips":["Always compare errors with errors.Is against the sentinel, never string-matching \"memo state changed\".","Fetch memo state and apply the mutation in the same request handler to shrink the race window.","Keep detached-row deletion outside the transaction (as the current design does) so a retry after conflict is safe."],"tags":["store","concurrency","optimistic-locking","memo","attachments"],"backgroundTag":null,"analyzedSha":"14d757ce1fb31c78590f374bc042f8dbedbc20d7","analyzedAt":"2026-08-15T09:27:36.538Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}