บทช่วยสอน Schema
บทช่วยสอน Schemaบทเรียนที่ 28: การอัปเดตข้อมูลชุดใหญ่

บทเรียนที่ 28: การอัปเดตข้อมูลชุดใหญ่

บางครั้งเราจำเป็นต้องอัปเดตทรัพยากรหลายพันรายการในการดำเนินการครั้งเดียว ดังที่แสดงในความคิดเห็นต่อไปนี้ (โพสต์ในกลุ่มชุมชนเกี่ยวกับ WordPress):

ฉันพบว่าสำหรับลูกค้าจำนวนมาก ฉันต้องทำงานกับข้อมูลชุดใหญ่ (variation ของผลิตภัณฑ์มากกว่า 10,000 รายการสำหรับ 1 ผลิตภัณฑ์ หรือไฟล์มีเดียมากกว่า 13,000 ไฟล์) ... อย่างหลีกเลี่ยงไม่ได้ ลูกค้าต้องการที่จะแก้ไขสิ่งต่าง ๆ จำนวนมากพร้อมกันแบบ bulk เช่น ติดแท็กไฟล์มีเดีย 2,000 ไฟล์ด้วยแท็กเดียวกัน

ในบทเรียนของทูทอเรียลนี้ เราจะสำรวจวิธีต่าง ๆ ในการจัดการกับงานนี้

Nested Mutations

เพื่อให้ GraphQL query นี้ทำงานได้ Schema Configuration ที่ใช้กับ endpoint จำเป็นต้องเปิดใช้งาน Nested Mutations

ด้วย Nested Mutations เราสามารถดึงและอัปเดตทรัพยากรหลายพันรายการจาก DB ผ่าน GraphQL query เดียวได้:

mutation ReplaceOldWithNewDomainInPosts {
  posts(pagination: { limit: 3000 }) {
    id
    rawContent
    adaptedRawContent: _strReplace(
      search: "https://my-old-domain.com"
      replaceWith: "https://my-new-domain.com"
      in: $__rawContent
    )
    update(input: {
      contentAs: { html: $__adaptedRawContent }
    }) {
      status
      errors {
        __typename
        ...on ErrorPayload {
          message
        }
      }
    }
  }
}

อย่างไรก็ตาม ขึ้นอยู่กับความทนทานของระบบ การทำงานของ GraphQL ครั้งเดียวนี้อาจสร้างภาระมากเกินไปให้กับ DB จนถึงขั้นทำให้ระบบล่มได้

การแบ่งหน้าการทำงานของ GraphQL query

หากการอัปเดตทรัพยากรหลายพันรายการพร้อมกันทำให้ระบบล่ม วิธีแก้ไขก็ง่ายมาก: แทนที่จะรัน GraphQL เพียงครั้งเดียวสำหรับทรัพยากรหลายพันรายการ เราสามารถรันมันหลายร้อยครั้ง ครั้งละไม่กี่สิบรายการได้

bash script ต่อไปนี้จะค้นหาจำนวนความคิดเห็นทั้งหมดก่อนผ่าน commentCount จากนั้นคำนวณ segment โดยพิจารณา env var $ENTRIES_TO_PROCESS แล้วคำนวณพารามิเตอร์การแบ่งหน้า และเรียก GraphQL query สำหรับแต่ละ segment (เพียงดึงความคิดเห็นจาก segment นั้น ๆ):

# Get the number of comments in the site
GRAPHQL_RESPONSE=$(curl
  -X POST \
  -H "Content-Type: application/json" \
  -d '{"query": "{\n  commentCount\n}"}' \
  https://mysite.com/graphql/)
 
# Extract the number of comments into a variable
COMMENT_COUNT=$(echo $GRAPHQL_RESPONSE \
  | grep -E -o '"commentCount\":([0-9]+)' \
  | cut -d':' -f2-)
 
echo "Number of comments: $COMMENT_COUNT"
 
# How many entries will be processed on each query
ENTRIES_TO_PROCESS=10
 
# Calculate how many requests must be triggered
PAGINATION_COUNT=$(($(($COMMENT_COUNT / $ENTRIES_TO_PROCESS)) + $(($(($COMMENT_COUNT % $ENTRIES_TO_PROCESS)) ? 1 : 0))))
 
echo "Number of requests to process (at $ENTRIES_TO_PROCESS entries per request): $PAGINATION_COUNT"
 
# Execute the requests, at one per second
for PAGINATION_NUMBER in $(seq 0 $(($PAGINATION_COUNT - 1))); do sleep 1 && echo "\n\nPagination number: $PAGINATION_NUMBER\n" && curl -X POST -H "Content-Type: application/json" -d "{\"query\": \"{ comments(pagination: { limit: $ENTRIES_TO_PROCESS, offset: $(($PAGINATION_NUMBER * $ENTRIES_TO_PROCESS)) }) { id date content } }\"}" https://mysite.com/graphql/ ; done

การรัน GraphQL query แบบ recursive

เนื่องจากวิธีแก้ไขข้างต้นเกี่ยวข้องกับ bash scripting มันจึงต้องรันผ่าน CLI (หรือแผงควบคุมหรือเครื่องมือบางอย่าง) ซึ่งจำกัดการใช้งาน

เราสามารถจำลองตรรกะเดียวกันนี้เข้าไปใน GraphQL query เองได้ จึงทำให้เราสามารถรันมันภายใน WordPress ได้เลย (ถึงขั้นจัดเก็บมันเป็น GraphQL Persisted Query ได้ด้วย)

GraphQL query ด้านล่างจะรันตัวมันเองแบบ recursive เมื่อถูกเรียกใช้งานครั้งแรก มันจะ:

  • แบ่งจำนวนทรัพยากรทั้งหมดที่ต้องอัปเดตออกเป็น segment (คำนวณโดยใช้ตัวแปร $limit ที่ให้มา)
  • รันตัวมันเองผ่าน HTTP request ใหม่สำหรับแต่ละ segment (โดยส่ง $offset ที่สอดคล้องกันเป็นตัวแปร) จึงอัปเดตทรัพยากรเพียงบางส่วนในแต่ละช่วงเวลา

GraphQL query นี้เป็น recursive โดยการให้ HTTP request ชี้ไปยัง URL เดียวกันกับ URL ปัจจุบัน (พร้อมเพิ่มตัวแปร $offset สำหรับ segment นั้น) ซึ่งเราดึง URL (รวมถึง body, method และ header) จาก HTTP request ปัจจุบัน (ผ่าน extension HTTP Request via Schema)

อาร์กิวเมนต์ $async ที่ส่งไปยัง _sendHTTPRequests ถูกตั้งค่าเป็น false เพื่อให้ HTTP request ถูกรันทีละอันต่อเนื่องกัน นอกจากนี้ ตัวแปรเสริม $delay ยังช่วยให้ระบุได้ว่าจะหน่วงเวลากี่มิลลิวินาทีก่อนส่งแต่ละ request

เมื่อทรัพยากรทั้งหมดถูกอัปเดตแล้ว การทำงานของ GraphQL query จะถึงจุดสิ้นสุดและจบลง:

# When first invoked, we do not pass variable `$offset`
# Then `$offset` is `null`, and dynamic variable `$executeQuery` will be `true`
query ExportExecute(
  $offset: Int
) {
  executeQuery: _notNull(value: $offset)
    @export(as: "executeQuery")
    @remove # Comment this directive to visualize output during development
}
 
# Only calculate the segments on the first invocation of the GraphQL query
query CalculateVars($limit: Int! = 10)
  @depends(on: "ExportExecute")
  @skip(if: $executeQuery)
{
  # Calculate the number of HTTP requests to be sent
  commentCount
  fractionalNumberExecutions: _floatDivide(number: $__commentCount, by: $limit)
    @remove # Comment this directive to visualize output during development
  numberExecutions: _floatCeil(number: $__fractionalNumberExecutions)
  
  # Generate a list of the offset
  arrayOffsets: _arrayPad(array: [], length: $__numberExecutions, value: null)
    @underEachArrayItem(
      passIndexOnwardsAs: "position"
    )
      @applyField(
        name: "_intMultiply"
        arguments: {
          multiply: $position
          with: $limit
        }
        setResultInResponse: true
      )
    @export(as: "offsets")
 
  # Vars needed to generate a list of the HTTP Request inputs,
  # with many of them retrieved from the current HTTP request data
  url: _httpRequestFullURL
    @export(as: "url")
    @remove # Comment this directive to visualize output during development
  method: _httpRequestMethod
    @export(as: "method")
    @remove # Comment this directive to visualize output during development
  headers: _httpRequestHeaders
    @remove # Comment this directive to visualize output during development
  headersInputList: _objectConvertToNameValueEntryList(
    object: $__headers
  )
    @export(as: "headersInputList")
    @remove # Comment this directive to visualize output during development
  body: _httpRequestBody
    @remove # Comment this directive to visualize output during development
  bodyJSONObject: _strDecodeJSONObject(string: $__body)
    @export(as: "bodyJSONObject")
    @remove # Comment this directive to visualize output during development
  bodyHasVariables: _propertyIsSetInJSONObject(
    object: $__bodyJSONObject,
    by: { key: "variables" }
  )
    @export(as: "bodyHasVariables")
    @remove # Comment this directive to visualize output during development
}
 
query GenerateVars
  @depends(on: ["ExportExecute", "CalculateVars"])
  @skip(if: $executeQuery)
{
  bodyJSON: _echo(value: $bodyJSONObject)
    @unless(condition: $bodyHasVariables)
      @objectAddEntry(
        key: "variables"
        value: {}
      )
    @export(as: "bodyJSON")
    @remove # Comment this directive to visualize output during development
}
 
# Generate all the HTTPRequestInput objects to send each of the HTTP requests
query GenerateRequestInputs(
  $timeout: Float,
  $delay: Int
)
  @depends(on: ["ExportExecute", "GenerateVars"])
  @skip(if: $executeQuery)
{
  # Generate a list of the HTTP Request inputs (without the offset)
  requestInputs: _echo(value: $offsets)
    @underEachArrayItem(
      passValueOnwardsAs: "requestOffset"
      affectDirectivesUnderPos: [1, 2]
    )
      @applyField(
        name: "_objectAddEntry",
        arguments: {
          object: $bodyJSON
          underPath: "variables"
          key: "offset"
          value: $requestOffset
        },
        passOnwardsAs: "itemJSON"
      )
      @applyField(
        name: "_echo",
        arguments: {
          value: {
            url: $url
            method: $method
            options: {
              headers: $headersInputList
              json: $itemJSON
              timeout: $timeout
              delay: $delay
            }
          }
        },
        setResultInResponse: true
      )
    @export(as: "requestInputs")
    @remove # Comment this directive to visualize output during development
}
 
# Execute all the generated URLs, either asynchronously or not
query ExecuteURLs
  @depends(on: ["ExportExecute", "GenerateRequestInputs"])
  @skip(if: $executeQuery)
{
  _sendHTTPRequests(
    async: false
    inputs: $requestInputs
  ) {
    statusCode
    contentType
    body
      @remove
    bodyJSON: _strDecodeJSONObject(string: $__body)
  }
}
 
# This is the actual execution of the query.
# In this case, it simply prints the time when it was executed,
# the provided query variables, and the comment IDs for that segment
query ExecuteQuery(
  $offset: Int
  $limit: Int! = 10
)
  @depends(on: "ExportExecute")
  @include(if: $executeQuery)
{
  executionTime: _httpRequestRequestTime
  queryVariables: _sprintf(string: "[$limit: %s, $offset: %s]", values: [$limit, $offset])
  comments(
    pagination: { limit: $limit, offset: $offset }
    sort: { order: ASC, by: ID }
  ) {
    id
  }
}
 
query ExecuteAll
  @depends(on: ["ExecuteURLs", "ExecuteQuery"])
{
  id
    @remove
}

response คือ:

{
  "data": {
    "commentCount": 23,
    "numberExecutions": 3,
    "arrayOffsets": [
      0,
      10,
      20
    ],
    "_sendHTTPRequests": [
      {
        "statusCode": 200,
        "contentType": "application/json",
        "bodyJSON": {
          "data": {
            "executionTime": 1689814467,
            "queryVariables": "[$limit: 10, $offset: 0]",
            "comments": [
              {
                "id": 2
              },
              {
                "id": 3
              },
              {
                "id": 4
              },
              {
                "id": 5
              },
              {
                "id": 6
              },
              {
                "id": 7
              },
              {
                "id": 8
              },
              {
                "id": 9
              },
              {
                "id": 10
              },
              {
                "id": 11
              }
            ]
          }
        }
      },
      {
        "statusCode": 200,
        "contentType": "application/json",
        "bodyJSON": {
          "data": {
            "executionTime": 1689814468,
            "queryVariables": "[$limit: 10, $offset: 10]",
            "comments": [
              {
                "id": 12
              },
              {
                "id": 13
              },
              {
                "id": 16
              },
              {
                "id": 17
              },
              {
                "id": 18
              },
              {
                "id": 19
              },
              {
                "id": 20
              },
              {
                "id": 21
              },
              {
                "id": 22
              },
              {
                "id": 23
              }
            ]
          }
        }
      },
      {
        "statusCode": 200,
        "contentType": "application/json",
        "bodyJSON": {
          "data": {
            "executionTime": 1689814470,
            "queryVariables": "[$limit: 10, $offset: 20]",
            "comments": [
              {
                "id": 24
              },
              {
                "id": 25
              },
              {
                "id": 26
              }
            ]
          }
        }
      }
    ]
  }
}