บทเรียนที่ 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
}
]
}
}
}
]
}
}