Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix broken object type serializer in QueryExecutor #6223

Conversation

Arkatufus
Copy link
Contributor

@Arkatufus Arkatufus commented Nov 1, 2022

Fix for akkadotnet/Akka.Hosting#127
Fix #6389

Changes

  • Removed persistence default deserializer feature

Issue

Any non-serializer mapped objects being serialized by Akka.Persistence.Sql.Common.Journal.QueryExecutor will use the JSON serializer and not the object type serializer.

Problem is with this line:

var serializer = Serialization.FindSerializerForType(e.Payload.GetType(), Configuration.DefaultSerializer);

which takes its value from the very obscure and very rarely used HOCON override here:

Workaround

Override the HOCON setting:

akka.persistence.journal-plugin-fallback.serializer = null
akka.persistence.snapshot-store-plugin-fallback.serializer = null

or declare the default non-mapped object serializer in the journal/snapshot settings:

akka.persistence.journal.sqlite.serializer = mySerializer
akka.persistence.snapshot-store.sqlite.serializer = mySerializer

await using var cmd = new SqliteCommand(sql, conn);
var record = await cmd.ExecuteReaderAsync();
await record.ReadAsync();
record[0].Should().Be(9999);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fails because the payload is being serialized using the JSON serializer (1) and not MySerializer (9999)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add a comment explaining what this test actually tests - like, what use case is it here to assert? Just need a snippet at the top explaining this.

@Aaronontheweb
Copy link
Member

Nice work @Arkatufus - yep, that will do it.

So what can be done to safely change this default, given that this behavior is somewhat destructive but has also been in-place for years?

@Arkatufus
Copy link
Contributor Author

To be honest, no idea... this is breaking change anyway we approach it.

@Arkatufus Arkatufus changed the title Reproduction unit test for broken object type serializer in QueryExecutor Fix broken object type serializer in QueryExecutor Nov 2, 2022
@Aaronontheweb
Copy link
Member

https://github.com/akkadotnet/akka.net/blame/dev/src/contrib/persistence/Akka.Persistence.Sql.Common/Journal/QueryExecutor.cs#L815 <-- so it looks like for at least the past 5 years, we've been recording both the manifest and the serializer id in their own columns in all SQL persistence implementations. I think this means we can safely work around this default to some extent, but it will require making some backwards compatibility tests for the journal itself.

Basically, we need to try loading the explicit serializer by its ID first during reads - and on writes we should just use whatever the default object serializer is if there's not a specific match defined. If we can still read previous reads compatibly then this change is much less risky to implement.

cc @ismaelhamed

@Aaronontheweb
Copy link
Member

So what would a backwards compat spec look like? Using SQLite as an example:

  1. Write events using the current config, commit SQLite file to repository (this is key - can't produce on the fly);
  2. Change the configuration so there's no more default fallback serializer for persistence implementations - update the read event to use the serializer ID explicitly;
  3. Verify that all of the expected events were read and recovered correctly.

@@ -780,7 +780,7 @@ protected DbCommand GetCommand(DbConnection connection, string sql)
protected virtual void WriteEvent(DbCommand command, IPersistentRepresentation e, IImmutableSet<string> tags)
{

var serializer = Serialization.FindSerializerForType(e.Payload.GetType(), Configuration.DefaultSerializer);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@@ -846,7 +846,7 @@ protected virtual IPersistentRepresentation ReadEvent(DbDataReader reader)
{
// Support old writes that did not set the serializer id
var type = Type.GetType(manifest, true);
var deserializer = Serialization.FindSerializerForType(type, Configuration.DefaultSerializer);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so we'd need to keep this configuration setting here for the rare cases where the data is really old and never had a serialzier ID in-place - we should revert this change specifically.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TL;DR; this is probably not the hotpath for reads on 99.999999% of events in Akka.Persistence.Sql

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This Do we have a backward compat spec around this specific condition? My concern is that if someone had a different default serializer defined, will we be using the correct one here?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly my question

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So we'll need to find which versions of Akka.Persistence didn't include a serializerId.

It looks like we first started adding serializerIds to Akka.Persistence.Sql.Common back in 1.3.1: https://github.com/akkadotnet/akka.net/releases/tag/v1.3.1

So, we should create a Sqlite database that runs on version 1.3.0 or earlier, writes some data, commit the binary to this repo, and then replay it using the latest development version of Akka.Persistence.Sql.Common - if we can still recover that data with this change, we're in good shape.

@ismaelhamed
Copy link
Member

ismaelhamed commented Nov 4, 2022

Don't forget this also applies to the BatchingJournal.

So, what happens now for users that have set a custom serializer for persistence that is different from the default serializer?

actor {
    serializers {
        hyperion = "Akka.Serialization.HyperionSerializer, Akka.Serialization.Hyperion"
    }
    serialization-bindings {
        "System.Object" = hyperion
    }
}

akka.persistence.snapshot-store.sqlite.serializer = msgpack

@Aaronontheweb
Copy link
Member

So, what happens now for users that have set a custom serializer for persistence that is different from the default serializer?

New messages will be serialized using your object serializer, old messages that were previously serialized (incorrectly) with JSON are still deserialized with the JSON serializer. It's just a write-side change if we do it the way it's laid out in the review comments.

@ismaelhamed
Copy link
Member

I may be missing something, but with my example above, does it mean that messages that were being persisted with msgpack will now be serialized with hyperion?

@Arkatufus
Copy link
Contributor Author

Arkatufus commented Nov 7, 2022

No, this is a very specific bug where if there's any types that does not have any serializer mapping, the persistence plugin will use the serializer with the name "json" or whatever is being set inside akka.persistence.journal.{persistence_plugin}.serializer, regardless of which serializer were set to handle the System.Object serialization.

I misread your comment, yes, that would mean that messages that used to be persisted using msgpack will now be persisted using hyperion

@Arkatufus
Copy link
Contributor Author

Arkatufus commented Nov 7, 2022

This is a "breaking" change for those people that sets their akka.persistence.journal.{persistence_plugin}.serializer setting in their HOCON if the serializer is not the same as the one being mapped to System.Object AND they have used persistence since the first version where serializer id were not written to the database. Data will still be written and read correctly, but the serializer used to write the data would be different, reading really really old legacy events with no serializer id set will still work because we will still use the legacy code if and only if there are no serializer id set in the database row.

@Arkatufus
Copy link
Contributor Author

Arkatufus commented Nov 7, 2022

This is how very old legacy serialized data would look like.
image

Notice that the serializer_id column is DbNull. In this very specific case, the QueryExecutor.ReadEvent will be forced to read the data using the serializer defined in akka.persistence.{persistence_plugin}.journal.serializer setting.

@Arkatufus
Copy link
Contributor Author

OK, looking at the history of why this setting is introduced in the first place, I'm in two minds about this.
This is the original reason: #2743 (comment)

Seems like removing this feature would introduce a bigger problem than it would solve.

@ismaelhamed
Copy link
Member

This is a "breaking" change for those people that sets their akka.persistence.journal.{persistence_plugin}.serializer setting in their HOCON if the serializer is not the same as the one being mapped to System.Object

This is clear for me now, thanks @Arkatufus.

Copy link
Member

@Aaronontheweb Aaronontheweb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall looks good, but we do need to harden the backwards compat test and add some additional comments for future committers to understand the significance of these tests - most of this backwards compatibility data is very "inside baseball."

@@ -234,6 +234,7 @@ public abstract class BatchingSqlJournalSetup
/// <summary>
/// The default serializer used when not type override matching is found
/// </summary>
[Obsolete(message: "This property should never be used for writes, use the default `System.Object` serializer instead")]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@@ -1325,7 +1326,7 @@ private async Task<WriteMessagesResult> HandleWriteMessages(WriteMessages req, T
protected virtual void WriteEvent(TCommand command, IPersistentRepresentation persistent, string tags = "")
{
var payloadType = persistent.Payload.GetType();
var serializer = _serialization.FindSerializerForType(payloadType, Setup.DefaultSerializer);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM - this is really the key fix here.

@@ -846,7 +846,7 @@ protected virtual IPersistentRepresentation ReadEvent(DbDataReader reader)
{
// Support old writes that did not set the serializer id
var type = Type.GetType(manifest, true);
var deserializer = Serialization.FindSerializerForType(type, Configuration.DefaultSerializer);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So we'll need to find which versions of Akka.Persistence didn't include a serializerId.

It looks like we first started adding serializerIds to Akka.Persistence.Sql.Common back in 1.3.1: https://github.com/akkadotnet/akka.net/releases/tag/v1.3.1

So, we should create a Sqlite database that runs on version 1.3.0 or earlier, writes some data, commit the binary to this repo, and then replay it using the latest development version of Akka.Persistence.Sql.Common - if we can still recover that data with this change, we're in good shape.

@@ -224,22 +224,5 @@ protected override async Task DeleteAsync(string persistenceId, SnapshotSelectio
await QueryExecutor.DeleteBatchAsync(connection, nestedCancellationTokenSource.Token, persistenceId, criteria.MaxSequenceNr, criteria.MaxTimeStamp);
}
}

private SnapshotEntry ToSnapshotEntry(SnapshotMetadata metadata, object snapshot)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why was this removed?

await using var cmd = new SqliteCommand(sql, conn);
var record = await cmd.ExecuteReaderAsync();
await record.ReadAsync();
record[0].Should().Be(9999);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add a comment explaining what this test actually tests - like, what use case is it here to assert? Just need a snippet at the top explaining this.

}

[Fact(DisplayName = "Persistence.Sql should be able to read legacy data")]
public void LegacyDataTest()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So to make this cover the issue referenced here: https://github.com/akkadotnet/akka.net/pull/6223/files#r1012961321 - we just need even older data, correct?

@Aaronontheweb
Copy link
Member

Also, you'll need to add an entry to the upgrade advisories page for this change too @Arkatufus

@Arkatufus
Copy link
Contributor Author

Closing this issue, moving the changes to dev branch instead of v1.4.
PR moved to #6528

@Arkatufus Arkatufus closed this Mar 16, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants