Tutoriel du schéma
Tutoriel du schémaLeçon 28 : Mise à jour de grands ensembles de données

Leçon 28 : Mise à jour de grands ensembles de données

Parfois, nous devons mettre à jour des milliers de ressources en une seule action, comme l'exprime le commentaire suivant (publié dans un groupe communautaire sur WordPress) :

Je constate que pour beaucoup de clients je travaille avec de grands ensembles de données (plus de 10 000 variations de produit pour 1 produit, ou plus de 13 000 fichiers médias)... inévitablement les clients souhaitent pouvoir modifier en masse beaucoup de choses à la fois — par exemple étiqueter 2 000 fichiers médias avec la même étiquette.

Dans cette leçon du tutoriel, nous allons explorer des façons d'aborder cette tâche.

Nested Mutations

Pour que cette requête GraphQL fonctionne, la Configuration du schéma appliquée à l'endpoint doit avoir les Nested Mutations activées

Grâce aux Nested Mutations, nous pouvons récupérer et mettre à jour des milliers de ressources depuis la base de données via une seule requête GraphQL :

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
        }
      }
    }
  }
}

Selon la résilience du système, cette unique exécution GraphQL pourrait toutefois imposer une charge trop importante sur la base de données, voire la faire planter.

Paginer l'exécution de la requête GraphQL

Si la mise à jour de milliers de ressources à la fois fait planter le système, la solution est simple : au lieu d'exécuter la GraphQL une seule fois pour des milliers de ressources, nous pouvons l'exécuter des centaines de fois pour quelques dizaines de ressources à chaque fois.

Les scripts bash suivants commencent par récupérer le nombre total de commentaires via commentCount, calculent ensuite les segments en tenant compte de la variable d'environnement $ENTRIES_TO_PROCESS, puis calculent les paramètres de pagination et appellent la requête GraphQL pour chaque segment (en récupérant simplement les commentaires de ce 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

Exécuter la requête GraphQL de façon récursive

Étant donné que la solution ci-dessus implique des scripts bash, elle doit être exécutée via la CLI (ou un panneau d'administration ou un outil), ce qui en limite l'usage.

Nous pouvons reproduire la même logique directement dans la requête GraphQL elle-même, nous permettant ainsi de l'exécuter depuis WordPress (en la stockant même déjà comme une Persisted Query GraphQL).

La requête GraphQL ci-dessous s'exécute de façon récursive. Lors de la première invocation, elle :

  • Divise le nombre total de ressources à mettre à jour en segments (calculés à partir de la variable $limit fournie)
  • S'exécute elle-même via une nouvelle requête HTTP pour chacun des segments (en passant le $offset correspondant en tant que variable), ne mettant ainsi à jour qu'un sous-ensemble de toutes les ressources à la fois

La requête GraphQL est récursive car les requêtes HTTP pointent vers la même URL que l'URL actuelle (en ajoutant la variable $offset pour ce segment), pour laquelle nous récupérons l'URL (ainsi que le corps, la méthode et les en-têtes) depuis la requête HTTP actuelle (via l'extension HTTP Request via Schema).

L'argument $async passé à _sendHTTPRequests a été défini à false, afin que les requêtes HTTP s'exécutent l'une après l'autre. De plus, la variable optionnelle $delay permet d'indiquer combien de millisecondes attendre avant d'envoyer chaque requête.

Une fois toutes les ressources mises à jour, l'exécution de la requête GraphQL atteint la fin et se termine :

# 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
}

La réponse est :

{
  "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 }
            ]
          }
        }
      }
    ]
  }
}