Implementar sistemas de dados e compra de jogadores

*Este conteúdo é traduzido por IA (Beta) e pode conter erros. Para ver a página em inglês, clique aqui.

Plano de fundo

O Roblox fornece um conjunto de APIs para interagir com armazenamentos de dados via DataStoreService .O caso de uso mais comum para essas APIs é para salvar, carregar e replicar dados do jogador.Ou seja, dados associados ao progresso do jogador, compras e outras características de sessão que persistem entre sessões de jogo individuais.

A maioria das experiências no Roblox usa essas APIs para implementar algum tipo de sistema de dados do jogador.Essas implementações diferem em sua abordagem, mas geralmente buscam resolver o mesmo conjunto de problemas.

Problemas comuns

Abaixo estão alguns dos problemas mais comuns que os sistemas de dados do jogador tentam resolver:

  • No acesso à memória: DataStoreService solicitações fazem solicitações da web que operam de forma assíncrona e estão sujeitas a limites de taxa.Isso é apropriado para uma carga inicial no início da sessão, mas não para operações de leitura e gravação de alta frequência durante o curso normal do jogabilidade.Os sistemas de dados de jogadores da maioria dos desenvolvedores armazenam esse dado em memória no servidor Roblox, limitando DataStoreService pedidos aos seguintes cenários:

    • Leitura inicial no início de uma sessão
    • Escrita final no final da sessão
    • Escrituras periódicas em um intervalo para mitigar o cenário em que a escrita final falha
    • Escreve para garantir que os dados sejam salvos enquanto processa uma comprar
  • Armazenamento eficiente: Armazenar todos os dados de sessão de um jogador em uma única tabela permite que você atualize vários valores atomicamente e lidar com a mesma quantidade de dados em menos solicitações.Ele também remove o risco de des sincronização inter-valor e torna os revertimentos mais fáceis de entender.

    Alguns desenvolvedores também implementam serialização personalizada para compactar grandes estruturas de dados (normalmente para salvar conteúdo gerado pelo usuário no jogo).

  • Replicação: O cliente precisa de acesso regular aos dados de um jogador (por exemplo, para atualizar a interface).Uma abordagem genérica para replicar dados do jogador para o cliente permite que você transmita essas informações sem precisar criar sistemas de replicação personalizados para cada componente de dados.Os desenvolvedores muitas vezes querem que a opção seja seletiva sobre o que é e não é replicado para o cliente.

  • Manuseio de erros: Quando os armazenamentos de dados não podem ser acessados, a maioria das soluções implementará um mecanismo de tentativa novamente e um recurso aos dados "padrão".É necessário cuidado especial para garantir que os dados de rejeição não substituam dados "reais" mais tarde e que isso seja comunicado ao jogador de forma apropriada.

  • Reencarnações: Quando os armazenamentos de dados são inacessíveis, a maioria das soluções implementa um mecanismo de reencarnação e um recurso a dados padrão.Tome cuidado especial para garantir que os dados de rejeição não substituam dados "reais" mais tarde e comunicar a situação ao jogador de forma adequada.

  • Bloqueio de sessão: Se os dados de um único jogador forem carregados e permanecerem em memória em vários servidores, podem ocorrer problemas em que um servidor salva informações desatualizadas.Isso pode levar à perda de dados e falhas comuns de duplicação de itens.

  • Manuseio de compra atômica: Verifique, conceda e registre compras atômicas para evitar que itens sejam perdidos ou concedidos várias vezes.

códigode amostra

O Roblox tem código de referência para ajudá-lo a projetar e construir sistemas de dados do jogador.O resto desta página examina detalhes de plano de fundo, implementação e advertências gerais.


Depois de importar o modelo no Studio, você deve ver a seguinte estrutura de pasta:

Explorer window showing the purchasing system model.

Arquitetura

Este diagrama de alto nível ilustra os sistemas-chave na amostra e como eles interagem com o código no resto da experiência.

An architecture diagram for the code sample.

Tentativas de novamente

Classe: DataStoreWrapper

Plano de fundo

Como DataStoreService faz solicitações da web sob o capô, seus pedidos não são garantidos de ter sucesso.Quando isso acontece, os métodos DataStore lançam erros, permitindo que você os manipule.

Um "gotcha" comum pode ocorrer se você tentar lidar com falhas no armazenamento de dados como esta:


local function retrySetAsync(dataStore, key, value)
for _ = 1, MAX_ATTEMPTS do
local success, result = pcall(dataStore.SetAsync, dataStore, key, value)
if success then
break
end
task.wait(TIME_BETWEEN_ATTEMPTS)
end
end

Embora este seja um mecanismo de tentativa perfeitamente válido para uma função genérica, ele não é adequado para solicitações DataStoreService genéricas porque não garante a ordem em que as solicitações são feitas.Preservar a ordem das solicitações é importante para DataStoreService solicitações porque elas interagem com o estado.Considere o seguinte cenário:

  1. O pedido A é feito para definir o valor da chave K para 1.
  2. O pedido falha, então um reteste está agendado para executar em 2 segundos.
  3. Antes que o erro ocorra novamente, solicite B define o valor de K para 2, mas o erro de solicitação A imediatamente substitui esse valor e define K para 1.

Embora UpdateAsync opere na última versão do valor da chave, UpdateAsync pedidos ainda devem ser processados para evitar estados transitórios inválidos (por exemplo, uma compra subtrai moedas antes que uma adição de moeda seja processada, resultando em moedas negativas).

Nosso sistema de dados de jogador usa uma nova classe, DataStoreWrapper, que fornece tentativas de repetição que são garantidas de serem processadas em ordem por chave.

Aproximação

An process diagram illustrating the retry system

DataStoreWrapper fornece métodos correspondentes aos métodos DataStore : DataStore:GetAsync() , DataStore:SetAsync() , DataStore:UpdateAsync() e DataStore:RemoveAsync() .

Estes métodos, quando chamados:

  1. Adicione o pedido a uma fila.Cada chave tem sua própria fila, onde as solicitações são processadas em ordem e em série.O thread solicitante se rende até que a solicitação tenha sido concluída.

    Essa funcionalidade é baseada na classe ThreadQueue, que é um planejador de tarefas baseado em corrotinas e limitador de taxa.Em vez de retornar uma promessa, ThreadQueue produz o subprocesso atual até que a operação seja concluída e lança um erro se falhar.Isso é mais consistente com padrões síncronos e assíncronos idiomáticos Luau.

  2. Se um pedido falhar, ele tenta novamente com um recuo exponencial configurável.Essas revisões forma parte do retorno enviado ao ThreadQueue, portanto, elas são garantidas de concluir antes que a próxima solicitação na fila para essa chave comece.

  3. Quando um pedido é concluído, o método de pedido retorna com o padrão success, result

DataStoreWrapper também expõe métodos para obter o comprimento da fila para uma chave dada e limpar solicitações expiradas.A última opção é particularmente útil em cenários em que o servidor está desligando e não há tempo para processar quaisquer solicitações mais recentes.

Ressalvas

DataStoreWrapper segue o princípio de que, fora de cenários extremos, cada solicitação de armazenamento de dados deve ser permitida a completar (com sucesso ou de outra forma), mesmo que uma solicitação mais recente a torne redundante.Quando uma nova solicitação ocorre, solicitações expiradas não são removidas da fila, mas são permitidas concluir antes que a nova solicitação seja iniciada.A razão para isso está na aplicabilidade deste módulo como uma utilidade de armazenamento de dados genérica, ao invés de uma ferramenta específica para dados do jogador, e é a seguinte:

  1. É difícil decidir sobre um conjunto intuitivo de regras para quando uma solicitação é segura para ser removida da fila. Considere a seguinte fila:

    Value=0, SetAsync(1), GetAsync(), SetAsync(2)

    O comportamento esperado é que GetAsync() retornaria 1, mas se removermos a solicitação SetAsync() da fila devido a ela estar sendo feita redundante pela mais recente, ela retornaria 0 .

    A progressão lógica é que quando uma nova solicitação de escrita é adicionada, apenas limpe solicitações estagnadas tão longe quanto a solicitar / pedirde leitura mais recente.UpdateAsync(), de longe a operação mais comum (e a única usada por este sistema), pode ler e escrever, então seria difícil reconciliar dentro deste design sem adicionar complexidade extra.

    DataStoreWrapper poderia exigir que você especificasse se um pedido UpdateAsync() foi permitido para ler e/ou escrever, mas não teria aplicabilidade ao nosso sistema de dados do jogador, onde isso não pode ser determinado com antecedência devido ao mecanismo de bloqueio de sessão (abordado em mais detalhes mais tarde).

  2. Uma vez removida da fila, é difícil decidir por uma regra intuitiva para como isso deve ser tratado.Quando um pedido DataStoreWrapper é feito, o subprocesso atual é liberado até que seja concluído.Se removemos solicitações estagnadas da fila, teríamos que decidir se devemos retornar false, "Removed from queue" ou nunca retornar e descartar o Subprocessoativo.Ambas as abordagens vêm com suas próprias desvantagens e transferem complexidade adicional para o consumidor.

Em última análise, a nossa opinião é de que a abordagem simples (processar cada solicitar / pedir) é preferível aqui e cria um ambiente mais claro para navegar quando se aproxima de problemas complexos como bloqueio de sessão.A única exceção a isso é durante DataModel:BindToClose(), onde limpar a fila se torna necessário para salvar todos os dados dos usuários a tempo e o retorno da função individual de valor não é mais uma preocupação em andamento.Para contabilizar isso, exponemos um método skipAllQueuesToLastEnqueued .Para mais contexto, veja Dados do Jogador.

Bloqueio de sessão

Classe: SessionLockedDataStoreWrapper

Plano de fundo

Os dados do jogador são armazenados na memória no servidor e só são lidos e escritos nos armazenamentos de dados subjacentes quando necessário.Você pode ler e atualizar dados do jogador em memória instantaneamente sem precisar de solicitações da web e evitar exceder os limites DataStoreService .

Para que este modelo funcione como pretendido, é imperativo que nenhum mais de um servidor seja capaz de carregar os dados de um jogador na memória do DataStore ao mesmo tempo.

Por exemplo, se o servidor A carregar os dados de um jogador, o servidor B não pode carregar esses dados até que o servidor A libere seu bloqueio sobre ele durante um salvamento final.Sem um mecanismo de bloqueio, o servidor B poderia carregar dados desatualizados do jogador da armazenagem de dados antes que o servidor A tenha a chance de salvar a versão mais recente que ele tem em memória.Então, se o servidor A salvar seus dados mais recentes após o servidor B carregar os dados desatualizados, o servidor B substituiria esse dado mais recente durante seu próximo salvamento.

Embora o Roblox só permita que um cliente se conecte a um servidor de cada vez, você não pode assumir que os dados de uma sessão sejam sempre salvos antes que a próxima sessão comece.Considere os seguintes cenários que podem ocorrer quando um jogador deixa o servidor A:

  1. O servidor A faz um pedido DataStore de salvar seus dados, mas o pedido falha e requer várias tentativas para concluir com sucesso.Durante o período de re試, o jogador se junta ao servidor B.
  2. Servidor A faz muitas chamadas UpdateAsync() ao mesmo key e é limitado.O pedido de salvamento final é colocado em uma fila.Enquanto a solicitação está na fila, o jogador se junta ao servidor B.
  3. No servidor A, algum código conectado ao evento PlayerRemoving é executado antes que os dados do jogador sejam salvos.Antes que esta operação conclua, o jogador se junta ao servidor B.
  4. O desempenho do servidor A diminuiu ao ponto em que o salvamento final é adiado até que o jogador se junte ao servidor B.

Esses cenários devem ser raros, mas ocorrer, especialmente em situações em que um jogador se desconecta de um servidor e se conecta a outro em rápida sucessão (por exemplo, enquanto se teletransporta).Alguns usuários maliciosos podem até tentar abusar desse comportamento para concluir ações sem persistirem.Isso pode ser particularmente impactante em jogos que permitem que os jogadores negociem e é uma fonte comum de exploits de duplicação de itens.

O bloqueio de sessão aborda essa vulnerabilidade garantindo que, quando a chave DataStore de um jogador for lida primeiro pelo servidor, o servidor escreve atomivamente um bloqueio no metadado da chave dentro da mesma chamada UpdateAsync().Se esse valor de bloqueio estiver presente quando qualquer outro servidor tentar ler ou escrever a chave, o servidor não procede.

Aproximação

An process diagram illustrating the session locking system

SessionLockedDataStoreWrapper é um meta-wrapper em torno da classe DataStoreWrapper.DataStoreWrapper fornece funcionalidades de fila e tentativa novamente, que SessionLockedDataStoreWrapper complementam com bloqueio de sessão.

SessionLockedDataStoreWrapper passes a cada DataStore solicitação - independentemente de se é GetAsync , SetAsync ou UpdateAsync - através de UpdateAsync .Isso ocorre porque UpdateAsync permite que uma chave seja lida e escrita para ser atômica.Também é possível abandonar a escrita com base no valor lido retornando nil na chamada de retorno de chamada.

A função de transformação passada em UpdateAsync para cada solicitação realiza as seguintes operações:

  1. Verifica se a chave é segura para acesso, abandonando a operação se não for. "Seguro para acesso" significa:

    • O objeto de metadados da chave não inclui um valor não reconhecido LockId que foi atualizado pela última vez menos de um tempo de expiração de bloqueio.Isso contribui para respeitar um bloqueio colocado por outro servidor e ignorar esse bloqueio se expirar.

    • Se este servidor colocou seu próprio valor LockId na metadados da chave anteriormente, então esse valor ainda está nos metadados da chave.Isso explica a situação em que outro servidor assumiu o bloqueio deste servidor (por expiração ou por força) e o liberou mais tarde.Alternativamente formulado, mesmo que LockId seja nil, outro servidor ainda poderia ter substituído e removido um bloqueio no tempo desde que você bloqueou a chave.

  2. UpdateAsync executa a operação DataStore a solicitada pelo consumidor de SessionLockedDataStoreWrapper . Por exemplo, GetAsync() traduz para function(value) return value end .

  3. Dependendo dos parâmetros passados na solicitar / pedir, UpdateAsync bloqueia ou desbloqueia a chave:

    1. Se a chave for bloqueada, UpdateAsync define o LockId no metadado da chave para um GUID.Este GUID é armazenado na memória no servidor para que possa ser verificado na próxima vez que acessar a chave.Se o servidor já tiver um bloqueio nesta chave, ele não faz alterações.Também agenda uma tarefa para avisá-lo se você não acessar a chave novamente para manter o bloqueio dentro do tempo de expiração do bloqueio.

    2. Se a chave for desbloqueada, UpdateAsync remove o LockId na metadados da chave.

Um manipulador de tentativa personalizado é passado para o subjacente DataStoreWrapper para que a operação seja retentida se for abortada no passo 1 devido à sessão estar bloqueada.

Uma mensagem de erro personalizada também é retornada ao consumidor, permitindo que o sistema de dados do jogador relate um erro alternativo no caso de bloqueio de sessão para o cliente.

Ressalvas

O regime de bloqueio de sessão depende de um servidor sempre liberando seu bloqueio em uma chave quando terminar com ele.Isso sempre deve acontecer através de uma instrução para desbloquear a chave como parte da escrita final em PlayerRemoving ou BindToClose() .

No entanto, o desbloqueio pode falhar em certas situações. Por exemplo:

  • O servidor travou ou DataStoreService foi inoperável para todas as tentativas de acessar a chave.
  • Devido a um erro na lógica ou bug semelhante, a instrução para desbloquear a chave não foi feita.

Para manter o bloqueio em uma chave, você deve acessá-la regularmente enquanto estiver carregada na memória.Isso normalmente seria feito como parte do ciclo de salvamento automático executando em segundo plano na maioria dos sistemas de dados do jogador, mas esse sistema também expõe um método refreshLockAsync se você precisar fazer isso manualmente.

Se o tempo de expiração do bloqueio foi excedido sem que o bloqueio seja atualizado, então qualquer servidor é livre para assumir o bloqueio.Se um servidor diferente assumir o bloqueio, as tentativas do servidor atual de ler ou escrever a chave falham a menos que ele estabeleça um novo bloqueio.

Processamento de produto do desenvolvedor

Singleton: ReceiptHandler

Plano de fundo

O retorno de chamada ProcessReceipt crítico determina quando finalizar uma comprar.ProcessReceipt é chamado em cenários muito específicos.Para o conjunto de garantias, veja MarketplaceService.ProcessReceipt .

Embora a definição de "manuseio" de uma compra possa diferir entre experiências, usamos os seguintes critérios

  1. A compra não foi previamente tratada.

  2. A compra é refletida na sessão atual.

  3. A compra foi salva em um DataStore .

    Cada comprar, mesmo consumíveis de uso único, deve ser refletida no DataStore para que o histórico de compras do usuário seja incluído com seus dados de sessão.

Isso requer a realização das seguintes operações antes de retornar PurchaseGranted :

  1. Verifique se o PurchaseId não foi já registrado como tratado.
  2. Recompense a compra nos dados do jogador em memória do jogador.
  3. Grave o PurchaseId como tratado nos dados do jogador na memória do jogador.
  4. Escreva os dados do jogador na memória do jogador do jogador para o DataStore.

O bloqueio de sessão simplifica esse fluxo, pois você não precisa mais se preocupar com os seguintes cenários:

  • Os dados do jogador na memória no servidor atual potencialmente estando desatualizados, exigindo que você obtenha o último valor do DataStore antes de verificar o histórico PurchaseId
  • O retorno de chamada para a mesma compra executada em outro servidor, exigindo que você leia e escreva o histórico PurchaseId e salve os dados atualizados do jogador com a compra refletida atomicamente para evitar condições de corrida

O bloqueio de sessão garante que, se uma tentativa de escrever no player's DataStore for bem-sucedida, nenhum outro servidor leu ou escreveu com sucesso no player's DataStore entre os dados carregados e salvos neste servidor.Em suma, os dados do jogador na memória neste servidor são a versão mais atual disponível.Há algumas ressalvas, mas elas não afetam esse comportamento.

Aproximação

Os comentários em ReceiptProcessor esboçam a abordagem:

  1. Verifique se os dados do jogador estão atualmente carregados neste servidor e que foram carregados sem erros.

    Como este sistema usa bloqueio de sessão, esta verificação também verifica que os dados em memória são a versão mais atual.

    Se os dados do jogador ainda não foram carregados (o que é esperado quando um jogador se junta a um jogo), aguarde o carregamento dos dados do jogador.O sistema também ouve o jogador deixar o jogo antes que seus dados sejam carregados, pois não deve render indefinidamente e bloquear esse retorno de chamada novamente neste servidor para esta compra se o jogador se juntar novamente.

  2. Verifique se o PurchaseId não está já registrado como processado nos dados do jogador.

    Devido ao bloqueio de sessão, o array de PurchaseIds do sistema que está na memória é a versão mais atual.Se o PurchaseId for registrado como processado e refletido em um valor que foi carregado ou salvo no DataStore , retorne PurchaseGranted .Se for registrado como processado, mas não refletido no DataStore , retorne NotProcessedYet .

  3. Atualize os dados do jogador localmente neste servidor para " premiar " a comprar.

    ReceiptProcessor toma uma abordagem de chamada genérica e atribui um chamado diferente para cada DeveloperProductId.

  4. Atualize os dados do jogador localmente neste servidor para armazenar o PurchaseId .

  5. Envie um pedido para salvar os dados em memória no DataStore, retornando PurchaseGranted se o pedido for bem-sucedido. Caso não seja, retorne NotProcessedYet .

    Se esse pedido de salvamento não for bem-sucedido, um pedido posterior para salvar os dados de sessão em memória do jogador ainda pode ser bem-sucedido.Durante a próxima chamada ProcessReceipt , o passo 2 lida com essa situação e retorna PurchaseGranted .

Dados do jogador

Singletons: PlayerData.Server , PlayerData.Client

Plano de fundo

Módulos que fornecem uma interface para o código do jogo para ler e escrever dados de sessão do jogador de forma sincronizada são comuns nas experiências do Roblox.Esta seção abrange PlayerData.Server e PlayerData.Client.

Aproximação

PlayerData.Server e PlayerData.Client lidam com o seguindo:

  1. Carregar os dados do jogador na memória, incluindo o manuseio de casos em que ele não consegue carregar
  2. Fornecer uma interface para o código do servidor para consultar e alterar os dados do jogador
  3. Replicar alterações nos dados do jogador para o cliente para que o código do cliente possa acessá-los
  4. Replicando erros de carregamento e/ou salvamento ao cliente para que ele possa mostrar diálogos de erro
  5. Salvar os dados do jogador periodicamente, quando o jogador sai, e quando o servidor desliga

Carregar dados do jogador

An process diagram illustrating the loading system
  1. SessionLockedDataStoreWrapper faz um pedido getAsync ao lojade dados.

    Se essa solicitação falhar, os dados padrão são usados e o perfil é marcado como "errado" para garantir que não seja escrito no armazenamento de dados mais tarde.

    Uma opção alternativa é expulsar o jogador, mas recomendamos deixar o jogador jogar com dados padrão e limpar a mensageria sobre o que ocorreu, ao invés de removê-los da experiência.

  2. Um pagamento inicial é enviado para PlayerDataClient contendo os dados carregados e o status de erro (se algum).

  3. Quaisquer subprocessos gerados usando waitForDataLoadAsync para o jogador são retomados.

Forneça uma interface para código do servidor

  • PlayerDataServer é um singleton que pode ser solicitado e acessado por qualquer código de servidor executando no mesmo ambiente.
  • Os dados do jogador são organizados em um dicionário de chaves e valores.Você pode manipular esses valores no servidor usando os métodos setValue, getValue, updateValue e removeValue.Estes métodos todos operam de forma sincrona sem ceder.
  • Os métodos hasLoaded e waitForDataLoadAsync estão disponíveis para garantir que os dados tenham sido carregados antes de você acessá-los.Recomendamos fazer isso uma vez durante uma tela de carregamento antes que outros sistemas sejam iniciados para evitar ter que verificar erros de carregamento antes de cada interação com dados no cliente.
  • Um método hasErrored pode consultar se a carga inicial do jogador falhou, fazendo com que ele use dados padrão.Verifique este método antes de permitir que o jogador faça quaisquer compras, pois as compras não podem ser salvas em dados sem uma carregarbem-sucedida.
  • Um sinal A playerDataUpdated dispara com o player , key e value sempre que os dados de um jogador forem alterados.Sistemas individuais podem se inscrever nisso.

Replicar alterações para o cliente

  • Qualquer alteração nos dados do jogador em PlayerDataServer é replicada para PlayerDataClient , a menos que essa chave tenha sido marcada como privada usando setValueAsPrivate
    • setValueAsPrivate é usado para denotar chaves que não devem ser enviadas ao cliente
  • PlayerDataClient inclui um método para obter o valor de uma chave (obter) e um sinal que dispara quando é atualizado (atualizado).Um método hasLoaded e um sinal loaded também são incluídos, para que o cliente possa esperar que os dados sejam carregados e replicados antes de iniciar seus sistemas
  • PlayerDataClient é um singleton que pode ser solicitado e acessado por qualquer código de cliente executando no mesmo ambiente

Replicar erros para o cliente

  • Estados de erro encontrados ao salvar ou carregar dados do jogador são replicados para PlayerDataClient .
  • Acesse essas informações com os métodos getLoadError e getSaveError, juntamente com os sinais loaded e saved.
  • Existem dois tipos de erros: DataStoreError (a solicitação DataStoreService falhou) e SessionLocked (veja Bloqueio de Sessão ).
  • Use esses eventos para desativar prompts de compra do cliente e implementar diálogos de aviso. Esta imagem mostra um diálogo de exemplo:
A screenshot of an example warning that could be shown when player data fails to load

Salvar dados do jogador

A process diagram illustrating the saving system
  1. Quando o jogador deixa o jogo, o sistema toma as seguintes medidas:

    1. Verifique se é seguro escrever os dados do jogador no armazenamento de dados.Cenários em que não seria seguro incluem os dados do jogador não carregando ou ainda carregando.
    2. Faça um pedido através do SessionLockedDataStoreWrapper para escrever o valor atual de dados na memória no armazenamento de dados e remova o bloqueio de sessão uma vez concluído.
    3. Limpa os dados do jogador (e outras variáveis, como metadados e estados de erro) da memória do servidor.
  2. Em um ciclo periódico, o servidor escreve os dados de cada jogador no armazenamento de dados (desde que seja seguro salvar).Esta redundância de boas-vindas mitiga a perda em caso de colapso do servidor e também é necessária para manter o bloqueio de sessão.

  3. Quando um pedido para desligar o servidor é recebido, o seguinte ocorre em um BindToClose retorno de chamada:

    1. Um pedido é feito para salvar os dados de cada jogador no servidor, seguindo o processo normalmente passado quando um jogador deixa o servidor.Essas solicitações são feitas em paralelo, pois BindToClose os retornos de chamada só têm 30 segundos para serem concluídos.
    2. Para agilizar os salvos, todas as outras solicitações na fila de cada chave são excluídas do subjacente DataStoreWrapper (veja Retentidas).
    3. O retorno de chamada não ocorre até que todas as solicitações tenham sido concluídas.