Concorrência em LINQ para SQL

Introdução

A correcta gestão de acessos concorrentes e transacções são tarefas muito importantes numa aplicação. A arquitectura da DLINQ (LINQ para SQL) já nos fornece uma implementação base preocupada com estes aspectos, mas também permite que o programador personalize essa implementação de modo a adaptá-la às necessidades da sua aplicação.

Não sendo o objectivo deste artigo explicar a base do funcionamento da DLINQ é necessário explicar alguns conceitos mais básicos do seu funcionamento de modo a enquadrar os leitores com menos contacto com esta linguagem/tecnologia. De modo a cumprir esse objectivo iremos falar, ligeiramente mais à frente, sobre a classe DataContext.

Nesta tecnologia, a técnica utilizada para detectar e resolver conflitos de concorrência tem o nome de concorrência optimista. O nome resulta da crença em que a probabilidade das alterações produzidas por uma transacção interferir com outra transacção é baixa. A DLINQ utiliza um esquema de dados desconectados, onde cada utilizador pode fazer as alterações que quiser na sua cópia dos dados (instância de DataContext). No momento em que o programa tentar propagar essas alterações para a base de dados é realizada a detecção de conflitos. A detecção de conflitos consiste basicamente em verificar se os dados presentes na base de dados foram modificados desde que a aplicação cliente fez o seu último acesso. Se for detectado algum conflito, o programa necessita de saber como resolvê-lo, nomeadamente se escreve por cima os novos dados, se descarta os novos dados ou se faz alguma operação entre eles. A detecção de conflitos é uma das funcionalidades da classe DataContext. Quando o programador tenta actualizar a base de dados chamando o método SubmitChanges da classe DataContext é automaticamente realizada uma detecção de conflitos.

Sempre que existe algum conflito é lançada a excepção ChangeConflictException, portanto todas as chamadas ao método SubmitChanges devem estar protegidas por um bloco try/catch.

Além da informação sobre a origem e a mensagem, a classe ChangeConflictException oferece ainda a possibilidade de resolver os conflitos de concorrência através de utilização dos enumerados RefreshMode e ConflictMode como veremos mais à frente.

De modo a tornar o código mais breve e legível muitos dos exemplos de DLINQ disponíveis na Internet ou em livros não definem um modo de detectar e resolver conflitos de concorrência, tenha em atenção que em código para produção deverá sempre defini-los.

A classe DataContext

Na base da DLINQ está a classe DataContext. É esta classe que fornece a maioria dos serviços/funcionalidades e sobre a qual são realizadas a maioria das operações. Como principais funcionalidades disponibilizadas por esta classe temos:

  • Gestão da ligação à base de dados.
  • Mapeamento entre os objectos e a base de dados.
  • Tradução e execução das queries.
  • Gestão da identidade dos objectos.
  • Gestão de alterações nos objectos.
  • Processador de alterações.
  • Gestão da integridade transaccional.

Para uma melhor compreensão deste artigo importa saber que as tabelas da base de dados são mapeadas em tipos (classes) do lado da linguagem de programação e que as colunas das tabelas são suportadas através de propriedades. Sempre que forem referidas propriedades será neste contexto, representação de colunas de uma tabela.

Acessos ReadOnly

Vamos começar por falar de uma situação que, apesar de não gerar conflitos de concorrência, pode ocorrer várias vezes na vida de uma aplicação. Muitas vezes pretende-se aceder a uma base de dados apenas para consultar os seus dados sem que se pretenda alterá-los num futuro próximo. Um exemplo disso podem ser os valores a apresentar numa ComboBox, como por exemplo uma lista de países do mundo. A classe DataContext controla as modificações feitas aos valores das propriedades que representam os campos das tabelas da base de dados mantendo em memória o valor original e o valor actual. A manutenção desses dois valores e o processo de detectar alterações tem um peso computacional. A classe DataContext controla ainda a identidade dos objectos, cada vez que é realizada uma consulta á base de dados, o serviço de gestão de identidade da classe DataContext verifica se um objecto com a mesma identidade já foi devolvido numa consulta anterior. Se assim for, a DataContext irá retornar o valor armazenado na cache interna em vez de o obter da base de dados. Esta gestão de identidade também tem um peso computacional. No caso de pretender apenas dados para leitura pode suprimir estes pesos computacionais e obter daí um aumento de performance na aplicação. Para obter este efeito deverá ser afectada a propriedade ObjectTrackingEnabled da DataContext com falso. Esta afectação indica à framework que não precisa de detectar alterações nos dados nem fazer gestão de identidade.

DatabaseDataContext dataContext = new DatabaseDataContext(); dataContext.ObjectTrackingEnabled = false;

O leitor repare que se fizer uma chamada ao método SubmitChanges será gerada uma excepção visto que não existem alterações a submeter. Será ainda lançada uma excepção caso a propriedade seja afectada com falso após a execução de uma query.

Ao afectar ObjectTrackingEnabled com falso o valor da propriedade DeferredLoadingEnabled será ignorado e inferido como falso.

Detecção de Conflitos

Existem dois métodos para realizar a detecção de conflitos de concorrência. Se existir alguma propriedade marcada com o atributo IsVersion a verdadeiro, a detecção de conflitos é realizada apenas com base na chave primária da tabela e no valor dessa coluna. Se não existir nenhuma propriedade com o atributo IsVersion como verdadeiro, a DLINQ permite ao programador definir quais as colunas que participam na detecção de conflitos através do atributo UpdateCheck das propriedades.

O atributo IsVersion especifica se uma propriedade que representa uma coluna da base de dados contém um número de versão ou um timestamp do registo. As definições de UpdateCheck da classe serão ignoradas na presença de uma propriedade marcada com IsVersion a verdadeiro.

De modo a percebermos melhor a forma como os conflitos são detectados vamos tentar perceber como está implementado. Quando é realizada a chamada ao método SubmitChanges é gerado código SQL para realizar a persistência dos dados na base de dados. Quando for necessário realizar uma actualização dos dados na base de dados não é enviado apenas a chave primária da tabela na cláusula where mas também todas as colunas que irão participar na detecção de conflitos. A detecção de conflitos é realizada enviando na cláusula where os valores originais das colunas, detectando-se assim eventuais mudanças. Nada melhor que um exemplo para ficarmos realmente a perceber o comportamento. Vamos imaginar que temos uma tabela Cliente com dois campos: Nome (chave primária) e Cidade.

Como operação iremos actualizar o valor de Cidade do cliente Vítor para Torres Vedras. Como podemos observar, a detecção de conflitos é realizada enviando na cláusula where os valores originais:

UPDATE [dbo].[Cliente]
SET [Cidade] = @p2
WHERE ([Nome] = @p0) AND ([Cidade] = @p1)
-- @p0: Input NChar (Size = 10; Prec = 0; Scale = 0) [Vítor]
-- @p1: Input NChar (Size = 10; Prec = 0; Scale = 0) [Lisboa]
-- @p2: Input NChar (Size = 10; Prec = 0; Scale = 0) [Torres Vedras]

Para exemplificar a detecção de conflitos usando uma coluna de versão/timestamp foi adicionado um campo com o nome Version à tabela e definido o atributo IsVersion como verdadeiro na propriedade da DataContext que o representa.

Como podemos observar no exemplo seguinte a detecção é agora realizada utilizando apenas na cláusula where a chave primária e a propriedade marcada como IsVersion.

UPDATE [dbo].[Cliente]
SET [Cidade] = @p2
WHERE ([Nome] = @p0) AND ([Version] = @p1)
 
SELECT [t1].[Version]
FROM [dbo].[Cliente] AS [t1]
WHERE ((@@ROWCOUNT) > 0) AND ([t1].[Nome] = @p3)
-- @p0: Input NChar (Size = 10; Prec = 0; Scale = 0) [Vítor]
-- @p1: Input Int (Size = 0; Prec = 0; Scale = 0) [1]
-- @p2: Input NChar (Size = 10; Prec = 0; Scale = 0) [Torres Vedras]
-- @p3: Input NChar (Size = 10; Prec = 0; Scale = 0) [Vítor]

O atributo UpdateCheck

Como já vimos, podemos realizar a detecção de conflitos através do atributo UpdateCheck. Exemplo:

[Column(DbType = "nvarchar(50)",UpdateCheck = UpdateCheck.WhenChanged)]
public string Nome;

Apesar de, na maioria das situações, a melhor opção ser informar o utilizador que existe um conflito de concorrência e disponibilizar mecanismos para a resolver, podem existir cenários em que a concorrência não é uma preocupação. Neste caso podemos simplesmente ignorar qualquer alteração concorrente e efectuar sempre a actualização dos registos, ficando gravado na base de dados a ultima actualização submetida. Esta opção pode implementada atribuindo UpdateCheck.Never em todas as propriedades. Existem casos em que estarem dois utilizadores a alterar colunas diferentes da mesma linha não levanta problemas. Para implementar esta situação deverá definir o atributo UpdateCheck como WhenChanged.

Por omissão as propriedades são consideradas com estando marcadas como UpdateCheck.Always, o que significa que irão sempre participar na detecção de conflitos, independentemente de terem sofrido alterações após a última leitura da base de dados. Ter todas as colunas a participar nessa detecção pode ser bastante penalizador para o desempenho da aplicação. O programador deverá rever este atributo em todas as propriedades de modo a apenas deixar marcadas as propriedades vitais para o correcto funcionamento da aplicação. Lembra-se de termos falado há pouco que as detecções eram feitas enviando os valores originais na cláusula where? Ora bem, se a propriedade tiver o atributo UpdateCheck definido como Always, o valor original da mesma irá sempre fazer parte da cláusula where. Se o valor de UpdateCheck estiver definido como WhenChanged essa propriedade irá fazer parte da cláusula where apenas se o valor corrente for diferente do original, ou seja, tiver sido modificado. No caso do atributo UpdateCheck estar definido como Never, essa coluna não estará presente na cláusula where, ou seja, não fará parte da detecção de conflitos .

O parâmetro ConflictMode

O método SubmitChanges aceita como parâmetro uma opção do enumerado ConflictMode, que tem dois valores possíveis: ContinueOnConflict e FailOnFirstConflict. A opção ContinueOnConflict, define que todas as actualizações serão tentadas, os conflitos que ocorrerem serão reunidos numa colecção e retornados no fim do processo. A opção FailOnFirstConflict, que é a opção por omissão, define que as actualizações deverão parar imediatamente após o primeiro conflito.

try
{
  dataContext.SubmitChanges(ConflictMode.ContinueOnConflict);
}
catch (ChangeConflictException){ (...) }

O leitor tenha em atenção que ao utilizar o modo FailOnFirstConflict seria de esperar que todas as alterações á base de dados realizadas antes do primeiro conflito acorrer tivessem sucesso. Este comportamento poderia deixar a base de dados inconsistente dado que apenas parte dos dados teriam sido actualizados. Falaremos em transacções mais à frente neste artigo mas é importante perceber nesta fase que, por omissão, o DataContext cria uma transacção sempre que o SubmitChanges é chamado. Se for lançada uma excepção ocorre um rollback automático da transacção.