Trabalhar com as Reliable Collections

O Service Fabric oferece um modelo de programação stateful disponível para desenvolvedores .NET por meio de Coleções Confiáveis. Especificamente, o Service Fabric fornece dicionário confiável e classes de fila confiáveis. Quando você usa essas classes, seu estado é particionado (para escalabilidade), replicado (para disponibilidade) e transacionado dentro de uma partição (para semântica ACID). Vamos olhar para um uso típico de um objeto de dicionário confiável e ver o que ele está realmente fazendo.

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áveis (exceto ClearAsync, que não é inviável), exigem um objeto ITransaction. Este objeto associou a ele todas e quaisquer alterações que você esteja tentando fazer em qualquer dicionário confiável e/ou objetos de fila confiáveis dentro de uma única 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 para o método AddAsync de um 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 modifica o valor da chave, o método usa um bloqueio de gravação na chave e se o método só lê o valor da chave, então um bloqueio de leitura é tomado na chave. Como o AddAsync modifica o valor da chave para o novo valor passado, o bloqueio de gravação da chave é usado. Assim, se 2 (ou mais) threads tentarem adicionar valores com a mesma chave ao mesmo tempo, um thread adquirirá o bloqueio de gravação e os outros threads serão bloqueados. Por padrão, os métodos bloqueiam por até 4 segundos para adquirir o bloqueio; após 4 segundos, os métodos lançam um TimeoutException. Existem sobrecargas de método, permitindo que você passe um valor explícito de tempo limite, se preferir.

Normalmente, você escreve seu código para reagir a um TimeoutException capturando-o e tentando novamente toda a operação (como mostrado no código acima). Neste código simples, estamos apenas chamando Task.Delay passando 100 milissegundos de cada vez. Mas, na realidade, talvez seja melhor usar algum tipo de atraso exponencial em vez disso.

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

Nota

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 de persistência e replicação das alterações. Recomendamos 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áveis.

Em seguida, AddAsync serializa seus objetos de chave e valor para matrizes de bytes e acrescenta essas matrizes de bytes a um arquivo de log no nó local. Finalmente, o 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 estão associadas tenha sido confirmada.

No código acima, a chamada para CommitAsync confirma todas as operações da transação. Especificamente, ele 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. Uma vez que um quórum (maioria) das réplicas tenha respondido, todas as alterações de dados são consideradas permanentes e quaisquer bloqueios associados a chaves que foram manipuladas por meio do objeto ITransaction são liberados para que outros threads/transações possam manipular as mesmas chaves e seus valores.

Se CommitAsync não for chamado (geralmente devido a uma exceção sendo lançada), 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 nada precisa ser enviado para nenhuma das réplicas secundárias. E então, todos os bloqueios associados a chaves que foram manipuladas através da transação são liberados.

Coleções confiáveis voláteis

Em algumas cargas de trabalho, como um cache replicado, por exemplo, a perda ocasional de dados 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 contrapartida para a falta de persistência é que, se ocorrer perda de quórum, ocorrerá perda total de dados. Como a perda de quórum é 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 está disponível apenas para Dicionários Confiáveis e Filas Confiáveis, e não para ReliableConcurrentQueues. Consulte a lista de Advertências para informar sua decisão sobre o uso ou não de coleções voláteis.

Para habilitar o suporte volátil em seu serviço, defina o HasPersistedState sinalizador na declaração de tipo de serviço como , assim false:

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

Nota

Os serviços existentes persistentes não podem tornar-se voláteis e vice-versa. Se desejar fazer isso, será necessário 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 total de dados se desejar alterar a HasPersistedState bandeira.

Armadilhas comuns e como evitá-las

Agora que você já entendeu como funcionam internamente as coleções confiáveis, vamos dar uma olhada em alguns usos indevidos comuns delas. Veja o código abaixo:

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 .NET regular, 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 chave/valor em 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 posteriormente uma propriedade, isso alterará o valor da propriedade somente na memória; Isso não afeta o arquivo local ou os dados enviados para as réplicas. Se o processo falhar, o que está na memória é jogado fora. Quando um novo processo é iniciado ou se outra réplica se torna primária, o valor da propriedade antiga é o que está disponível.

Nunca é demais sublinhar como é fácil cometer o tipo de erro acima referido. E você só vai aprender sobre o erro se/quando o processo cair. A maneira correta de escrever o código é simplesmente inverter 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();
}

Aqui está outro exemplo mostrando 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 .NET regulares, o código acima funciona bem e é um padrão comum: o desenvolvedor usa uma chave para procurar um valor. Se o valor existir, o promotor altera o valor de um imóvel. No entanto, com coleções confiáveis, esse código exibe o mesmo problema já discutido: você NÃO deve modificar um objeto depois de tê-lo dado 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 o objeto referido por essa referência imutável. Em seguida, crie um novo objeto que seja uma cópia exata do objeto original. Agora, você pode modificar o estado desse novo objeto e gravar o novo objeto 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 a(s) alteração(ões), os objetos na memória, o arquivo local e todas as réplicas têm o mesmo estado exato. Tudo é bom!

O código abaixo 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 erros do programador

Idealmente, gostaríamos que o compilador relatasse erros quando você acidentalmente produz código que muda o estado de um objeto que você deveria considerar imutável. Mas, o compilador C# não tem a capacidade de fazer isso. Portanto, para evitar possíveis bugs do programador, é altamente recomendável que você defina os tipos que você usa com coleções confiáveis para serem tipos imutáveis. Especificamente, isso significa que você se ater aos tipos de valor principais (como números [Int32, UInt64, etc.], DateTime, Guid, TimeSpan e similares). Você também pode usar String. É melhor evitar propriedades de coleção, pois serializá-las e desserializá-las frequentemente pode prejudicar o desempenho. No entanto, se você quiser usar propriedades de coleção, é altamente recomendável o uso de . Biblioteca de coleções imutáveis da NET (System.Collections.Immutable). Esta biblioteca está disponível para download em https://nuget.org. Também recomendamos selar suas aulas e tornar os campos somente leitura sempre que possível.

O tipo UserInfo abaixo demonstra como definir um tipo imutável aproveitando as recomendações acima mencionadas.

[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 de esquema (atualizações)

Internamente, as Coleções Confiáveis serializam seus objetos usando o . DataContractSerializer da NET. Os objetos serializados são persistentes para o disco local da réplica primária e também são transmitidos para as réplicas secundárias. À medida que seu serviço amadurece, é provável que você queira alterar o tipo de dados (esquema) que seu serviço exige. Aborde o controle de versão de seus dados com muito cuidado. Em primeiro lugar, você deve sempre ser capaz de desserializar 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 há 5 anos.

Além disso, o código de serviço é atualizado um domínio de atualização de cada 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 a nova versão do código de serviço use o novo esquema, pois as versões antigas do código de serviço podem não ser capazes de lidar com o novo esquema. Sempre que possível, você deve projetar cada versão do seu serviço para ser compatível com uma 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 ele não manipula explicitamente. No entanto, ele deve ser capaz de salvar quaisquer dados que não conheça explicitamente e escrevê-los novamente ao atualizar uma chave ou valor de 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 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 à corrupção de dados, perda e falhas de serviço. As Cadeias .NET podem ser utilizadas como uma chave, mas utilize a própria cadeia como chave (não utilize o resultado de String.GetHashCode como chave).

Como alternativa, você pode executar uma atualização multifaseada.

  1. Atualize o serviço para uma nova versão que
    • tem a versão V1 original e a nova versão V2 dos contratos de dados incluídos no pacote de código de serviço;
    • registra serializadores de estado V2 personalizados, se necessário;
    • executa 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 primeiras coleções V1 e depois 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 de serviço implantada na etapa 2.
    • Retreieve todas as chaves da coleção V1. A enumeração é executada 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 o valor para a 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 só deve ser confirmada se as TryAddAsync devoluções true.
      • As APIs de acesso de valor 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 primeiras coleções V1 e depois V2 para manter a opção de reverter para V1.
  5. Teste o serviço de forma abrangente e confirme se ele está funcionando conforme o esperado.
    • Se você perdeu qualquer operação de acesso de valor que não foi atualizada para funcionar na coleção V1 e V2, você pode notar dados ausentes.
    • Se algum dado estiver faltando, reverta para a Etapa 1, remova a coleção V2 e repita o processo.
  6. Atualize o serviço para uma nova versão que
    • executa todas as operações apenas na coleção V2;
    • voltar para V1 não é mais possível com uma reversão de serviço e exigiria rolar para frente com as etapas invertidas de 2 a 4.
  7. Serviço de atualização de uma nova versão que
  8. Aguarde o truncamento do log.
    • Por padrão, isso acontece a cada 50 MB de gravações (adiciona, atualiza e remove) em coleções confiáveis.
  9. Atualize o serviço para uma nova versão que
    • não tem mais os contratos de dados V1 incluídos no pacote de código de serviço.

Próximos passos

Para saber mais sobre como criar contratos de dados compatíveis com encaminhamento, consulte Contratos de dados compatíveis com encaminhamento

Para aprender as práticas recomendadas sobre controle de versão de contratos de dados, consulte Controle de versão de contrato de dados

Para saber como implementar contratos de dados tolerantes à versão, consulte Retornos de chamada de serialização tolerantes à versão

Para saber como fornecer uma estrutura de dados que pode interoperar entre várias versões, consulte IExtensibleDataObject

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