Implement anti-entropy for the EventuallyConsistentMap.
ONOS-857. Change-Id: Ife2070142d3c165c2a0035c3011c05b426c8baa4
Showing
2 changed files
with
344 additions
and
7 deletions
1 | +/* | ||
2 | + * Copyright 2015 Open Networking Laboratory | ||
3 | + * | ||
4 | + * Licensed under the Apache License, Version 2.0 (the "License"); | ||
5 | + * you may not use this file except in compliance with the License. | ||
6 | + * You may obtain a copy of the License at | ||
7 | + * | ||
8 | + * http://www.apache.org/licenses/LICENSE-2.0 | ||
9 | + * | ||
10 | + * Unless required by applicable law or agreed to in writing, software | ||
11 | + * distributed under the License is distributed on an "AS IS" BASIS, | ||
12 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
13 | + * See the License for the specific language governing permissions and | ||
14 | + * limitations under the License. | ||
15 | + */ | ||
16 | +package org.onosproject.store.impl; | ||
17 | + | ||
18 | +import org.onosproject.cluster.NodeId; | ||
19 | +import org.onosproject.store.Timestamp; | ||
20 | + | ||
21 | +import java.util.Map; | ||
22 | + | ||
23 | +import static com.google.common.base.Preconditions.checkNotNull; | ||
24 | + | ||
25 | +/** | ||
26 | + * Anti-entropy advertisement message for eventually consistent map. | ||
27 | + */ | ||
28 | +public class AntiEntropyAdvertisement<K> { | ||
29 | + | ||
30 | + private final NodeId sender; | ||
31 | + private final Map<K, Timestamp> timestamps; | ||
32 | + private final Map<K, Timestamp> tombstones; | ||
33 | + | ||
34 | + /** | ||
35 | + * Creates a new anti entropy advertisement message. | ||
36 | + * | ||
37 | + * @param sender the sender's node ID | ||
38 | + * @param timestamps map of item key to timestamp for current items | ||
39 | + * @param tombstones map of item key to timestamp for removed items | ||
40 | + */ | ||
41 | + public AntiEntropyAdvertisement(NodeId sender, | ||
42 | + Map<K, Timestamp> timestamps, | ||
43 | + Map<K, Timestamp> tombstones) { | ||
44 | + this.sender = checkNotNull(sender); | ||
45 | + this.timestamps = checkNotNull(timestamps); | ||
46 | + this.tombstones = checkNotNull(tombstones); | ||
47 | + } | ||
48 | + | ||
49 | + /** | ||
50 | + * Returns the sender's node ID. | ||
51 | + * | ||
52 | + * @return the sender's node ID | ||
53 | + */ | ||
54 | + public NodeId sender() { | ||
55 | + return sender; | ||
56 | + } | ||
57 | + | ||
58 | + /** | ||
59 | + * Returns the map of current item timestamps. | ||
60 | + * | ||
61 | + * @return current item timestamps | ||
62 | + */ | ||
63 | + public Map<K, Timestamp> timestamps() { | ||
64 | + return timestamps; | ||
65 | + } | ||
66 | + | ||
67 | + /** | ||
68 | + * Returns the map of removed item timestamps. | ||
69 | + * | ||
70 | + * @return removed item timestamps | ||
71 | + */ | ||
72 | + public Map<K, Timestamp> tombstones() { | ||
73 | + return tombstones; | ||
74 | + } | ||
75 | + | ||
76 | + // For serializer | ||
77 | + @SuppressWarnings("unused") | ||
78 | + private AntiEntropyAdvertisement() { | ||
79 | + this.sender = null; | ||
80 | + this.timestamps = null; | ||
81 | + this.tombstones = null; | ||
82 | + } | ||
83 | +} |
... | @@ -16,8 +16,10 @@ | ... | @@ -16,8 +16,10 @@ |
16 | package org.onosproject.store.impl; | 16 | package org.onosproject.store.impl; |
17 | 17 | ||
18 | import com.google.common.base.MoreObjects; | 18 | import com.google.common.base.MoreObjects; |
19 | +import org.apache.commons.lang3.RandomUtils; | ||
19 | import org.onlab.util.KryoNamespace; | 20 | import org.onlab.util.KryoNamespace; |
20 | import org.onosproject.cluster.ClusterService; | 21 | import org.onosproject.cluster.ClusterService; |
22 | +import org.onosproject.cluster.ControllerNode; | ||
21 | import org.onosproject.cluster.NodeId; | 23 | import org.onosproject.cluster.NodeId; |
22 | import org.onosproject.store.Timestamp; | 24 | import org.onosproject.store.Timestamp; |
23 | import org.onosproject.store.cluster.messaging.ClusterCommunicationService; | 25 | import org.onosproject.store.cluster.messaging.ClusterCommunicationService; |
... | @@ -32,6 +34,8 @@ import java.io.IOException; | ... | @@ -32,6 +34,8 @@ import java.io.IOException; |
32 | import java.util.ArrayList; | 34 | import java.util.ArrayList; |
33 | import java.util.Collection; | 35 | import java.util.Collection; |
34 | import java.util.Collections; | 36 | import java.util.Collections; |
37 | +import java.util.HashMap; | ||
38 | +import java.util.LinkedList; | ||
35 | import java.util.List; | 39 | import java.util.List; |
36 | import java.util.Map; | 40 | import java.util.Map; |
37 | import java.util.Set; | 41 | import java.util.Set; |
... | @@ -40,6 +44,7 @@ import java.util.concurrent.CopyOnWriteArraySet; | ... | @@ -40,6 +44,7 @@ import java.util.concurrent.CopyOnWriteArraySet; |
40 | import java.util.concurrent.ExecutorService; | 44 | import java.util.concurrent.ExecutorService; |
41 | import java.util.concurrent.Executors; | 45 | import java.util.concurrent.Executors; |
42 | import java.util.concurrent.ScheduledExecutorService; | 46 | import java.util.concurrent.ScheduledExecutorService; |
47 | +import java.util.concurrent.TimeUnit; | ||
43 | import java.util.stream.Collectors; | 48 | import java.util.stream.Collectors; |
44 | 49 | ||
45 | import static com.google.common.base.Preconditions.checkNotNull; | 50 | import static com.google.common.base.Preconditions.checkNotNull; |
... | @@ -69,8 +74,9 @@ public class EventuallyConsistentMapImpl<K, V> | ... | @@ -69,8 +74,9 @@ public class EventuallyConsistentMapImpl<K, V> |
69 | 74 | ||
70 | private final MessageSubject updateMessageSubject; | 75 | private final MessageSubject updateMessageSubject; |
71 | private final MessageSubject removeMessageSubject; | 76 | private final MessageSubject removeMessageSubject; |
77 | + private final MessageSubject antiEntropyAdvertisementSubject; | ||
72 | 78 | ||
73 | - private final Set<EventuallyConsistentMapListener> listeners | 79 | + private final Set<EventuallyConsistentMapListener<K, V>> listeners |
74 | = new CopyOnWriteArraySet<>(); | 80 | = new CopyOnWriteArraySet<>(); |
75 | 81 | ||
76 | private final ExecutorService executor; | 82 | private final ExecutorService executor; |
... | @@ -138,12 +144,20 @@ public class EventuallyConsistentMapImpl<K, V> | ... | @@ -138,12 +144,20 @@ public class EventuallyConsistentMapImpl<K, V> |
138 | newSingleThreadScheduledExecutor(minPriority( | 144 | newSingleThreadScheduledExecutor(minPriority( |
139 | namedThreads("onos-ecm-" + mapName + "-bg-%d"))); | 145 | namedThreads("onos-ecm-" + mapName + "-bg-%d"))); |
140 | 146 | ||
147 | + // start anti-entropy thread | ||
148 | + backgroundExecutor.scheduleAtFixedRate(new SendAdvertisementTask(), | ||
149 | + initialDelaySec, periodSec, | ||
150 | + TimeUnit.SECONDS); | ||
151 | + | ||
141 | updateMessageSubject = new MessageSubject("ecm-" + mapName + "-update"); | 152 | updateMessageSubject = new MessageSubject("ecm-" + mapName + "-update"); |
142 | clusterCommunicator.addSubscriber(updateMessageSubject, | 153 | clusterCommunicator.addSubscriber(updateMessageSubject, |
143 | new InternalPutEventListener()); | 154 | new InternalPutEventListener()); |
144 | removeMessageSubject = new MessageSubject("ecm-" + mapName + "-remove"); | 155 | removeMessageSubject = new MessageSubject("ecm-" + mapName + "-remove"); |
145 | clusterCommunicator.addSubscriber(removeMessageSubject, | 156 | clusterCommunicator.addSubscriber(removeMessageSubject, |
146 | new InternalRemoveEventListener()); | 157 | new InternalRemoveEventListener()); |
158 | + antiEntropyAdvertisementSubject = new MessageSubject("ecm-" + mapName + "-anti-entropy"); | ||
159 | + clusterCommunicator.addSubscriber(antiEntropyAdvertisementSubject, | ||
160 | + new InternalAntiEntropyListener()); | ||
147 | } | 161 | } |
148 | 162 | ||
149 | private KryoSerializer createSerializer(KryoNamespace.Builder builder) { | 163 | private KryoSerializer createSerializer(KryoNamespace.Builder builder) { |
... | @@ -158,9 +172,9 @@ public class EventuallyConsistentMapImpl<K, V> | ... | @@ -158,9 +172,9 @@ public class EventuallyConsistentMapImpl<K, V> |
158 | .register(ArrayList.class) | 172 | .register(ArrayList.class) |
159 | .register(InternalPutEvent.class) | 173 | .register(InternalPutEvent.class) |
160 | .register(InternalRemoveEvent.class) | 174 | .register(InternalRemoveEvent.class) |
175 | + .register(AntiEntropyAdvertisement.class) | ||
176 | + .register(HashMap.class) | ||
161 | .build(); | 177 | .build(); |
162 | - | ||
163 | - // TODO anti-entropy classes | ||
164 | } | 178 | } |
165 | }; | 179 | }; |
166 | } | 180 | } |
... | @@ -360,8 +374,8 @@ public class EventuallyConsistentMapImpl<K, V> | ... | @@ -360,8 +374,8 @@ public class EventuallyConsistentMapImpl<K, V> |
360 | clusterCommunicator.removeSubscriber(removeMessageSubject); | 374 | clusterCommunicator.removeSubscriber(removeMessageSubject); |
361 | } | 375 | } |
362 | 376 | ||
363 | - private void notifyListeners(EventuallyConsistentMapEvent event) { | 377 | + private void notifyListeners(EventuallyConsistentMapEvent<K, V> event) { |
364 | - for (EventuallyConsistentMapListener listener : listeners) { | 378 | + for (EventuallyConsistentMapListener<K, V> listener : listeners) { |
365 | listener.event(event); | 379 | listener.event(event); |
366 | } | 380 | } |
367 | } | 381 | } |
... | @@ -418,6 +432,245 @@ public class EventuallyConsistentMapImpl<K, V> | ... | @@ -418,6 +432,245 @@ public class EventuallyConsistentMapImpl<K, V> |
418 | } | 432 | } |
419 | } | 433 | } |
420 | 434 | ||
435 | + private final class SendAdvertisementTask implements Runnable { | ||
436 | + @Override | ||
437 | + public void run() { | ||
438 | + if (Thread.currentThread().isInterrupted()) { | ||
439 | + log.info("Interrupted, quitting"); | ||
440 | + return; | ||
441 | + } | ||
442 | + | ||
443 | + try { | ||
444 | + final NodeId self = clusterService.getLocalNode().id(); | ||
445 | + Set<ControllerNode> nodes = clusterService.getNodes(); | ||
446 | + | ||
447 | + List<NodeId> nodeIds = nodes.stream() | ||
448 | + .map(node -> node.id()) | ||
449 | + .collect(Collectors.toList()); | ||
450 | + | ||
451 | + if (nodeIds.size() == 1 && nodeIds.get(0).equals(self)) { | ||
452 | + log.trace("No other peers in the cluster."); | ||
453 | + return; | ||
454 | + } | ||
455 | + | ||
456 | + NodeId peer; | ||
457 | + do { | ||
458 | + int idx = RandomUtils.nextInt(0, nodeIds.size()); | ||
459 | + peer = nodeIds.get(idx); | ||
460 | + } while (peer.equals(self)); | ||
461 | + | ||
462 | + if (Thread.currentThread().isInterrupted()) { | ||
463 | + log.info("Interrupted, quitting"); | ||
464 | + return; | ||
465 | + } | ||
466 | + | ||
467 | + AntiEntropyAdvertisement<K> ad = createAdvertisement(); | ||
468 | + | ||
469 | + try { | ||
470 | + unicastMessage(peer, antiEntropyAdvertisementSubject, ad); | ||
471 | + } catch (IOException e) { | ||
472 | + log.debug("Failed to send anti-entropy advertisement to {}", peer); | ||
473 | + } | ||
474 | + } catch (Exception e) { | ||
475 | + // Catch all exceptions to avoid scheduled task being suppressed. | ||
476 | + log.error("Exception thrown while sending advertisement", e); | ||
477 | + } | ||
478 | + } | ||
479 | + } | ||
480 | + | ||
481 | + private AntiEntropyAdvertisement<K> createAdvertisement() { | ||
482 | + final NodeId self = clusterService.getLocalNode().id(); | ||
483 | + | ||
484 | + Map<K, Timestamp> timestamps = new HashMap<>(items.size()); | ||
485 | + | ||
486 | + items.forEach((key, value) -> timestamps.put(key, value.timestamp())); | ||
487 | + | ||
488 | + Map<K, Timestamp> tombstones = new HashMap<>(removedItems); | ||
489 | + | ||
490 | + return new AntiEntropyAdvertisement<>(self, timestamps, tombstones); | ||
491 | + } | ||
492 | + | ||
493 | + private void handleAntiEntropyAdvertisement(AntiEntropyAdvertisement<K> ad) { | ||
494 | + List<EventuallyConsistentMapEvent<K, V>> externalEvents; | ||
495 | + | ||
496 | + synchronized (this) { | ||
497 | + final NodeId sender = ad.sender(); | ||
498 | + | ||
499 | + externalEvents = antiEntropyCheckLocalItems(ad); | ||
500 | + | ||
501 | + antiEntropyCheckLocalRemoved(ad); | ||
502 | + | ||
503 | + externalEvents.addAll(antiEntropyCheckRemoteRemoved(ad)); | ||
504 | + | ||
505 | + // if remote ad has something unknown, actively sync | ||
506 | + for (K key : ad.timestamps().keySet()) { | ||
507 | + if (!items.containsKey(key)) { | ||
508 | + AntiEntropyAdvertisement<K> myAd = createAdvertisement(); | ||
509 | + try { | ||
510 | + unicastMessage(sender, antiEntropyAdvertisementSubject, | ||
511 | + myAd); | ||
512 | + break; | ||
513 | + } catch (IOException e) { | ||
514 | + log.debug( | ||
515 | + "Failed to send reactive anti-entropy advertisement to {}", | ||
516 | + sender); | ||
517 | + } | ||
518 | + } | ||
519 | + } | ||
520 | + } // synchronized (this) | ||
521 | + | ||
522 | + externalEvents.forEach(this::notifyListeners); | ||
523 | + } | ||
524 | + | ||
525 | + /** | ||
526 | + * Checks if any of the remote's live items or tombstones are out of date | ||
527 | + * according to our local live item list, or if our live items are out of | ||
528 | + * date according to the remote's tombstone list. | ||
529 | + * If the local copy is more recent, it will be pushed to the remote. If the | ||
530 | + * remote has a more recent remove, we apply that to the local state. | ||
531 | + * | ||
532 | + * @param ad remote anti-entropy advertisement | ||
533 | + * @return list of external events relating to local operations performed | ||
534 | + */ | ||
535 | + // Guarded by synchronized (this) | ||
536 | + private List<EventuallyConsistentMapEvent<K, V>> antiEntropyCheckLocalItems( | ||
537 | + AntiEntropyAdvertisement<K> ad) { | ||
538 | + final List<EventuallyConsistentMapEvent<K, V>> externalEvents | ||
539 | + = new LinkedList<>(); | ||
540 | + final NodeId sender = ad.sender(); | ||
541 | + | ||
542 | + final List<PutEntry<K, V>> updatesToSend = new ArrayList<>(); | ||
543 | + | ||
544 | + for (Map.Entry<K, Timestamped<V>> item : items.entrySet()) { | ||
545 | + K key = item.getKey(); | ||
546 | + Timestamped<V> localValue = item.getValue(); | ||
547 | + | ||
548 | + Timestamp remoteTimestamp = ad.timestamps().get(key); | ||
549 | + if (remoteTimestamp == null) { | ||
550 | + remoteTimestamp = ad.tombstones().get(key); | ||
551 | + } | ||
552 | + if (remoteTimestamp == null || localValue | ||
553 | + .isNewer(remoteTimestamp)) { | ||
554 | + // local value is more recent, push to sender | ||
555 | + updatesToSend | ||
556 | + .add(new PutEntry<>(key, localValue.value(), | ||
557 | + localValue.timestamp())); | ||
558 | + } | ||
559 | + | ||
560 | + Timestamp remoteDeadTimestamp = ad.tombstones().get(key); | ||
561 | + if (remoteDeadTimestamp != null && | ||
562 | + remoteDeadTimestamp.compareTo(localValue.timestamp()) > 0) { | ||
563 | + // sender has a more recent remove | ||
564 | + if (removeInternal(key, remoteDeadTimestamp)) { | ||
565 | + externalEvents.add(new EventuallyConsistentMapEvent<>( | ||
566 | + EventuallyConsistentMapEvent.Type.REMOVE, key, null)); | ||
567 | + } | ||
568 | + } | ||
569 | + } | ||
570 | + | ||
571 | + // Send all updates to the peer at once | ||
572 | + if (!updatesToSend.isEmpty()) { | ||
573 | + try { | ||
574 | + unicastMessage(sender, updateMessageSubject, new InternalPutEvent<>(updatesToSend)); | ||
575 | + } catch (IOException e) { | ||
576 | + log.warn("Failed to send advertisement response", e); | ||
577 | + } | ||
578 | + } | ||
579 | + | ||
580 | + return externalEvents; | ||
581 | + } | ||
582 | + | ||
583 | + /** | ||
584 | + * Checks if any items in the remote live list are out of date according | ||
585 | + * to our tombstone list. If we find we have a more up to date tombstone, | ||
586 | + * we'll send it to the remote. | ||
587 | + * | ||
588 | + * @param ad remote anti-entropy advertisement | ||
589 | + */ | ||
590 | + // Guarded by synchronized (this) | ||
591 | + private void antiEntropyCheckLocalRemoved(AntiEntropyAdvertisement<K> ad) { | ||
592 | + final NodeId sender = ad.sender(); | ||
593 | + | ||
594 | + final List<RemoveEntry<K>> removesToSend = new ArrayList<>(); | ||
595 | + | ||
596 | + for (Map.Entry<K, Timestamp> dead : removedItems.entrySet()) { | ||
597 | + K key = dead.getKey(); | ||
598 | + Timestamp localDeadTimestamp = dead.getValue(); | ||
599 | + | ||
600 | + Timestamp remoteLiveTimestamp = ad.timestamps().get(key); | ||
601 | + if (remoteLiveTimestamp != null | ||
602 | + && localDeadTimestamp.compareTo(remoteLiveTimestamp) > 0) { | ||
603 | + // sender has zombie, push remove | ||
604 | + removesToSend | ||
605 | + .add(new RemoveEntry<>(key, localDeadTimestamp)); | ||
606 | + } | ||
607 | + } | ||
608 | + | ||
609 | + // Send all removes to the peer at once | ||
610 | + if (!removesToSend.isEmpty()) { | ||
611 | + try { | ||
612 | + unicastMessage(sender, removeMessageSubject, new InternalRemoveEvent<>(removesToSend)); | ||
613 | + } catch (IOException e) { | ||
614 | + log.warn("Failed to send advertisement response", e); | ||
615 | + } | ||
616 | + } | ||
617 | + } | ||
618 | + | ||
619 | + /** | ||
620 | + * Checks if any of the local live items are out of date according to the | ||
621 | + * remote's tombstone advertisements. If we find a local item is out of date, | ||
622 | + * we'll apply the remove operation to the local state. | ||
623 | + * | ||
624 | + * @param ad remote anti-entropy advertisement | ||
625 | + * @return list of external events relating to local operations performed | ||
626 | + */ | ||
627 | + // Guarded by synchronized (this) | ||
628 | + private List<EventuallyConsistentMapEvent<K, V>> | ||
629 | + antiEntropyCheckRemoteRemoved(AntiEntropyAdvertisement<K> ad) { | ||
630 | + final List<EventuallyConsistentMapEvent<K, V>> externalEvents | ||
631 | + = new LinkedList<>(); | ||
632 | + | ||
633 | + for (Map.Entry<K, Timestamp> remoteDead : ad.tombstones().entrySet()) { | ||
634 | + K key = remoteDead.getKey(); | ||
635 | + Timestamp remoteDeadTimestamp = remoteDead.getValue(); | ||
636 | + | ||
637 | + Timestamped<V> local = items.get(key); | ||
638 | + Timestamp localDead = removedItems.get(key); | ||
639 | + if (local != null | ||
640 | + && remoteDeadTimestamp.compareTo(local.timestamp()) > 0) { | ||
641 | + // remove our version | ||
642 | + if (removeInternal(key, remoteDeadTimestamp)) { | ||
643 | + externalEvents.add(new EventuallyConsistentMapEvent<>( | ||
644 | + EventuallyConsistentMapEvent.Type.REMOVE, key, null)); | ||
645 | + } | ||
646 | + } else if (localDead != null && | ||
647 | + remoteDeadTimestamp.compareTo(localDead) > 0) { | ||
648 | + // If we both had the item as removed, but their timestamp is | ||
649 | + // newer, update ours to the newer value | ||
650 | + removedItems.put(key, remoteDeadTimestamp); | ||
651 | + } | ||
652 | + } | ||
653 | + | ||
654 | + return externalEvents; | ||
655 | + } | ||
656 | + | ||
657 | + private final class InternalAntiEntropyListener | ||
658 | + implements ClusterMessageHandler { | ||
659 | + | ||
660 | + @Override | ||
661 | + public void handle(ClusterMessage message) { | ||
662 | + log.trace("Received anti-entropy advertisement from peer: {}", message.sender()); | ||
663 | + AntiEntropyAdvertisement<K> advertisement = serializer.decode(message.payload()); | ||
664 | + backgroundExecutor.submit(() -> { | ||
665 | + try { | ||
666 | + handleAntiEntropyAdvertisement(advertisement); | ||
667 | + } catch (Exception e) { | ||
668 | + log.warn("Exception thrown handling advertisements", e); | ||
669 | + } | ||
670 | + }); | ||
671 | + } | ||
672 | + } | ||
673 | + | ||
421 | private final class InternalPutEventListener implements | 674 | private final class InternalPutEventListener implements |
422 | ClusterMessageHandler { | 675 | ClusterMessageHandler { |
423 | @Override | 676 | @Override |
... | @@ -433,7 +686,7 @@ public class EventuallyConsistentMapImpl<K, V> | ... | @@ -433,7 +686,7 @@ public class EventuallyConsistentMapImpl<K, V> |
433 | Timestamp timestamp = entry.timestamp(); | 686 | Timestamp timestamp = entry.timestamp(); |
434 | 687 | ||
435 | if (putInternal(key, value, timestamp)) { | 688 | if (putInternal(key, value, timestamp)) { |
436 | - EventuallyConsistentMapEvent externalEvent = | 689 | + EventuallyConsistentMapEvent<K, V> externalEvent = |
437 | new EventuallyConsistentMapEvent<>( | 690 | new EventuallyConsistentMapEvent<>( |
438 | EventuallyConsistentMapEvent.Type.PUT, key, | 691 | EventuallyConsistentMapEvent.Type.PUT, key, |
439 | value); | 692 | value); |
... | @@ -461,7 +714,8 @@ public class EventuallyConsistentMapImpl<K, V> | ... | @@ -461,7 +714,8 @@ public class EventuallyConsistentMapImpl<K, V> |
461 | Timestamp timestamp = entry.timestamp(); | 714 | Timestamp timestamp = entry.timestamp(); |
462 | 715 | ||
463 | if (removeInternal(key, timestamp)) { | 716 | if (removeInternal(key, timestamp)) { |
464 | - EventuallyConsistentMapEvent externalEvent = new EventuallyConsistentMapEvent<K, V>( | 717 | + EventuallyConsistentMapEvent<K, V> externalEvent |
718 | + = new EventuallyConsistentMapEvent<>( | ||
465 | EventuallyConsistentMapEvent.Type.REMOVE, | 719 | EventuallyConsistentMapEvent.Type.REMOVE, |
466 | key, null); | 720 | key, null); |
467 | notifyListeners(externalEvent); | 721 | notifyListeners(externalEvent); | ... | ... |
-
Please register or login to post a comment