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

feat: Add signal commands #20876

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion signals/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
Expand Down
112 changes: 112 additions & 0 deletions signals/src/main/java/com/vaadin/signals/Id.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
* Copyright 2000-2025 Vaadin Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package com.vaadin.signals;

import java.math.BigInteger;
import java.util.Base64;
import java.util.Base64.Encoder;
import java.util.concurrent.ThreadLocalRandom;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;

/**
* Generated identifier for signals and other related resources.
* <p>
* The id is a random 64-bit number to be more compact than a full 128-bit UUID
* or such. The ids don't need to be globally unique but only unique within a
* smaller context so the risk of collisions is still negligible. The value is
* JSON serialized as a base64-encoded string with a special case,
* <code>""</code>, for the frequently used special 0 id. The ids are comparable
* to facilitate consistent ordering to avoid deadlocks in certain situations.
*
* @param value
* the id value as a 64-bit integer
*/
public record Id(long value) implements Comparable<Id> {
mshabarov marked this conversation as resolved.
Show resolved Hide resolved
/**
* Default or initial id in various contexts. Always used for the root node
* in a signal hierarchy. The zero id is frequently used and has a custom
* compact JSON representation.
*/
public static final Id ZERO = new Id(0);

/**
* Special id value reserved for internal bookkeeping.
*/
public static final Id MAX = new Id(Long.MAX_VALUE);

/*
* Padding refers to the trailing = characters that are only necessary when
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
* Padding refers to the trailing = characters that are only necessary when
* Padding refers to the trailing '=' characters that are only necessary when

* base64 values are concatenated together
*/
private static final Encoder base64Encoder = Base64.getEncoder()
.withoutPadding();

/**
* Creates a random id. Randomness is only needed to reduce the risk of
* collisions but there's no security impact from being able to guess random
* ids.
*
* @return a random id, not <code>null</code>
*/
public static Id random() {
var random = ThreadLocalRandom.current();

long value;
do {
value = random.nextLong();
} while (value == 0 || value == Long.MAX_VALUE);

return new Id(value);
}

/**
* Parses the given base64 string as an id. As a special case, the empty
* string is parsed as {@link #ZERO}.
*
* @param base64
* the base64 string to parse, not <code>null</code>
* @return the parsed id.
*/
@JsonCreator
public static Id parse(String base64) {
if (base64.equals("")) {
return ZERO;
}
byte[] bytes = Base64.getDecoder().decode(base64);
return new Id(new BigInteger(bytes).longValue());
mshabarov marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* Returns this id value as a base64 string.
*
* @return the base64 string representing this id
*/
@JsonValue
public final String asBase64() {
if (value == 0) {
return "";
}
byte[] bytes = BigInteger.valueOf(value).toByteArray();
return base64Encoder.encodeToString(bytes);
}

@Override
public int compareTo(Id other) {
return Long.compare(value, other.value);
mshabarov marked this conversation as resolved.
Show resolved Hide resolved
}
}
64 changes: 64 additions & 0 deletions signals/src/main/java/com/vaadin/signals/ListSignal.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Copyright 2000-2025 Vaadin Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package com.vaadin.signals;

/**
* The rest of this class will be implemented later.
*/
public class ListSignal {

/**
* A list insertion position before and/or after the referenced entries. If
* both entries are defined, then this position represents an exact match
* that is valid only if the two entries are adjacent. If only one is
* defined, then the position is relative to only that position. A position
* with neither reference is not valid for inserts but it is valid to test a
* parent-child relationship regardless of the child position.
* {@link Id#ZERO} represents the edge of the list, i.e. the first or the
* last position.
*
* @param after
* id of the node to insert immediately after, or
* <code>null</code> to not define a constraint
* @param before
* id of the node to insert immediately before, or
* <code>null</code> to not define a constraint
*/
public record ListPosition(Id after, Id before) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Can be marked with @Nullable from JSpecify (it should be in Flow codebase already, it was added with the CRUD repos feature)

mshabarov marked this conversation as resolved.
Show resolved Hide resolved
/**
* Gets the insertion position that corresponds to the beginning of the
* list.
*
* @return a list position for the beginning of the list, not
* <code>null</code>
*/
public static ListPosition first() {
// After edge
return new ListPosition(Id.ZERO, null);
}

/**
* Gets the insertion position that corresponds to the end of the list.
*
* @return a list position for the end of the list, not
* <code>null</code>
*/
public static ListPosition last() {
// Before edge
return new ListPosition(null, Id.ZERO);
}
Comment on lines +41 to +62
Copy link
Contributor

@taefi taefi Jan 20, 2025

Choose a reason for hiding this comment

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

Not sure if I easily understand this part:
The description of the after param says: id of the node to insert immediately after...
and description of the before param says: id of the node to insert immediately before...

What is the role of the ZERO? Is the ZERO an always existing root pointer that will store the head position (or the head == tail == ZERO in case of an empty list)?

If so, calling first() returns the before first (after the ZERO) position which makes sense. But, the last() returns before the ZERO, which I don't simply get :) Was it supposed to be Id.MAX?

Probably, something in the implementation is being optimized by this(?), but this representation seems a bit confusing.

Copy link
Member Author

Choose a reason for hiding this comment

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

This is a bit of (premature ?) optimization: ZERO is widely used also in other contexts and has therefore a custom JSON representation to be as compact as practically possible ("").

ZERO represents both edges of the list, i.e. both head and tail. This should be safe since they can never be mixed up with each other. Furthermore, ZERO also represents the root node but that node can never have siblings.

I agree that it can be confusing. I'm just not sure if that should be addressed by additional documentation or by removing the optimization? Or maybe just introduce an EDGE constant that refers to the same Id instance? It would probably be dangerous to introduce HEAD and TAIL constants with identical values since someone might assume them to be different?

Copy link
Contributor

Choose a reason for hiding this comment

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

Well, at this point, not seeing the rest of the implementation / usages makes it hard to make a practical decision about which approach to pick. Maybe I was mentioned and I forgot: is the List implementation going to be a circular linked list? In the case, it makes sense to have only one EDGE and the before / after seems to be enough.

Copy link
Member Author

Choose a reason for hiding this comment

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

What would first() and last() mean in a circular linked list?

Copy link
Contributor

@taefi taefi Jan 28, 2025

Choose a reason for hiding this comment

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

I think, when I was writing the previous comment, I got confused for a sec with having one EDGE constant for both head and tail (for the before first and after last positions), vs. head and tail pointing to the same location all the time (which wasn't the case). I would say, having a separate constant such as EDGE is enough for not getting confused with the ZERO thingy. Having separate HEAD and TAIL constants with different values might not add much value.

}
}
116 changes: 116 additions & 0 deletions signals/src/main/java/com/vaadin/signals/Node.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/*
* Copyright 2000-2025 Vaadin Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package com.vaadin.signals;

import java.util.List;
import java.util.Map;
import java.util.Objects;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.NullNode;

/**
* A node in a signal tree. Each node represents as signal entry. Nodes are
* immutable and referenced by an {@link Id} rather than directly referencing
* the node instance. The node is either a {@link Data} node carrying actual
* signal data or an {@link Alias} node that allows multiple signal ids to
* reference the same data.
*/
public sealed interface Node {

/**
* An empty data node without parent, scope owner, value or children and the
* initial last update id.
*/
public static final Data EMPTY = new Data(null, Id.ZERO, null, null,
List.of(), Map.of());

/**
* A node alias. An alias node allows multiple signal ids to reference the
* same data.
*
* @param target
* the id of the alias target, not <code>null</code>
*/
public record Alias(Id target) implements Node {
}

/**
* A data node. The node represents the actual data behind a signal
* instance.
*
* @param parent
* the parent id, or <code>null</code> for the root node
* @param lastUpdate
* a unique id for the update that last updated this data node,
* not <code>null</code>
* @param scopeOwner
* the id of the external owner of this node, or
* <code>null</code> if the node has no owner. Any node with an
* owner is deleted if the owner is disconnected.
* @param value
* the JSON value of this node, or <code>null</code> if there is
* no value
* @param listChildren
* a list of child ids, or an empty list if the node has no list
* children
* @param mapChildren
* a sequenced map from key to child id, or an empty map if the
* node has no map children
*/
public record Data(Id parent, Id lastUpdate, Id scopeOwner, JsonNode value,
List<Id> listChildren,
Map<String, Id> mapChildren) implements Node {
Comment on lines +74 to +76
Copy link
Contributor

Choose a reason for hiding this comment

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

Looking at the implementation of TreeRevision # assertValidTree method, and the way it had to concat the listChildren and mapChildren, seems like a flag to me. Enabling the caller code to provide both, can easily result in creating an invalid Data node. One should be calculated based on the other, for instance, If the mapChildren changes to an orderedMap such as LinkedHashMap, then the listChildren could be just a public method that calculates based on mapChildren.values(). Or maybe I misunderstood the purpose of having both the mapChildren and listChildren side by side.

Copy link
Member Author

Choose a reason for hiding this comment

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

Some child nodes are accessed by key and some by order. There's no reason why a single node couldn't have children of both types even though that's not the typical case.

Copy link
Contributor

Choose a reason for hiding this comment

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

What I'm not understanding is: should or shouldn't the listChildren and mapChildren.values() contain the same set of Ids?

Copy link
Member Author

Choose a reason for hiding this comment

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

There should be no overlap. Every child should be in exactly one location - either addressable by ListPosition or by a String key but never both at the same time.

All operations that move a child remove the child from its current location before attaching it back again, even if moving within the same parent. Any accidental overlap should trigger an error in TreeRevision.assertValidTree() from the !visited.add(id) check (since the concatenation doesn't do distinct()). Should maybe add a unit test for that case as well.

Copy link
Contributor

@taefi taefi Jan 28, 2025

Choose a reason for hiding this comment

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

There should be no overlap. Every child should be in exactly one location - either addressable by ListPosition or by a String key but never both at the same time.

To me, the it definitely worth adding this to the javadocs.

/**
* Creates a new data node.
*
* @param parent
* the parent id, or <code>null</code> for the root node
* @param lastUpdate
* a unique id for the update that last updated this data
* node, not <code>null</code>
* @param scopeOwner
* the id of the external owner of this node, or
* <code>null</code> if the node has no owner. Any node with
* an owner is deleted if the owner is disconnected.
* @param value
* the JSON value of this node, or <code>null</code> if there
* is no value
* @param listChildren
* a list of child ids, or the an list if the node has no
* list children
Comment on lines +92 to +94
Copy link
Contributor

Choose a reason for hiding this comment

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

Same here. Though, I prefer to get rid of this almost duplicate block.

* @param mapChildren
* a sequenced map from key to child id, or an empty map if
Copy link
Contributor

Choose a reason for hiding this comment

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

What does a sequenced map mean here? Is it referring to the Java 21's SequencedMap or an ordered map? If so, shouldn't we define with a more specific interface e.g. SequencedMap or the good old LinkedHashMap?

Copy link
Member Author

Choose a reason for hiding this comment

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

Cannot use SequencedMap since we still support Java 17. Don't want to use LinkedHashMap in any signature since that's an implementation detail.

Copy link
Contributor

@taefi taefi Jan 28, 2025

Choose a reason for hiding this comment

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

Then what prevents the caller code from passing an unordered map?

Copy link
Member Author

Choose a reason for hiding this comment

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

If caller code doesn't care about ordering, then it can do whatever it wants. But we should probably make sure there are tests to verify that order remains preserved once it has been implicitly or explicitly established.

Copy link
Contributor

Choose a reason for hiding this comment

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

Are those tests going to be added to this PR?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes. I will add those tests along with some other test omissions that have been pointed out in other comments.

* the node has no map children
*/
taefi marked this conversation as resolved.
Show resolved Hide resolved
/*
* There's no point in copying the record components here since they are
* already documented on the top level, but the Javadoc checker insist
* that this constructor also has full documentation...
*/
public Data {
Objects.requireNonNull(lastUpdate);

/*
* Avoid accidentally making a distinction between the two different
* nulls that will look the same after JSON deserialization
*/
if (value instanceof NullNode) {
value = null;
}
}
}
}
Loading
Loading