Trabalhando com Reliable Collections

O Service Fabric oferece um modelo de programação com estado disponível para desenvolvedores .NET por meio das Reliable Collections. Especificamente, o Service Fabric fornece as classes de dicionário confiável e fila confiável. Quando você usar essas classes, seu estado é particionado (para escalabilidade), replicado (para disponibilidade) e transacionado dentro de uma partição (para semântica ACID). Vejamos um uso típico de um objeto de dicionário confiável para ver o que ele está fazendo realmente.

try
{
   // Create a new Transaction object for this partition
   using (ITransaction tx = base.StateManager.CreateTransaction())
   {
      // AddAsync takes key's write lock; if >4 secs, TimeoutException
      // Key & value put in temp dictionary (read your own writes),
      // serialized, redo/undo record is logged & sent to secondary replicas
      await m_dic.AddAsync(tx, key, value, cancellationToken);

      // CommitAsync sends Commit record to log & secondary replicas
      // After quorum responds, all locks released
      await tx.CommitAsync();
   }
   // If CommitAsync isn't called, Dispose sends Abort
   // record to log & all locks released
}
catch (TimeoutException)
{
   // choose how to handle the situation where you couldn't get a lock on the file because it was 
   // already in use. You might delay and retry the operation
   await Task.Delay(100);
}

Todas as operações em objetos de dicionário confiável (exceto ClearAsync, que não pode ser desfeito) exigem um objeto ITransaction. Esse objeto associa a ele toda e qualquer alteração que você tenta realizar em qualquer dicionário confiável e/ou objetos de fila confiável em uma mesma partição. Você adquire um objeto ITransaction chamando o método CreateTransaction do StateManager da partição.

No código acima, o objeto ITransaction é passado a um método AddAsync do dicionário confiável. Internamente, os métodos de dicionário que aceitam uma chave usam um bloqueio de leitor/gravador associado à chave. Se o método modificar o valor da chave, ele usará um bloqueio de gravação na chave e o método apenas ler o valor da chave, um bloqueio de leitura será obtido na chave. Como o AddAsync modifica o valor da chave para o novo valor repassado, o bloqueio de gravação da chave é obtido. Dessa maneira, se dois (ou mais) threads tentarem adicionar valores com a mesma chave ao mesmo tempo, um deles adquirirá o bloqueio de gravação e os outros ficarão bloqueados. Por padrão, os métodos ficam bloqueados por até quatro segundos para adquirir o bloqueio; após quatro segundos, os métodos gerarão uma TimeoutException. Há sobrecargas de método que permitem passar um valor de tempo limite explícito caso você prefira.

Normalmente você pode escrever seu código para reagir a uma TimeoutException capturando-a e repetindo toda a operação (conforme mostrado no código acima). Nesse código simples, estamos apenas chamando Task.Delay passando 100 milissegundos de cada vez. Porém, na realidade, é melhor usar algum tipo de atraso de recuo exponencial em vez disso.

Depois que o bloqueio é adquirido, AddAsync adiciona as referências de objeto de chave e de valor a um dicionário temporário interno associado ao objeto ITransaction. Isso é feito para fornecer a semântica de ler-suas-próprias-gravações. Ou seja, depois de chamar AddAsync, uma chamada posterior para TryGetValueAsync, usando o mesmo objeto ITransaction, retornará o valor mesmo se você ainda não tiver confirmado a transação.

Observação

Chamar TryGetValueAsync com uma nova transação retornará uma referência ao último valor confirmado. Não modifique essa referência diretamente, pois isso ignora o mecanismo para persistir e replicar as alterações. É recomendável tornar os valores somente leitura para que a única maneira de alterar o valor de uma chave seja por meio de APIs de dicionário confiável.

Em seguida, AddAsync serializa os objetos de chave e de valor para matrizes de bytes e acrescenta essas matrizes de byte em um arquivo de log no nó local. Por fim, AddAsync envia as matrizes de bytes para todas as réplicas secundárias para que elas tenham as mesmas informações de chave/valor. Embora as informações de chave/valor tenham sido gravadas em um arquivo de log, as informações não são consideradas parte do dicionário até que a transação à qual elas estão associadas seja confirmada.

No código acima, a chamada para CommitAsync confirma todas as operações da transação. Especificamente, ela acrescenta informações de confirmação ao arquivo de log no nó local e também envia o registro de confirmação para todas as réplicas secundárias. Depois de o quórum (maioria) das réplicas responder, todas as alterações de dados são consideradas permanentes e os bloqueios associados às chaves que foram manipuladas por meio do objeto ITransaction são liberados para que outras threads/transações possam manipular as mesmas chaves e seus valores.

Se CommitAsync não for chamado (geralmente devido a uma exceção é gerada), o objeto ITransaction será descartado. Ao descartar um objeto ITransaction não confirmado, o Service Fabric acrescenta informações de anulação ao arquivo de log do nó local e mais nada precisa ser enviado a nenhuma das réplicas secundárias. Em seguida, todos os bloqueios associados às chaves que foram manipuladas por meio da transação são liberados.

Coletas Confiáveis voláteis

Em algumas cargas de trabalho, como um cache replicado por exemplo, a perda de dados ocasional pode ser tolerada. Evitar a persistência dos dados no disco pode permitir melhores latências e taxas de transferência ao gravar em Dicionários Confiáveis. A compensação por falta de persistência é que, se ocorrer perda de quorum, ocorrerá perda de dados completa. Como a perda de quorum é uma ocorrência rara, o aumento do desempenho pode valer a pena a rara possibilidade de perda de dados para essas cargas de trabalho.

Atualmente, o suporte volátil somente está disponível para Dicionários Confiáveis e Filas Confiáveis, e não ReliableConcurrentQueues. Confira a lista de Advertências para informar sua decisão se deve usar coleções voláteis.

Para habilitar o suporte volátil no serviço, defina o sinalizador HasPersistedState na declaração de tipo de serviço como false, desta forma:

<StatefulServiceType ServiceTypeName="MyServiceType" HasPersistedState="false" />

Observação

Os serviços persistentes existentes não podem ser tornados voláteis e vice-versa. Se você quiser fazer isso, precisará excluir o serviço existente e, em seguida, implantar o serviço com o sinalizador atualizado. Isso significa que você deve estar disposto a incorrer em perda de dados completa se quiser alterar o sinalizador HasPersistedState.

Armadilhas comuns e como evitá-las

Agora que você entende como as coletas confiáveis funcionam internamente, vejamos alguns usos indevidos comuns delas. Veja o código a seguir:

using (ITransaction tx = StateManager.CreateTransaction())
{
   // AddAsync serializes the name/user, logs the bytes,
   // & sends the bytes to the secondary replicas.
   await m_dic.AddAsync(tx, name, user);

   // The line below updates the property's value in memory only; the
   // new value is NOT serialized, logged, & sent to secondary replicas.
   user.LastLogin = DateTime.UtcNow;  // Corruption!

   await tx.CommitAsync();
}

Ao trabalhar com um dicionário regular do .NET, você pode adicionar uma chave/valor ao dicionário e, em seguida, alterar o valor de uma propriedade (como LastLogin). No entanto, esse código não funcionará corretamente com um dicionário confiável. Lembre-se da discussão anterior: a chamada para AddAsync serializa os objetos de chave/valor para matrizes de bytes e, em seguida, salva as matrizes em um arquivo local e também as envia para as réplicas secundárias. Se você alterar uma propriedade posteriormente, isso apenas modifica o valor da propriedade na memória e não afeta o arquivo local ou os dados enviados para as réplicas. Se o processo falhar, o que estiver na memória será descartado. Quando um novo processo é iniciado ou se outra réplica se tornar primária, o valor antigo da propriedade será o que está disponível.

Nunca é demais enfatizar como é fácil cometer o tipo de erro mostrado acima. E você só descobrirá que o erro ocorreu se/quando o processo falhar. A maneira correta de escrever o código é simplesmente reverter as duas linhas:

using (ITransaction tx = StateManager.CreateTransaction())
{
   user.LastLogin = DateTime.UtcNow;  // Do this BEFORE calling AddAsync
   await m_dic.AddAsync(tx, name, user);
   await tx.CommitAsync();
}

Veja este outro exemplo que mostra um erro comum:

using (ITransaction tx = StateManager.CreateTransaction())
{
   // Use the user's name to look up their data
   ConditionalValue<User> user = await m_dic.TryGetValueAsync(tx, name);

   // The user exists in the dictionary, update one of their properties.
   if (user.HasValue)
   {
      // The line below updates the property's value in memory only; the
      // new value is NOT serialized, logged, & sent to secondary replicas.
      user.Value.LastLogin = DateTime.UtcNow; // Corruption!
      await tx.CommitAsync();
   }
}

Novamente, com dicionários regulares do .NET, o código acima funciona bem e é um padrão comum: o desenvolvedor usa uma chave para pesquisar um valor. Se o valor existir, o desenvolvedor altera o valor da propriedade. No entanto, com coleções confiáveis, esse código exibe o mesmo problema já abordado: você NÃO DEVE modificar um objeto depois de atribui-lo a uma coleção confiável.

A maneira correta de atualizar um valor em uma coleção confiável é obter uma referência ao valor existente e considerar imutável o objeto referenciado por esta referência. Em seguida, crie um novo objeto que é uma cópia exata do objeto original. Agora, você pode modificar o estado deste novo objeto e gravá-lo na coleção para que ele seja serializado em matrizes de bytes, anexado ao arquivo local e enviado para as réplicas. Depois de confirmar as alterações, os objetos na memória, o arquivo local e todas as réplicas terão o mesmo estado exato. Parece que está tudo bem.

O código a seguir mostra a maneira correta de atualizar um valor em uma coleção confiável:

using (ITransaction tx = StateManager.CreateTransaction())
{
   // Use the user's name to look up their data
   ConditionalValue<User> currentUser = await m_dic.TryGetValueAsync(tx, name);

   // The user exists in the dictionary, update one of their properties.
   if (currentUser.HasValue)
   {
      // Create new user object with the same state as the current user object.
      // NOTE: This must be a deep copy; not a shallow copy. Specifically, only
      // immutable state can be shared by currentUser & updatedUser object graphs.
      User updatedUser = new User(currentUser);

      // In the new object, modify any properties you desire
      updatedUser.LastLogin = DateTime.UtcNow;

      // Update the key's value to the updateUser info
      await m_dic.SetValue(tx, name, updatedUser);
      await tx.CommitAsync();
   }
}

Definir tipos de dados imutáveis para evitar erro do programador

O ideal seria que o compilador relatasse erros quando você acidentalmente produz código que transforma o estado de um objeto que deve ser considerado imutável. Porém, o compilador C# não consegue fazer isso. Portanto, para evitar possíveis bugs do programador, é altamente recomendável que você defina os tipos usados com coleções confiáveis como tipos imutáveis. Especificamente, isso significa usar apenas tipos de valor principais (como números [Int32, UInt64, etc.], DateTime, Guid, TimeSpan e assim por diante). Você também pode usar String. É melhor evitar propriedades de coleção, pois serializá-las e desserializá-las com frequência pode prejudicar o desempenho. No entanto, se você quiser usar propriedades de coleção, é altamente recomendável usar a biblioteca de coleções imutáveis do .NET (System.Collections.Immutable). Essa biblioteca está disponível para download em https://nuget.org. Também recomendamos validar suas classes e tornar os campos somente leitura sempre que possível.

O tipo de UserInfo abaixo demonstra como definir um tipo imutável tirando proveito das recomendações mencionadas anteriormente.

[DataContract]
// If you don't seal, you must ensure that any derived classes are also immutable
public sealed class UserInfo
{
   private static readonly IEnumerable<ItemId> NoBids = ImmutableList<ItemId>.Empty;

   public UserInfo(String email, IEnumerable<ItemId> itemsBidding = null) 
   {
      Email = email;
      ItemsBidding = (itemsBidding == null) ? NoBids : itemsBidding.ToImmutableList();
   }

   [OnDeserialized]
   private void OnDeserialized(StreamingContext context)
   {
      // Convert the deserialized collection to an immutable collection
      ItemsBidding = ItemsBidding.ToImmutableList();
   }

   [DataMember]
   public readonly String Email;

   // Ideally, this would be a readonly field but it can't be because OnDeserialized
   // has to set it. So instead, the getter is public and the setter is private.
   [DataMember]
   public IEnumerable<ItemId> ItemsBidding { get; private set; }

   // Since each UserInfo object is immutable, we add a new ItemId to the ItemsBidding
   // collection by creating a new immutable UserInfo object with the added ItemId.
   public UserInfo AddItemBidding(ItemId itemId)
   {
      return new UserInfo(Email, ((ImmutableList<ItemId>)ItemsBidding).Add(itemId));
   }
}

O tipo ItemId também é um tipo imutável, conforme mostrado aqui:

[DataContract]
public struct ItemId
{
   [DataMember] public readonly String Seller;
   [DataMember] public readonly String ItemName;
   public ItemId(String seller, String itemName)
   {
      Seller = seller;
      ItemName = itemName;
   }
}

Controle de versão do esquema (atualizações)

Internamente, as Coletas Confiáveis serializam os objetos usando DataContractSerializer do .NET. Os objetos serializados são persistidos no disco local da réplica primária e também são transmitidos para as réplicas secundárias. À medida que o serviço amadurece, é provável que você queira alterar o tipo de dados (esquema) que o serviço requer. Você deve abordar o controle de versão dos seus dados com muito cuidado. Antes de tudo, você deve sempre ser capaz de desserializar os dados antigos. Especificamente, isso significa que seu código de desserialização deve ser infinitamente compatível com versões anteriores: a versão 333 do seu código de serviço deve ser capaz de operar em dados colocados em uma coleção confiável pela versão 1 do seu código de serviço cinco anos atrás.

Além disso, o código de serviço é atualizado em um domínio de atualização por vez. Assim, durante uma atualização, você tem duas versões diferentes do seu código de serviço em execução simultaneamente. Você deve evitar que uma nova versão do seu código de serviço use o novo esquema, pois as versões antigas do seu código de serviço podem não ser capazes de lidar com o novo esquema. Quando possível, projete cada versão do seu serviço para ser compatível com a próxima versão. Especificamente, isso significa que a V1 do seu código de serviço deve ser capaz de ignorar quaisquer elementos de esquema que não consegue manipular explicitamente. No entanto, deve ser capaz de salvar todos os dados desconhecidos explicitamente e então gravá-los de volta ao atualizar uma chave ou valor do dicionário.

Aviso

Embora seja possível modificar o esquema de uma chave, você deve garantir que os algoritmos de igualdade e comparação da sua chave sejam estáveis. O comportamento de coleções confiáveis após uma alteração em qualquer um desses algoritmos é indefinido e pode levar a dados corrompidos, perda e falhas no serviço. As cadeias de caracteres do .NET podem ser usadas como uma chave, mas usam a própria cadeia de caracteres como a chave. Não use o resultado de String.GetHashCode como a chave.

Como alternativa, você pode executar um upgrade de várias fases.

  1. Atualize o serviço para uma nova versão que
    • tenha tanto a versão V1 original quanto a nova versão V2 dos contratos de dados incluídos no pacote de código de serviço;
    • registre serializadores de estado V2 personalizados, se necessário;
    • execute todas as operações na coleção V1 original usando os contratos de dados V1.
  2. Atualize o serviço para uma nova versão que
    • cria uma nova coleção V2;
    • executa cada operação de adição, atualização e exclusão nas coleções V1 e V2 em uma única transação;
    • executa operações de leitura somente na coleção V1.
  3. Copie todos os dados da coleção V1 para a coleção V2.
    • Isso pode ser feito em um processo em segundo plano pela versão do serviço implantada na etapa 2.
    • Recupere todas as chaves da coleção V1. A enumeração é realizada com o IsolationLevel.Snapshot por padrão para evitar o bloqueio da coleção durante a operação.
    • Para cada chave, use uma transação separada para
      • TryGetValueAsync da coleção V1.
      • Se o valor já tiver sido removido da coleção V1 desde o início do processo de cópia, a chave deverá ser ignorada e não ressuscitada na coleção V2.
      • TryAddAsync para adicionar valores à coleção V2.
      • Se o valor já tiver sido adicionado à coleção V2 desde o início do processo de cópia, a chave deverá ser ignorada.
      • A transação deve ser confirmada se o TryAddAsync retornar true.
      • As APIs de acesso a valores usam o IsolationLevel.ReadRepeatable por padrão e dependem do bloqueio para garantir que os valores não sejam modificados por outro chamador até que a transação seja confirmada ou abortada.
  4. Atualize o serviço para uma nova versão que
    • executa operações de leitura somente na coleção V2;
    • ainda executa cada operação de adição, atualização e exclusão nas coleções V1 e V2 para manter a opção de reverter para a V1.
  5. Teste o serviço de forma abrangente e confirme se ele está funcionando conforme o esperado.
    • Se você perdeu alguma operação de acesso a valores que não tenha sido atualizada para funcionar nas coleções V1 e V2, poderá notar a falta de dados.
    • Se algum dado estiver faltando, volte à Etapa 1, remova a coleção V2 e repita o processo.
  6. Atualize o serviço para uma nova versão que
    • execute todas as operações somente na coleção V2;
    • voltar para a V1 não é mais possível com uma reversão de serviço e exigiria a reversão com as etapas 2 a 4 invertidas.
  7. Atualize o serviço de uma nova versão que
  8. Aguarde o truncamento do log.
    • Por padrão, isso ocorre a cada 50 MB de gravações (adições, atualizações e remoções) em coleções confiáveis.
  9. Atualize o serviço para uma nova versão que
    • não tenha mais os contratos de dados V1 incluídos no pacote de código de serviço.

Próximas etapas

Para saber como criar contratos de dados compatíveis com versões futuras, confira Contratos de dados compatíveis com versões futuras

Para conhecer as melhores práticas de contratos de dados de controle de versão, confira Controle de versão de contrato de dados

Para saber como implementar contratos de dados tolerantes a versões, confira Retornos de Chamada de Serialização Tolerantes a Versões

Para saber como fornecer uma estrutura de dados interoperável em várias versões, confira IExtensibleDataObject

Para saber como configurar coleções confiáveis, consulte Configuração do Replicador