Skip to content

fix: 문서(Pages) 저장 500 + 감사로그가 웹 화면 진입을 끊는 문제 - #4

Merged
otroym merged 3 commits into
motefrom
fix/plane-pages-and-audit-log
Aug 5, 2026
Merged

fix: 문서(Pages) 저장 500 + 감사로그가 웹 화면 진입을 끊는 문제#4
otroym merged 3 commits into
motefrom
fix/plane-pages-and-audit-log

Conversation

@otroym

@otroym otroym commented Aug 5, 2026

Copy link
Copy Markdown

도트/쿠키의 "plane 페이지가 잘 안들어가진다" 리포트에서 출발했습니다.

먼저, 정상인 것

서버 다운이 아닙니다. 컨테이너 13개 healthy, 12시간 5xx 0건, Keycloak SSO 정상(도트·쿠키 로그인 성공 로그 확인), 홈 API 전건 200, 에셋 1.3MB 33ms, 외부망 접속 정상, 헤드리스 로드 1.3초.

실제 결함은 3건이었습니다.

① 문서 본문 저장이 100% 500

PageBinaryUpdateSerializer 의 메서드 3개(validate_description_binary / validate_description_html / update)가 병합 사고로 PageCommentSerializer 안에 들어가 있었습니다. PageCommentSerializer 안에 남아 있던 docstring 이 "Update the **page** instance" 라고 말하는 게 결정적 증거입니다.

PageBinaryUpdateSerializerserializers.Serializer 라 DRF 가 update() 를 유도할 모델이 없습니다. 없으면 serializer.save()NotImplementedError 를 던집니다 → 저장 시도 전건 500. 운영 로그에 update() must be implemented 6건.

메서드를 원래 클래스로 되돌리면서 부수 피해 2건도 함께 해소됩니다.

  • 페이지 저장에 검증이 전혀 걸리지 않고 있었습니다 — 이 serializer 를 만든 목적인 validate_html_content HTML 살균(chore: added validation for description  makeplane/plane#7507)이 실행된 적이 없습니다.
  • 잘못 붙은 update()ModelSerializer.update() 를 가렸고, description_* 키만 찾는데 PageComment 에는 그런 필드가 없습니다(comment_html/comment_json). 그래서 페이지 댓글 수정이 instance.save() 만 하고 아무 값도 반영하지 않았습니다 — 조용히 저장 실패.

② 감사로그가 AMQP 채널을 죽여서 화면 진입이 500

APITokenLogMiddleware 가 요청·응답 본문을 무제한 복사하고, mongo_log = {**log_data, ...} 가 그걸 한 번 더 복제합니다. 즉 모든 메시지가 페이로드를 2배로 싣습니다.

운영에서 실측 354,911,882 바이트 가 RabbitMQ 한도(max_message_size 128MB)를 넘겼습니다. RabbitMQ 는 PRECONDITION_FAILED (406) 으로 publish 를 거절하면서 채널을 통째로 닫습니다. 그런데 이 실패는 같은 커넥션의 다음 publish 에서 터지기 때문에, 실제로 500 이 난 요청은 전혀 무관한 요청이었습니다 — ProjectViewSet.retrieve 안의 recent_visited_task.delay().

사용자가 본 증상:

GET /api/workspaces/motemote/projects/{id}/   → 500   (referer: 문서 화면)
GET /api/workspaces/motemote/projects/{id}/pages/ → 499  (기다리다 포기)

남의 API 트래픽 때문에 내 프로젝트·문서 화면이 안 열리는 구조였습니다. "API 로 AI 랑 하는 건 되는데 웹이 안 들어가진다" 가 우연이 아니라 인과입니다.

본문을 64KB 로 캡했습니다(메시지 최대 ~128KB).

같은 경로에서 함께 처리:

  • X-Api-Keyheaders 블롭에서 마스킹 (PLANE-75 일부). token_identifier 는 감사 조인키라 손대지 않았습니다.
  • StreamingHttpResponse 에는 .content 가 없어서 문서 바이너리 다운로드마다 AttributeErrorlog_exception 으로 들어가고 있었습니다 → [Streaming Content] 로 기록.

logger_task 등록이 mote.50 에서 유실 (회귀)

mote.49 에서 넣은 CELERY_IMPORTS"plane.bgtasks.logger_task" 한 줄이 배포 이미지에만 없습니다. 레포에는 있고, 튜플의 나머지는 완전히 동일합니다.

origin/mote  →  "plane.bgtasks.logger_task",   ← 있음
배포 mote.50 →  (없음)                          ← 이 한 줄만

mote.50 이 stash 대피/복원을 거친 /srv/shared/app-src/plane 에서 빌드된 결과입니다(RELEASES-mote.md 의 mote.50 ⚠️ 항목이 이미 경고하고 있습니다).

이건 코드 수정이 아니라 재빌드·재배포가 곧 수정입니다. 대신 재발 방지 테스트를 넣었습니다 — 미등록 태스크는 어디서도 에러가 아니고 워커가 조용히 버리기만 해서, 감사추적이 0 이 되어도 아무것도 빨개지지 않는 실패 모드이기 때문입니다.

테스트

신규 25건. origin/mote 기준선 대비 회귀 0건.

passed failed error (DB 없음)
origin/mote 89 4 18
이 브랜치 114 4 18

실패 4건·에러 18건은 기준선과 동일합니다(에러는 컨테이너에 postgres 가 없어서 발생).

배포 시 유의

이 PR 머지 후 이미지를 새로 빌드해야 ③이 실제로 해소됩니다. 빌드소스는 반드시 origin/mote 에서 깨끗하게 받아야 하고(mote.50 과 같은 사고 재발 방지), 태그 재사용 시 docker inspect --format '{{.Created}}' 로 실제 재빌드 여부를 확인해야 합니다.

관련 없이 이미 적용 완료: 업로드 한도 200MB → 500MB (게이트웨이 nginx client_max_body_size 500m reload, plane.env FILE_SIZE_LIMIT=524288000, proxy recreate + api×2 무중단 카나리 스왑, 300MB 실전송 검증).

🤖 Generated with Claude Code

otroym and others added 3 commits August 5, 2026 14:17
The three methods belonging to PageBinaryUpdateSerializer --
validate_description_binary, validate_description_html and update -- sat on
PageCommentSerializer instead. The docstring left behind inside
PageCommentSerializer still read "Update the page instance".

PageBinaryUpdateSerializer is a plain serializers.Serializer, so DRF has no
model to derive update() from. Without it, serializer.save() raises
NotImplementedError and every page description save returns 500. Observed in
production as 6 x `update() must be implemented` on
PagesDescriptionViewSet.partial_update.

Two further consequences of the misplacement, both fixed by moving the methods
back:

- Page saves ran no validation at all, so validate_html_content -- the HTML
  sanitisation that makeplane#7507 added this serializer for -- never executed.
- PageCommentSerializer.update() shadowed ModelSerializer.update() and only
  looked for description_* keys, which PageComment does not have (it has
  comment_html / comment_json). Editing a page comment therefore called
  instance.save() without applying any validated data: the edit silently
  saved nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
APITokenLogMiddleware copied request and response bodies into the celery
message with no size bound, and mongo_log duplicated the whole dict, so every
message carried the payload twice. A large API response therefore published at
roughly 2x its size.

In production that reached 354,911,882 bytes against RabbitMQ's 128MB
max_message_size. RabbitMQ rejects the publish with PRECONDITION_FAILED (406)
and closes the channel -- and because the failure surfaces on the *next*
publish over that connection, the request that actually returned 500 was an
unrelated one: recent_visited_task.delay() inside ProjectViewSet.retrieve.
Users saw `GET /api/workspaces/{slug}/projects/{id}/` fail with 500 and the
sibling `/pages/` request hang until they gave up (499) -- i.e. opening a
project or a page broke because of someone else's API traffic.

Bodies are now capped at 64KB with an explicit truncation marker, bounding a
message at ~128KB.

Also in this path:

- X-Api-Key is redacted from the logged headers blob, which duplicated the raw
  token for no audit value (partial PLANE-75; token_identifier is left alone
  as it is the audit join key).
- StreamingHttpResponse has no .content, so page description binary downloads
  raised AttributeError into log_exception on every request. Those are now
  recorded as [Streaming Content] instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mote.49 added "plane.bgtasks.logger_task" to CELERY_IMPORTS. The deployed
mote.50 image does not have that line, although origin/mote does -- the rest
of the tuple is byte-identical. mote.50 was built from
/srv/shared/app-src/plane after a stash detour (see the mote.50 warning in
RELEASES-mote.md), and the one-line fix was lost in the process.

The failure mode is silent by construction: an unregistered task is not an
error anywhere, the worker just logs "Received unregistered task" and discards
the message, so the audit trail goes to zero without anything going red. This
test pins the entry and checks that every listed module actually imports.

No source change is needed for that one -- rebuilding and redeploying from
origin/mote is the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant