Protection against data override by old Sync clients

Overview

Problem

This document outlines the necessary steps to prevent data loss scenarios with Sync's Model API for multi-client Sync users. A typical problematic scenario is as follows:

  1. New proto field F is introduced in data specifics (e.g. PasswordSpecifics).
  2. Client N (a newer client) submits a proto containing the introduced field F.
  3. Client O (an older client) receives the proto, but doesn’t know the field F, discarding it before storing in the local model.
  4. Client O submits a change to the same proto, which results in discarding field’s F data from client N.

Solution

To prevent the described data loss scenario, it is necessary for the old client (client O above) to keep a copy of a server-provided proto, including unknown fields (i.e. fields not even defined in the .proto file at the time the binary was built) and partially-supported fields (e.g. functionality guarded behind a feature toggle). The logic for caching these protos was implemented in DataTypeLocalChangeProcessor.

To have this protection for a specific datatype, its DataTypeSyncBridge needs to be updated to include the cached data during commits to the server (more details in the Implementation).

Checklist

To implement this solution, a Sync datatype owner should follow these steps:

  1. Override TrimAllSupportedFieldsFromRemoteSpecifics function (see this section).
  2. [Optional] Add DCHECK to local updates flow (see this section).
  3. Include unsupported fields in local changes (see this section).
  4. Surface newly-supported fields on browser upgrade (see this section).
  5. [Optional] Add a version field to proto in the same milestone.
  6. [Optional] Add sync integration test (see this section).

The result of these steps is that:

Implementation

Trimming

Storing a full copy of a proto may have performance impact (memory, disk). The Sync infrastructure allows and encourages to trim proto fields that do not need an additional copy (if the field is already well supported by the client).

Trimming is a functionality that allows each data type to specify which proto fields are supported in the current browser version. Any field that is not supported will be cached by the DataTypeLocalChangeProcessor and can be used during commits to the server to prevent the data loss.

Fields that should not be marked as supported (i.e. must not be cleared by trimming):

TrimAllSupportedFieldsFromRemoteSpecifics is a function of DataTypeSyncBridge that:

To add a data-specific unsupported fields caching, override the trimming function in the data-specific DataTypeSyncBridge to clear all its supported fields (i.e. fields that are actively used by the implementation and fully launched). Wrap both the .proto definition and the trimming implementation in LINT.IfChange / LINT.ThenChange comments to keep them in sync, and only populate the datatype field on the returned EntitySpecifics if trimmed_specifics.ByteSizeLong() > 0u (otherwise an empty submessage shell will be cached in metadata for every entity):

sync_pb::EntitySpecifics DataSpecificBridge::TrimAllSupportedFieldsFromRemoteSpecifics(
    const sync_pb::EntitySpecifics& entity_specifics) const {
  // LINT.IfChange(TrimAllSupportedFieldsFromRemoteSpecifics)
  sync_pb::MyTypeSpecifics trimmed_specifics = entity_specifics.my_type();
  trimmed_specifics.clear_username();
  trimmed_specifics.clear_password();
  if (trimmed_specifics.has_submessage()) {
    sync_pb::SpecificsSubmessage* submessage =
        trimmed_specifics.mutable_submessage();
    submessage->clear_field();
    if (submessage->ByteSizeLong() == 0u) {
      trimmed_specifics.clear_submessage();
    }
  }
  // LINT.ThenChange(//components/sync/protocol/my_type_specifics.proto:MyTypeSpecifics)

  sync_pb::EntitySpecifics trimmed_entity_specifics;
  if (trimmed_specifics.ByteSizeLong() > 0u) {
    *trimmed_entity_specifics.mutable_my_type() = std::move(trimmed_specifics);
  }
  return trimmed_entity_specifics;
}

Safety check

Forgetting to trim fields that are supported might result in:

To prevent this scenario, add unit tests (and/or a DCHECK before every commit to the Sync server) that:

DCHECK_EQ(TrimAllSupportedFieldsFromRemoteSpecifics(entity_specifics).ByteSizeLong(), 0u);

Local update flow

To use the cached unsupported fields data during commits to the server (both in change_processor()->Put() and in GetDataForCommit()), add the code that does the following steps:

  1. Query cached sync_pb::EntitySpecifics from the DataTypeLocalChangeProcessor via GetPossiblyTrimmedRemoteSpecifics(storage_key) (Passwords example).
  2. Use the cached proto as a base for a commit and fill it with the supported fields from the local proto representation (Passwords example).
    • Make sure that all known supported fields are explicitly set or cleared (e.g. if an optional field is unset in the local model, call clear_foo()) to avoid preserving stale values in corner cases.
    • For enum fields, if the local model has an UNSPECIFIED/UNKNOWN value because the entity originally had an unrecognized enum value from a newer client, avoid calling set_my_enum(FOO_UNSPECIFIED) if doing so would overwrite the unknown value preserved in base_specifics.
  3. Commit the merged proto to the server (Passwords example).

Browser upgrade flow

When a client receives an entity containing fields from a newer browser version (or guarded behind a disabled feature flag) and later upgrades (or enables the flag) so those fields become supported, the local model must surface those newly-supported fields without waiting for another remote update. There are three valid patterns depending on how the DataTypeSyncBridge persists local data:

Integration test

Add a Sync integration test for the caching / trimming flow.

Limitations

Sync support horizon

The proposed solution is intended to be a long-term one, but it will take some time until it can be used reliably. This is due to the facts that:

Deprecating a field

Deprecated fields (even ones that are no longer read or written by the bridge) must not be removed from the .proto file or from TrimAllSupportedFieldsFromRemoteSpecifics(), and must still be treated as supported. Otherwise, deprecated fields still present on the server would fail to be trimmed and would be cached in possibly_trimmed_base_specifics and carried forward indefinitely.

Migrating a field

This requires client-side handling as the newer clients will have both fields present and the legacy clients will have access to the deprecated field only. Newer clients should:

Enums

In proto2, when an older client receives an unrecognized enum value introduced in a newer version, proto2 places the raw value in unknown_fields() rather than the named field. As a result, clear_<enum_field>() in TrimAllSupportedFieldsFromRemoteSpecifics() does not strip the unrecognized enum value from unknown_fields(), and possibly_trimmed_base_specifics retains it. When committing a local update that starts from GetPossiblyTrimmedRemoteSpecifics, if the local model still holds the UNSPECIFIED fallback value (meaning the enum was not modified locally), the bridge should avoid calling set_<enum_field>(..._UNSPECIFIED) so the unrecognized enum value in unknown_fields() is preserved on commit.

oneof fields

Clearing all currently known variants of a oneof in TrimAllSupportedFieldsFromRemoteSpecifics() leaves any newly introduced variant untouched in unknown_fields(), allowing it to be preserved in possibly_trimmed_base_specifics. However, if an older client modifies the entity and sets one of its known oneof variants on top of the cached possibly_trimmed_base_specifics, the serialized proto would contain two active variants of the same oneof (one known, one in unknown_fields()), and newer clients parsing that commit will have one overwrite the other depending on wire order. Avoid allowing older clients to modify entities whose oneof variant was NOT_SET (unrecognized).

Repeated fields

No client-side logic is required - the solution will work by default.

Nested fields

Protecting nested fields is possible, but requires adding client-side logic to trim single child fields or the top level field if none of the child fields are populated (Passwords notes example).

Versioning

Having a version field may help to detect which client populated a new field and whether it supported it. However, if it's not added from the beginning, then those clients would populate the version as well.

It's important to add a version field and clear it when trimming fields but it doesn't have to be populated while it's not used.