대규모 Drupal 포럼 마이그레이션, 가져오기 오류 및 제한 사항

안녕하세요, 이 주제에는 제가 천천히 계획하고 테스트 중인 마이그레이션에 대한 배경 정보가 담겨 있습니다. 마침 지난 금요일, 이 방법과 이 방법을 조합하여 테스트용 VPS에서 Drupal 가져오기 스크립트를 처음 시도해 보았습니다. 이 글을 작성하는 지금도 가져오기 스크립트가 실행 중이라 테스트 사이트의 기능을 실제로 확인하지는 못했지만, 곧 완료될 예정입니다.

제가 직면한 가장 큰 문제는 약 80,000개의 전체 노드 중 8개의 노드(Discourse의 주제에 해당)에서 발생하는 “중복된 키 값” 오류입니다. 혹시 Y2K와 유사한 수학 버그가 원인이 아닐까 싶어 해당 nid 번호들을 여기에 나열합니다:

42081, 53125, 57807, 63932, 66756, 76561, 78250, 82707

가져오기 스크립트를 다시 실행할 때마다 항상 동일한 nid에서 이 오류가 발생합니다:

Traceback (most recent call last):
	19: from script/import_scripts/drupal.rb:537:in `<main>'
	18: from /var/www/discourse/script/import_scripts/base.rb:47:in `perform'
	17: from script/import_scripts/drupal.rb:39:in `execute'
	16: from script/import_scripts/drupal.rb:169:in `import_forum_topics'
	15: from /var/www/discourse/script/import_scripts/base.rb:916:in `batches'
	14: from /var/www/discourse/script/import_scripts/base.rb:916:in `loop'
	13: from /var/www/discourse/script/import_scripts/base.rb:917:in `block in batches'
	12: from script/import_scripts/drupal.rb:195:in `block in import_forum_topics'
	11: from /var/www/discourse/script/import_scripts/base.rb:224:in `all_records_exist?'
	10: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-7.0.3.1/lib/active_record/transactions.rb:209:in `transaction'
	 9: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-7.0.3.1/lib/active_record/connection_adapters/abstract/database_statements.rb:316:in `transaction'
	 8: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-7.0.3.1/lib/active_record/connection_adapters/abstract/transaction.rb:317:in `within_new_transaction'
	 7: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activesupport-7.0.3.1/lib/active_support/concurrency/load_interlock_aware_monitor.rb:21:in `synchronize'
	 6: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activesupport-7.0.3.1/lib/active_support/concurrency/load_interlock_aware_monitor.rb:21:in `handle_interrupt'
	 5: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activesupport-7.0.3.1/lib/active_support/concurrency/load_interlock_aware_monitor.rb:25:in `block in synchronize'
	 4: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activesupport-7.0.3.1/lib/active_support/concurrency/load_interlock_aware_monitor.rb:25:in `handle_interrupt'
	 3: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-7.0.3.1/lib/active_record/connection_adapters/abstract/transaction.rb:319:in `block in within_new_transaction'
	 2: from /var/www/discourse/script/import_scripts/base.rb:231:in `block in all_records_exist?'
	 1: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/rack-mini-profiler-3.0.0/lib/patches/db/pg.rb:56:in `exec'
/var/www/discourse/vendor/bundle/ruby/2.7.0/gems/rack-mini-profiler-3.0.0/lib/patches/db/pg.rb:56:in `exec': ERROR:  duplicate key value violates unique constraint "import_ids_pkey" (PG::UniqueViolation)
DETAIL:  Key (val)=(nid:42081) already exists.
	20: from script/import_scripts/drupal.rb:537:in `<main>'
	19: from /var/www/discourse/script/import_scripts/base.rb:47:in `perform'
	18: from script/import_scripts/drupal.rb:39:in `execute'
	17: from script/import_scripts/drupal.rb:169:in `import_forum_topics'
	16: from /var/www/discourse/script/import_scripts/base.rb:916:in `batches'
	15: from /var/www/discourse/script/import_scripts/base.rb:916:in `loop'
	14: from /var/www/discourse/script/import_scripts/base.rb:917:in `block in batches'
	13: from script/import_scripts/drupal.rb:195:in `block in import_forum_topics'
	12: from /var/www/discourse/script/import_scripts/base.rb:224:in `all_records_exist?'
	11: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-7.0.3.1/lib/active_record/transactions.rb:209:in `transaction'
	10: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-7.0.3.1/lib/active_record/connection_adapters/abstract/database_statements.rb:316:in `transaction'
	 9: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-7.0.3.1/lib/active_record/connection_adapters/abstract/transaction.rb:317:in `within_new_transaction'
	 8: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activesupport-7.0.3.1/lib/active_support/concurrency/load_interlock_aware_monitor.rb:21:in `synchronize'
	 7: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activesupport-7.0.3.1/lib/active_support/concurrency/load_interlock_aware_monitor.rb:21:in `handle_interrupt'
	 6: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activesupport-7.0.3.1/lib/active_support/concurrency/load_interlock_aware_monitor.rb:25:in `block in synchronize'
	 5: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activesupport-7.0.3.1/lib/active_support/concurrency/load_interlock_aware_monitor.rb:25:in `handle_interrupt'
	 4: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-7.0.3.1/lib/active_record/connection_adapters/abstract/transaction.rb:319:in `block in within_new_transaction'
	 3: from /var/www/discourse/script/import_scripts/base.rb:243:in `block in all_records_exist?'
	 2: from /var/www/discourse/script/import_scripts/base.rb:243:in `ensure in block in all_records_exist?'
	 1: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/rack-mini-profiler-3.0.0/lib/patches/db/pg.rb:56:in `exec'
/var/www/discourse/vendor/bundle/ruby/2.7.0/gems/rack-mini-profiler-3.0.0/lib/patches/db/pg.rb:56:in `exec': ERROR:  current transaction is aborted, commands ignored until end of transaction block (PG::InFailedSqlTransaction)

진행을 계속할 수 있었던 유일한 방법은 SQL 조건문을 해킹하는 것이었습니다:

...
	 LEFT JOIN node_counter nc ON nc.nid = n.nid
         WHERE n.type = 'forum'
           AND n.status = 1
AND n.nid != 42081
AND n.nid != 53125
AND n.nid != 57807
AND n.nid != 63932
AND n.nid != 66756
AND n.nid != 76561
AND n.nid != 78250
AND n.nid != 82707
         LIMIT #{BATCH_SIZE}
        OFFSET #{offset};
...

소스 Drupal 데이터베이스에서 실패한 첫 번째 노드와 그 양쪽의 이전 및 다음 nid를 검사해 보았지만, 아무런 문제가 보이지 않습니다. nid는 기본 키로 설정되어 있고 AUTO_INCREMENT가 적용되어 있으며, 원래 Drupal 사이트는 정상적으로 작동하므로 소스 데이터베이스 무결성에 근본적인 문제가 있을 수는 없습니다.


위 버그 외에도 스크립트 사용 시 겪고 있는 제한 사항들은 다음과 같습니다:

  1. 영구 링크(Permalinks): 가져오기 스크립트는 이전 노드 URL example.com/node/XXXXXXX에 대한 영구 링크를 생성하는 것으로 보입니다. 하지만 해당 노드 내 특정 댓글에 대한 링크도 유지해야 하며, 이 링크의 형식은 example.com/comment/YYYYYYY#comment-YYYYYYY입니다 (YYYYYYY는 두 곳에서 동일합니다). Drupal URL 체계에는 댓글이 연결된 노드 ID가 포함되지 않지만, Discourse에는 포함됩니다(example.com/t/topic-keywords/XXXXXXX/YY). 따라서 이는 상당한 복잡성을 초래하는 것으로 보입니다.

  2. 사용자 이름 제한: Drupal은 사용자 이름에 공백을 허용합니다. Discourse는 이를 허용하지 않는 것으로 이해하고 있으며, 적어도 신규 사용자는 그렇게 생성할 수 없습니다. 이 게시물에 따르면 가져오기 스크립트가 문제 있는 사용자 이름을 자동으로 "변환"한다고 하지만, /import_scripts/drupal.rb에서 해당 코드를 찾을 수 없습니다. 업데이트: 실제로는 Discourse가 이를 올바른 방식으로 자동으로 처리한 것으로 보입니다.

  3. 차단된 사용자: 스크립트가 차단된 계정 포함 모든 사용자를 가져오는 것으로 보입니다. 활성 사용자 계정만 가져오기 위해 SQL 선택 조건에 WHERE status = 1을 쉽게 추가할 수 있을 것 같지만, 이것이 레코드 직렬화에 문제를 일으키지는 않을지 확신이 서지 않습니다. 무엇보다도 이전에 차단된 계정 이름과 관련 이메일 주소를 영구적으로 차단하여 같은 문제 사용자들이 Discourse에서 다시 가입하지 못하게 하는 것이 가장 좋습니다.

  4. 사용자 프로필 필드: 사용자 계정 프로필의 개인 정보 필드를 가져오는 데 사용할 예제 코드가 다른 가져오기 스크립트에 있는지 아는 분이 있을까요? 가져와야 할 프로필 필드는 하나(“위치”)뿐입니다.

  5. 아바타 (Gravatar가 아닌 경우): Drupal 가져오기 스크립트에는 Gravatar를 가져오는 코드는 있지만, 훨씬 더 일반적으로 사용되는 로컬 계정 아바타 사진을 가져오는 코드는 없는 것이 다소 이상하게 느껴집니다.

  6. 개인 메시지: 거의 모든 Drupal 7 포럼은 서드파티 privatemsg 모듈(공식 Drupal 개인 메시지 기능이 없음)을 사용할 가능성이 높습니다. 가져오기 스크립트는 개인 메시지 가져오기를 지원하지 않습니다. 제 경우 약 150만 개의 개인 메시지를 가져와야 합니다.

도움 주셔서 미리 감사드립니다. 또한 Drupal 가져오기 스크립트를 제공해 주셔서 감사합니다.

이러한 문제들은 대규모 가져오기(import) 작업에서 흔히 발생하는 수준입니다. 해당 코드를 작성한 사람은(아마도 충분히 신경 쓰지 않았거나, 아니면 그 문제를 인지하지 못했을 수 있습니다) 질문하신 문제들에 대해 무관심했던 것 같습니다.

이 부분은 Drupal이나 데이터베이스 자체의 버그로 보입니다(중복된 id는 발생해서는 안 됩니다). 저는 스크립트를 수정하여 중복이 있을 때 오류를 테스트하거나/또는 잡아내는 로직을 추가했을 것입니다. 하지만 질문자님의 방식은 작동했습니다(아직 더 많은 중복이 남아 있지 않는 한 말입니다).

게시물 영구 링크(post permalinks)를 생성하는 다른 가져오기 스크립트들을 참고해 보세요. 각 게시물의 PostCustomField에 import_id가 포함되어 있습니다.

그것은 base.rb나 사용자명 제안기(username suggester) 안에 있을 것입니다. 대부분 그냥 작동하며, 이를 변경할 수 있는 부분이 많지 않습니다.

그렇게 하시는 것을 권장하지 않습니다. 문제는 해당 사용자들이 작성한 게시물의 소유자가 system이 되어버리기 때문입니다. 비활성화하는 방법에 대한 예시는 다른 스크립트들을 참고해 보세요. fluxbb에는 suspend_users 스크립트가 있어 도움이 될 것입니다.

fluxbb(제가 현재 작업 중인 프로젝트)는 이를 처리합니다. 가져오기 사용자 스크립트에 다음과 같은 내용을 추가하기만 하면 됩니다:

          location: user['location'],

Gravatars는 discourse 코어에서 처리하므로, 스크립트는 이를 가져오는 데 아무것도 하지 않습니다. 그냥 작동합니다. "avatar"로 다른 스크립트들을 grep하여 어떻게 하는지 예시를 찾을 수 있습니다.

예시를 찾아보세요… ipboard에는 import_private_messages가 있습니다.

답변 감사합니다. 소스 데이터베이스를 검사해 봤는데 중복된 nid 키를 찾을 수 없어서, 이 문제는 Drupal 데이터베이스 때문이 아니라고 생각합니다.

아, 그래서 drupal.rb 밖에 그런 기능이 있는 거군요. 이제 테스트용 가져오기 사이트에서 이것저것 살펴보니, 사용자 이름 변환을 매우 잘 처리하고 있는 것 같습니다. 감사합니다!

유니코드 사용자 이름의 가져오기를 활성화하는 가장 쉬운 방법은 무엇일까요? (변환하지 않고, 즉 Narizon으로 변환하지 않고 사용자 이름 Narizón을 유지하는 경우)

웹 GUI가 설정되지 않은 인스턴스에서 Drupal 가져오기를 처음 테스트해 보았기 때문에, 유니코드 사용자 이름 허용을 위한 Discourse 옵션을 설정하지 않았습니다. 만약 해당 옵션이 설정되어 있었다면 가져오기 도구가 이를 준수했을까요? 프로덕션 마이그레이션을 실행할 때 이 기능을 활성화하는 권장 방법은 무엇인가요?

그리고 현재 테스트 인스턴스의 경우, 전체 이름을 사용자 이름에 적용하기 위한 rake 명령어가 있을까요? (prioritize username in ux는 이미 활성화했지만, 제 테스트 사용자들은 로그인에 사용자 이름만 지원하는(이메일 주소가 아닌) Drupal에 익숙하므로, 프로덕션 사용자 이름을 유지하는 것이 가장 좋다고 생각합니다. 적어도 fullname 필드에는 프로덕션 사용자 이름이 유지되었으니까요.)

아마도?

스크립트 시작 부분에 사이트 설정을 지정할 수 있습니다.

사용자 이름을 변경하는 것은 나쁜 아이디어라고 생각하지만, 마음에 들지 않는다면 사용자 이름 생성기에 전달되는 값을 변경할 수 있습니다.

감사합니다. 가져오기가 완료된 후 이름을 변경하는 것을 말씀하시는 건가요?

구식 시스템에서 사용자 이름이 비공개였고 실제 이름만 표시되었다면, 사용자 이름을 변경할 필요가 없다고 생각합니다.

만약 후자의 경우라면, 스크립트를 수정하여 사용자 이름을 실제 이름으로 설정하겠습니다. 문제는 이메일 주소를 모르면 계정을 찾을 수 없다는 점입니다.

알겠습니다. Drupal 포럼에는 시스템 사용자 이름만 있고, 별도의 실명이 없습니다. 게다가 Drupal은 이메일 주소로 로그인할 수 없고, 사용자 이름으로만 로그인할 수 있습니다. 그래서 제 경우에는 사용자 이름을 최대한 유지하는 것이 매우 중요합니다. (공백이 포함된 사용자 이름처럼 일부는 변환될 것입니다.) 따라서 가져오기 스크립트의 시작 부분에 Discourse 설정을 어떻게 지정해야 하는지 조사해야 합니다.

하지만 Discourse는 가능합니다. 따라서 사용자가 자신의 이메일 주소를 알고 있다면, 이를 이용해 비밀번호를 재설정할 수 있습니다. 아마도 사용자 이름을 기억하지 못하는 사람을 추측할 수 없으므로, 모든 사람에게 이렇게 하라고 알려주는 것이 좋을 것입니다.

제가 하라면, 가져오기 스크립트에서 SiteSetting.unicode_username=true를 설정하고 다시 실행하여 작동하는지 확인하겠습니다. rails 콘솔에서 테스트해 보는 것도 좋은 방법입니다. 이렇게 하면 다음과 같은 정보가 알려질 수 있습니다:

  User.create(username: 'Narizón', email: 'user@x.com',password: 'xxx', active: true)

음, 이것은 사용자 이름 생성기를 호출하지 않을 것 같으니, 직접 호출해야 합니다.

  UserNameSuggester.suggest("Narizón")

아니요. 그래도 유니코드 사용자 이름을 얻지 못합니다. 아마도 UsernameSuggester를 찾아서 조정해야 할 것 같습니다.

하지만 정말로 사용자 이름을 변경하고 싶다면, 스크립트를 수정하는 것보다 지금 변경하는 것이 더 나은 방법일 수 있습니다. 이렇게 할 때 모든 게시물의 사용자 이름이 업데이트되도록 확인해야 합니다. rake 작업을 사용한다면, 분명히 그렇게 될 것입니다.

훌륭합니다, 제이! 다음에 가져오기 도구를 실행할 때 이 방법을 시도해 보겠습니다.

굳이 그렇게 할 필요는 없다고 생각합니다:

lib/user_name_suggester.rb에 있지만, User.normalize_username을 원할 수도 있습니다.

정말 맞으셨네요. 엄밀히 말해 버그라기보다는, Drupal이 이전 주제 카테고리에 브레드크럼을 남겨둔 채로 이동된 주제를 처리하는 방식이 좀 특이해서 생긴 문제였습니다. 결국 하나의 완전한 Drupal 주제가 되는 데 사용될 여러 테이블 중 하나에 중복된 행을 생성하는 것이죠. 그래서 선택되는 테이블 중 하나에만 DISTINCT를 적용하는 방법을 알아내야 할 것 같습니다…

네. 매번 가져오기(import)가 고유한 문제(snowflake)를 겪는다는 점이 놀랍습니다. 어떻게 해서인지 당신의 포럼이 그 문제를 겪은 첫 번째 사례인 것 같네요(물론 많은 사람들이 문제를 해결했지만 업데이트를 PR로 제출하지 못했을 수도 있습니다). 아니면 에러를 무시한 걸까요?

아하. 아마도 이 함수는 매우 자주 사용되지 않는 것 같습니다. 스레드가 새로운 카테고리로 이동되면, 기존 카테고리에 “이동됨…” 링크를 남길 수 있는 선택 사항의 체크박스가 있습니다.

문제가 되는 중복 항목은 forum_index의 nid 컬럼에 있습니다. 따라서 GROUP BY nid를 사용하여 수정할 수 있을 것 같은데, 맞습니까?

        SELECT fi.nid nid,
               fi.title title,
               fi.tid tid,
               n.uid uid,
               fi.created created,
               fi.sticky sticky,
               f.body_value body,
	       nc.totalcount views
          FROM forum_index fi
	 LEFT JOIN node n ON fi.nid = n.nid
	 LEFT JOIN field_data_body f ON f.entity_id = n.nid
	 LEFT JOIN node_counter nc ON nc.nid = n.nid
         WHERE n.type = 'forum'
           AND n.status = 1
         GROUP BY nid

유망해 보입니다. GROUP BY nid를 포함하여 쿼리를 실행하면 행이 8개 줄어듭니다.

그렇게 하면 될 수도 있겠네요. 그 테이블에 이동되었음을 나타내는 값이 있을 거라고 생각하는데, 그 값이 없는 항목만 선택할 수 있을 것 같아요.

그것이 가장 논리적인 설계 방식이 분명합니다. 아마도 드루팔(Drupal) 특유의 방식인 것 같습니다…

이것이 하는 일은 tid(카테고리 ID)를 변경하는 것뿐입니다. 이는 제가 드루팔 데이터베이스와 씨름하면서 배운 스타일을 따르고 있습니다. 저는 DB 설계에 대해 아는 것이 없지만, 데이터를 명시적으로 저장하거나, 아니면 일부는 암묵적으로 두고 프로그래밍 로직을 통해 추론해낼 수 있다는 인상을 받습니다. 드루팔은 후자에 확실히 해당되는 것 같습니다.

음, 거의 다 된 것 같습니다. Jay님에게 지도해 주셔서 정말 감사합니다.

감사합니다. 이것이 핵심이었네요. 실제로는 Drupal import 스크립트 자체의 영구 링크 부분을 복사해서 포스트에서 실행하도록 변경하는 것만으로 해결되었습니다:

    ## 각 Drupal 댓글(답글) 링크에 영구 링크를 추가했습니다: /comment/DIGITS#comment-DIGITS
    Post.find_each do |post|
      begin
        pcf = post.custom_fields
        if pcf && pcf['import_id']
          cid = pcf['import_id'][/cid:(\d+)/, 1]
          slug = "/comment/#{cid}" # #comment-DIGITS 부분은 영구 링크를 깨뜨리며 필요하지 않습니다
          Permalink.create(url: slug, post_id: post.id)
        end
      rescue => e
        puts e.message
        puts "cid #{post.id}에 대한 영구 링크 생성 실패"
      end
    end

처음 시도했던 방법에서는 원래 Drupal 링크의 상대 페이지 #comment-DIGITS 부분을 포함시켰는데, 이것이 Discourse에서 영구 링크를 완전히 깨뜨려서 한동안 막혀 있었습니다. 나중에야 링크의 # 부분은 실제로 웹 서버로 전달되지 않으며, Drupal에서 특정 댓글이 위치한 페이지 부분으로 스크롤하기 위해만 필요했다는 것을 깨달았습니다. 따라서 Discourse에서는 외부 웹 페이지에서 오래된 /comment/YYYYYY#comment-YYYYY 링크로 접속하더라도 해당 부분이 없어도 정상적으로 작동합니다. Discourse에서는 다음과 같이 표시됩니다: /comment/YYYYYY → /t/topic-title-words/123456/X 그리고 URL 바에는 /t/topic-title-words/123456/X#comment-YYYYYY 로 표시됩니다. 잘못된 #comment-YYYYYY 부분은 무시되는 것 같습니다.

일부 포럼의 경우, 기본 Drupal importer의 postprocess_posts 함수가 실제로는 충분할 수 있다고 생각합니다. 각 포럼에 맞게 조정해야 한다는 점을 유의해야 합니다. site.com → community.site.com으로 대체하는 다소 엉성하게 하드코딩된 정규식이 있기 때문입니다. 하지만 이를 조정하면 노드 → 토픽, 댓글 → 답글에 대한 내부 포럼 링크를 다시 작성하는 데 좋은 역할을 합니다. 하지만 제 포럼의 개별 댓글(답글)에 링크를 걸어주는 외부 웹사이트가 상당수 있으며, 이를 보존하는 것이 가치가 있습니다. 또한 Google은 170만 개에 달하는 /comment-YYYYYY URL 대부분을 인덱싱하고 있으며, 이들이 모두 사라지면 검색 랭킹에 부정적인 영향을 미칠 가능성이 높습니다. 약 200만 개의 영구 링크가 Discourse에 있는 것이 문제를 일으키지 않기를 바랍니다.


정말 감사합니다. 그 함수를 거의 수정 없이 가져와서, 일부 컬럼 이름만 조정했습니다. 아주 잘 작동합니다.

  def suspend_users
    puts '', "ban된 사용자 업데이트 중"

    banned = 0
    failed = 0
    total = mysql_query("SELECT COUNT(*) AS count FROM users WHERE status = 0").first['count']

    system_user = Discourse.system_user

    mysql_query("SELECT name username, mail email FROM users WHERE status = 0").each do |b|
      user = User.find_by_email(b['email'])
      if user
        user.suspended_at = Time.now
        user.suspended_till = 200.years.from_now

        if user.save
          StaffActionLogger.new(system_user).log_user_suspend(user, "초기 import 중 ban됨")
          banned += 1
        else
          puts "사용자 #{user.username} 정지 실패. #{user.errors.try(:full_messages).try(:inspect)}"
          failed += 1
        end
      else
        puts "찾을 수 없음: #{b['email']}"
        failed += 1
      end

      print_status banned + failed, total
    end
  end

이것도 작동했습니다! Drupal의 흩어져 있는 DB 스키마를 처리해야 했고, 프로필 데이터를 포함하는 다른 테이블을 연결하기 위해 LEFT JOIN profile_value location ON users.uid = location.uid를 사용해야 했지만, Discourse 쪽에서 추가하는 것이 이렇게 쉬운 것은 정말 멋진 일입니다. 참고로 이 프로세스는 기본 설정보다 약 50% 더 느리게 실행되는 것 같으며, LEFT JOIN 때문일 것으로 추정됩니다. 하지만 약 80,000명의 사용자만 있으므로 이 정도는 감당할 수 있습니다.


이것은 꽤 어려웠습니다. 다시 한번 Drupal의 단절된 데이터베이스 스키마 때문이었습니다. 결국 jforum.rb를 기반으로 하고 Vanilla importer의 도움도 조금 받았습니다. 원래 스크립트는 아바타 파일명이 null이 아닌지 확인하기 위해 모든 변수 전달 시점에 매우 조심스럽게 체크하고 있어서, 코드를 덜 지저분하게 만들기 위해 대부분의 체크를 제거했습니다. 최악의 경우 스크립트가 충돌할 수 있지만, 제가 사용한 SQL 쿼리로는 그런 일도 일어나지 않을 것 같습니다.

  def import_users
    puts "", "사용자 import 중"

    user_count = mysql_query("SELECT count(uid) count FROM users").first["count"]

    last_user_id = -1
    
    batches(BATCH_SIZE) do |offset|
      users = mysql_query(<<-SQL
          SELECT users.uid,
                 name username,
                 mail email,
                 created,
                 picture,
                 location.value location
            FROM users
             LEFT JOIN profile_value location ON users.uid = location.uid
           WHERE users.uid > #{last_user_id}
        ORDER BY uid
           LIMIT #{BATCH_SIZE}
      SQL
      ).to_a

      break if users.empty?

      last_user_id = users[-1]["uid"]

      users.reject! { |u| @lookup.user_already_imported?(u["uid"]) }

      create_users(users, total: user_count, offset: offset) do |row|
        if row['picture'] > 0
        	q = mysql_query("SELECT filename FROM file_managed WHERE fid = #{row['picture']};").first
        	avatar = q["filename"]
        end
        email = row["email"].presence || fake_email
        email = fake_email if !EmailAddressValidator.valid_value?(email)

        username = @htmlentities.decode(row["username"]).strip

        {
          id: row["uid"],
          name: username,
          email: email,
          location: row["location"],
          created_at: Time.zone.at(row["created"]),
	  	post_create_action: proc do |user|
		    import_avatar(user, avatar)
		end
        }
      end 
    end
  end
  def import_avatar(user, avatar_source)
    return if avatar_source.blank?

    path = File.join(ATTACHMENT_DIR, avatar_source)

      @uploader.create_avatar(user, path)
  end

SQL 쿼리에 대한 유료 도움을 받은 후, Discuz, IPboard, Xenforo 스크립트에 적용해 보려고 시도했습니다. 각각에서 막혀서 진전을 보지 못했는데, Discuz 모델이 매우 유사한 데이터베이스 스키마를 가진 것으로 보였지만 @first_post_id_by_topic_id 인스턴스 변수에 대한 버그를 넘어서지 못했습니다. 수많은 시행착오 끝에 마침내 Discuz 스크립트 초반부에 이 변수가 제대로 초기화되지 않았다는 것을 깨달았습니다(Drupal 스크립트의 같은 위치에 넣으려고 했었죠). 그리고 마침내 이것이 문제를 해결했습니다:

  def initialize
    super
    
    @first_post_id_by_topic_id = {}

    @htmlentities = HTMLEntities.new

    @client = Mysql2::Client.new(
      host: "172.17.0.3",
      username: "user",
      password: "pass",
      database: DRUPAL_DB
    )
  end

…

def import_private_messages
	puts '', '개인 메시지 생성 중'

	pm_indexes = 'pm_index'
	pm_messages = 'pm_message'
	total_count = mysql_query("SELECT count(*) count FROM #{pm_indexes}").first['count']

	batches(BATCH_SIZE) do |offset|
		results = mysql_query("
SELECT pi.mid id, thread_id, pi.recipient to_user_id, pi.deleted deleted, pm.author user_id, pm.subject subject, pm.body message, pm.format format, pm.timestamp created_at FROM pm_index pi LEFT JOIN pm_message pm ON pi.mid=pm.mid WHERE deleted = 0
             LIMIT #{BATCH_SIZE}
            OFFSET #{offset};")

		break if results.size < 1

		# next if all_records_exist? :posts, results.map {|m| "pm:#{m['id']}"}

		create_posts(results, total: total_count, offset: offset) do |m|
			skip = false
			mapped = {}
			mapped[:id] = "pm:#{m['id']}"
			mapped[:user_id] = user_id_from_imported_user_id(m['user_id']) || -1
			mapped[:raw] = preprocess_raw(m['message'],m['format'])
			mapped[:created_at] = Time.zone.at(m['created_at'])
			thread_id = "pm_#{m['thread_id']}"
			if is_first_pm(m['id'], m['thread_id'])
				# 목록 테이블에서 제목 찾기
				#          pm_thread = mysql_query("
				#                SELECT thread_id, subject
				#                  FROM #{table_name 'ucenter_pm_lists'}
				#                 WHERE plid = #{m['thread_id']};").first
				mapped[:title] = m['subject']
				mapped[:archetype] = Archetype.private_message

          # 이 개인 메시지에 포함된 사용자 찾기.
          import_user_ids = mysql_query("
                SELECT thread_id plid, recipient user_id
                  FROM pm_index
                 WHERE thread_id = #{m['thread_id']};
              ").map { |r| r['user_id'] }.uniq
          mapped[:target_usernames] = import_user_ids.map! do |import_user_id|
            import_user_id.to_s == m['user_id'].to_s ? nil : User.find_by(id: user_id_from_imported_user_id(import_user_id)).try(:username)
          end.compact
          if mapped[:target_usernames].empty? # 나 자신과의 pm?
            skip = true
            puts "대상 없음으로 인해 pm:#{m['id']} 건너뜀"
          else
            @first_post_id_by_topic_id[thread_id] = mapped[:id]
          end
        else
          parent = topic_lookup_from_imported_post_id(@first_post_id_by_topic_id[thread_id])
          if parent
            mapped[:topic_id] = parent[:topic_id]
          else
            puts "부모 포스트 pm thread:#{thread_id}가 존재하지 않습니다. #{m["id"]}: #{m["message"][0..40]} 건너뜀"
            skip = true
          end
        end
        skip ? nil : mapped
      end

    end
end
# pm 시리즈의 첫 번째 pm id 찾기
def is_first_pm(pm_id, thread_id)
	result = mysql_query("
          SELECT mid id
            FROM pm_index
           WHERE thread_id = #{thread_id}
        ORDER BY id")
	result.first['id'].to_s == pm_id.to_s
end

오, 그리고 이 쿼리 대부분은 MySQL 컨테이너에서 다음을 실행하여 엄격 모드 SQL 무결성 체크를 비활성화해야 합니다:
mysql -u root -ppass -e "SET GLOBAL sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''));"


또한 누락되었음을 깨달은 것 중 하나는 poll 유형의 수천 개의 Drupal 노드였습니다. 처음에는 import_topics 함수에 WHERE type = 'forum' OR type = 'poll'를 포함하는 것만으로 충분할 것이라 생각했지만, 원래 Drupal 데이터베이스에는 심각한 문제가 있어 많은 노드를 놓치게 됩니다. 그래서 결국 import_topics를 새로운 import_polls 함수로 복사했습니다:

    def import_poll_topics
    puts '', "투표 토픽 import 중"

    polls = mysql_query(<<-SQL
      SELECT n.nid nid, n.title title, n.uid uid, n.created created, n.sticky sticky, taxonomy_index.tid tid, node_counter.totalcount views
        FROM node n
        LEFT JOIN taxonomy_index ON n.nid = taxonomy_index.nid
        LEFT JOIN node_counter ON n.nid = node_counter.nid
       WHERE n.type = 'poll'
         AND n.status = 1
    SQL
    ).to_a

    create_posts(polls) do |topic|
      {
        id: "nid:#{topic['nid']}",
        user_id: user_id_from_imported_user_id(topic['uid']) || -1,
        category: category_id_from_imported_category_id(topic['tid']),
        raw: "### Wayback Machine에서 아카이브된 투표 결과를 볼 수 있습니다:\n**https://web.archive.org/web/1234567890/http://myforum.com/node/#{topic['nid']}**",
        created_at: Time.zone.at(topic['created']),
        pinned_at: topic['sticky'].to_i == 1 ? Time.zone.at(topic['created']) : nil,
        title: topic['title'].try(:strip),
        views: topic['views'],
        custom_fields: { import_id: "nid:#{topic['nid']}" }
      }
    end
  end

실제 투표 결과를 import하는 데는 큰 관심이 없으며, 그렇게 하려면 Drupal이 모든 표를 집계하고 중복을 제거하는 전체 알고리즘을 다시 코딩해야 합니다. 저는 주로 투표 스레드 내의 후속 댓글만 import하고 싶습니다. 하지만 만에 하나 누군가 원래 투표 결과를 보고 싶어 할 경우를 대비하여, Wayback Machine의 원래 포럼 노드로의 직접 링크를 출력하도록 했습니다.


따라서 코드는 전혀 우아하지 않고 아마도 효율적이지도 않지만, 일회성 작업으로 목적을 달성할 수 있을 것입니다.

코드가 너무 많아서 죄송합니다. 누군가 불쾌해한다면 알려주세요. pastebin URL로 옮길 수 있습니다.

대부분의 사례가 이렇게 진행됩니다. 이 주제는 다른 사람이 참고할 수 있는 훌륭한 예시가 될 수 있습니다(여러분과 비슷한 기술 수준을 가진 경우).

직접 커스터마이징하는 데 얼마나 많은 시간을 보냈는지 대략적인 추산이 가능한가요?

축하합니다!

제이, 고마워! 격려해 줘서 정말 고마워.

아, 그건 생각하기 싫은데. :stuck_out_tongue_winking_eye: SQL 쿼리로 올바른 방향을 잡아준 덕분에 아마도 15시간에서 20시간 이상은 걸렸을 것 같아.

이 부분에 대해 네가 어떤 생각이 있다면 내 머리를 좀 빌려줄 수 있을까:

매우 강력한 VPS에서 프로덕션 데이터를 사용해 완전한 시험 주행을 하는데 약 70시간이 걸렸어. 게시글과 PM(개인 메시지) 가져오기가 아직 완료되지 않았더라도, 사용자들의 상호작용을 가능한 한 빨리 다시 시작하고 싶어. 또 다른 대안으로 생각한 아이디어는 preprocess_posts 함수를 비활성화하는 거야. 이 함수는 추가적인 gsub 정규식 치환을 통해 내가 상당히 수정했고, 또한 원본 게시글이 Textile 마크업인지 순수 HTML인지에 따라 두 가지 다른 명령어 중 하나로 모든 게시글과 PM을 Pandoc을 통해 처리하도록 설정했거든. 만약 preprocess_posts 루틴 전체를 비활성화하면 가져오기 시간이 거의 절반으로 줄어들 것 같고, 그러면 모든 원시 데이터가 가져와진 후에 포맷팅 관련 작업을 postprocess_posts 섹션에 추가할 수 있을 거야. 하지만 단점은 나중에 각 게시글의 원본 데이터베이스 컬럼(원본 포맷, 즉 Textile 또는 HTML)에 쉽게 접근할 수 없다는 거야. 이건 내 Pandoc 조작의 조건부 로직이니까. 아니면 각 게시글에 textile 또는 html로 라벨을 붙이는 커스텀 필드를 추가해서 나중에 후처리 단계에서 그 값을 가져올 수 있을까? 모르겠어, 그냥 소리 내어 생각해보는 중이야.

새 데이터만 포함하여 가져오기 스크립트를 다시 실행하면, 기존 데이터를 다시 가져오지 않기 때문에 훨씬 빠르게 실행됩니다. 따라서 몇 시간 정도만 소요됩니다. 이후 실행 시에는 가져와야 할 데이터가 줄어들므로 더욱 빨라질 것입니다.

특정 시점 이후의 데이터만 반환하도록 쿼리를 수정하면 속도를 더 높일 수 있습니다. 제가 다뤄본 대부분의 스크립트에는 이 목적(개발 속도를 높이기 위해 데이터의 작은 부분만 가져오는 것)을 위해 import_after 설정이 포함되어 있습니다.