Tutorial dello schema
Tutorial dello schemaLezione 28: Aggiornamento di grandi insiemi di dati

Lezione 28: Aggiornamento di grandi insiemi di dati

A volte dobbiamo aggiornare migliaia di risorse in una singola azione, come espresso nel seguente commento (pubblicato in un gruppo della community su WordPress):

Mi ritrovo spesso a lavorare con grandi insiemi di dati per molti clienti (più di 10.000 varianti di prodotto per 1 prodotto, o più di 13.000 file multimediali)... inevitabilmente i clienti vogliono poter modificare in blocco molte cose contemporaneamente — ad esempio applicare lo stesso tag a 2.000 file multimediali.

In questa lezione del tutorial esploreremo i modi per affrontare questo compito.

Nested Mutations

Affinché questa query GraphQL funzioni, la Configurazione dello schema applicata all'endpoint deve avere le Nested Mutations abilitate

Grazie alle Nested Mutations, possiamo recuperare e aggiornare migliaia di risorse dal DB tramite una singola query 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
        }
      }
    }
  }
}

A seconda della resilienza del sistema, però, questa singola esecuzione GraphQL potrebbe sovraccaricare eccessivamente il DB, arrivando persino a farlo crashare.

Paginare l'esecuzione della query GraphQL

Se aggiornare migliaia di risorse in una volta sola fa crashare il sistema, la soluzione è semplice: invece di eseguire la GraphQL una sola volta per migliaia di risorse, possiamo eseguirla centinaia di volte per qualche decina di risorse alla volta.

I seguenti script bash prima trovano il numero totale di commenti tramite commentCount, poi calcolano i segmenti tenendo conto della variabile d'ambiente $ENTRIES_TO_PROCESS, e infine calcolano i parametri di paginazione e chiamano la query GraphQL per ciascun segmento (recuperando semplicemente i commenti di quel segmento):

# 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

Eseguire la query GraphQL in modo ricorsivo

Poiché la soluzione precedente richiede script bash, deve essere eseguita tramite CLI (o qualche pannello di amministrazione o strumento), il che ne limita l'utilizzo.

Possiamo replicare la stessa logica direttamente nella query GraphQL stessa, consentendoci così di eseguirla già all'interno di WordPress (persino memorizzandola già come Persisted Query GraphQL).

La query GraphQL qui sotto si esegue in modo ricorsivo. Alla prima invocazione:

  • Divide il numero totale di risorse da aggiornare in segmenti (calcolati utilizzando la variabile $limit fornita)
  • Si esegue essa stessa tramite una nuova richiesta HTTP per ciascuno dei segmenti (passando il corrispondente $offset come variabile), aggiornando così solo un sottoinsieme di tutte le risorse per volta

La query GraphQL è ricorsiva in quanto le richieste HTTP puntano allo stesso URL di quella corrente (aggiungendo la variabile $offset per quel segmento), dal quale recuperiamo l'URL (nonché il corpo, il metodo e gli header) dalla richiesta HTTP corrente (tramite l'estensione HTTP Request via Schema).

L'argomento $async passato a _sendHTTPRequests è stato impostato su false, in modo che le richieste HTTP vengano eseguite una dopo l'altra. Inoltre, la variabile opzionale $delay consente di indicare quanti millisecondi attendere prima di inviare ogni richiesta.

Una volta aggiornate tutte le risorse, l'esecuzione della query GraphQL raggiunge la fine e termina:

# 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 risposta è:

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