diff --git a/README.md b/README.md index 37df5d3c..a56ee39e 100644 --- a/README.md +++ b/README.md @@ -18,3 +18,16 @@ PAMELA approach has been tested and validated on some java-based industrial proj - Dynamic code weaving at runtime (aspect programming without compilation) Official website, documentation and installation available here : [https://www.openflexo.org/pamela](https://www.openflexo.org/pamela) + +## Lunch Sync Feature + +Build the projet with gradle: + +```bash +.\gradlew.bat clean :pamela-core:build :book:classes -x test --console=plain +``` +Then run the Book example with the following command: + +```bash +.\gradlew.bat :book:runDemo --console=plain +``` diff --git a/book/.DS_Store b/book/.DS_Store new file mode 100644 index 00000000..2fa56ccf Binary files /dev/null and b/book/.DS_Store differ diff --git a/book/SYNC_README.md b/book/SYNC_README.md new file mode 100644 index 00000000..a0a50179 --- /dev/null +++ b/book/SYNC_README.md @@ -0,0 +1,157 @@ +# PAMELA Distributed Synchronization Demo + +Ce dossier contient un exemple de synchronisation distribuée d'instances PAMELA +utilisant RabbitMQ comme broker de messages. + +## Architecture + +``` +┌─────────────────┐ RabbitMQ ┌─────────────────┐ +│ ReplicaA │◄──────────────────►│ ReplicaB │ +│ (Terminal 1) │ fanout exchange │ (Terminal 2) │ +│ │ pamela-library- │ │ +│ ┌───────────┐ │ sync │ ┌───────────┐ │ +│ │ Library │ │ │ │ Library │ │ +│ │ ┌─────┐ │ │ │ │ ┌─────┐ │ │ +│ │ │Book1│ │ │ ◄── ADD Book ──► │ │ │Book1│ │ │ +│ │ │Book2│ │ │ ◄── SET prop ──► │ │ │Book2│ │ │ +│ │ └─────┘ │ │ ◄── REMOVE ────► │ │ └─────┘ │ │ +│ └───────────┘ │ │ └───────────┘ │ +└─────────────────┘ └─────────────────┘ +``` + +## Prérequis + +1. **Java 17+** installé +2. **Docker** installé et lancé +3. **Gradle** (ou utiliser le wrapper `gradlew`) + +## Étapes de Test + +### 1. Démarrer RabbitMQ (Docker) + +```bash +docker run -d --name rabbitmq \ + -p 5672:5672 \ + -p 15672:15672 \ + rabbitmq:3-management +``` + +Accéder à l'interface d'administration : http://localhost:15672 +Identifiants par défaut : `guest` / `guest` + +### 2. Compiler le projet + +```bash +cd book +.\gradlew build +``` + +### 3. Terminal 1 - Lancer ReplicaA + +```bash +.\gradlew run -PmainClass=org.openflexo.testPamela.ReplicaA +``` + +Ou avec Java directement : +```bash +java -cp build/libs/book.jar:../pamela-core/build/libs/* org.openflexo.testPamela.ReplicaA +``` + +Notez l'**ID de la Library** affiché (ex: `a3b4c5d6-...`) + +### 4. Terminal 2 - Lancer ReplicaB + +```bash +.\gradlew run -PmainClass=org.openflexo.testPamela.ReplicaB +``` + +Quand demandé, entrez l'ID de la Library affiché par ReplicaA. + +### 5. Tester la synchronisation + +**Dans Terminal 1 (ReplicaA) :** +``` +[A] > add Harry Potter +[A] ✓ Added: Book(title=Harry Potter) +``` + +**Dans Terminal 2 (ReplicaB) :** (mise à jour automatique) +``` +[B] 📥 Received: ADD on books = Book(title=Harry Potter) +``` + +**Dans Terminal 2 (ReplicaB) :** +``` +[B] > add Le Seigneur des Anneaux +``` + +**Dans Terminal 1 (ReplicaA) :** (mise à jour automatique) +``` +[A] 📥 Received: ADD on books = ... +``` + +## Commandes disponibles + +| Commande | Description | +|----------|-------------| +| `add ` | Ajoute un livre à la bibliothèque | +| `remove ` | Supprime un livre par son titre | +| `list` | Affiche tous les livres | +| `quit` | Quitte l'application | + +## Structure des fichiers + +``` +book/ +├── build.gradle # Dépendances (RabbitMQ, Jackson) +├── SYNC_README.md # Ce fichier +└── src/main/java/org/openflexo/testPamela/ + ├── model/ + │ ├── Book.java # Modèle PAMELA d'un livre + │ ├── Library.java # Modèle PAMELA d'une bibliothèque + │ ├── Novel.java # Sous-type de Book + │ └── Journal.java # Sous-type de Book + ├── App.java # Exemple original simple + ├── ReplicaA.java # Première instance collaborative + └── ReplicaB.java # Seconde instance collaborative +``` + +## Comment ça marche ? + +1. **SyncEditingContext** : Contexte d'édition étendu qui intercepte les modifications +2. **RabbitMQSyncManager** : Gère la connexion à RabbitMQ et la diffusion des messages +3. **ObjectIdentityManager** : Associe chaque objet PAMELA à un UUID unique +4. **SyncOperation** : Représente une opération (CREATE, SET, ADD, REMOVE, DELETE) +5. **VectorClock** : Horloge vectorielle pour l'ordre causal des opérations + +Quand vous faites `library.addToBooks(book)` : +1. Le `ProxyMethodHandler` détecte l'appel au setter +2. Il broadcast une `SyncOperation` via `SyncEditingContext` +3. Le `RabbitMQSyncManager` publie le message JSON sur l'exchange +4. Toutes les réplicas reçoivent le message et appliquent l'opération + +## Dépannage + +### RabbitMQ ne démarre pas +```bash +docker logs rabbitmq +``` + +### Erreur de connexion +Vérifiez que le port 5672 est accessible : +```bash +docker ps | findstr rabbitmq +``` + +### Les messages ne se synchronisent pas +1. Vérifiez que les deux réplicas utilisent le même Library ID +2. Consultez les messages dans RabbitMQ Management UI (http://localhost:15672) +3. Vérifiez l'exchange "pamela-library-sync" + +## Arrêter RabbitMQ + +```bash +docker stop rabbitmq +docker rm rabbitmq +``` diff --git a/book/build.gradle b/book/build.gradle new file mode 100644 index 00000000..2899fcb9 --- /dev/null +++ b/book/build.gradle @@ -0,0 +1,79 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * This generated file contains a sample Java project to get you started. + * For more details take a look at the Java Quickstart chapter in the Gradle + * User Manual available at https://docs.gradle.org/6.6.1/userguide/tutorial_java_projects.html + */ + +plugins { + // Apply the java plugin to add support for Java + id 'java' + + // Apply the application plugin to add support for building a CLI application. + id 'application' +} + +repositories { + maven { + url "https://maven.openflexo.org/artifactory/openflexo-deps/" + } + mavenCentral() +// jcenter() +} + +dependencies { + // Use local pamela-core project (includes the sync package) + implementation project(':pamela-core') + + // RabbitMQ for collaborative synchronization + implementation 'com.rabbitmq:amqp-client:5.20.0' + + // Jackson for JSON serialization + implementation 'com.fasterxml.jackson.core:jackson-databind:2.16.0' + + // Use JUnit test framework + testImplementation 'junit:junit:4.13' +} + +application { + // Define the main class for the application. + // Can be overridden with: gradlew run -PmainClass=org.openflexo.testPamela.ReplicaA + mainClassName = project.hasProperty('mainClass') ? project.property('mainClass') : 'org.openflexo.testPamela.App' +} + +// Task to run ReplicaA +task runReplicaA(type: JavaExec) { + group = 'application' + description = 'Run the first collaborative replica (ReplicaA)' + classpath = sourceSets.main.runtimeClasspath + mainClass = 'org.openflexo.testPamela.ReplicaA' + standardInput = System.in +} + +// Task to run ReplicaB +task runReplicaB(type: JavaExec) { + group = 'application' + description = 'Run the second collaborative replica (ReplicaB)' + classpath = sourceSets.main.runtimeClasspath + mainClass = 'org.openflexo.testPamela.ReplicaB' + standardInput = System.in +} + +// Task to run ReplicaC +task runReplicaC(type: JavaExec) { + group = 'application' + description = 'Run the third collaborative replica (ReplicaC)' + classpath = sourceSets.main.runtimeClasspath + mainClass = 'org.openflexo.testPamela.ReplicaC' + standardInput = System.in +} + +// Task to run the Distributed Feature Demo +task runDemo(type: JavaExec) { + group = 'application' + description = 'Run the distributed features demonstration' + classpath = sourceSets.main.runtimeClasspath + mainClass = 'org.openflexo.testPamela.DistributedFeatureDemo' + standardInput = System.in +} diff --git a/book/gradle/.DS_Store b/book/gradle/.DS_Store new file mode 100644 index 00000000..5008ddfc Binary files /dev/null and b/book/gradle/.DS_Store differ diff --git a/book/gradle/wrapper/gradle-wrapper.jar b/book/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..e708b1c0 Binary files /dev/null and b/book/gradle/wrapper/gradle-wrapper.jar differ diff --git a/book/gradle/wrapper/gradle-wrapper.properties b/book/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..3ae1e2f1 --- /dev/null +++ b/book/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/book/gradlew b/book/gradlew new file mode 100644 index 00000000..4f906e0c --- /dev/null +++ b/book/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# 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 +# +# https://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. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/book/gradlew.bat b/book/gradlew.bat new file mode 100644 index 00000000..107acd32 --- /dev/null +++ b/book/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/book/settings.gradle b/book/settings.gradle new file mode 100644 index 00000000..b23f0f0a --- /dev/null +++ b/book/settings.gradle @@ -0,0 +1,10 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * The settings file is used to specify which projects to include in your build. + * + * Detailed information about configuring a multi-project build in Gradle can be found + * in the user manual at https://docs.gradle.org/6.6.1/userguide/multi_project_builds.html + */ + +rootProject.name = 'book' diff --git a/book/src/main/java/org/openflexo/testPamela/App.java b/book/src/main/java/org/openflexo/testPamela/App.java new file mode 100644 index 00000000..fdfdd4d8 --- /dev/null +++ b/book/src/main/java/org/openflexo/testPamela/App.java @@ -0,0 +1,41 @@ +/* + * This Java source file was generated by the Gradle 'init' task. + */ +package org.openflexo.testPamela; + +import org.openflexo.pamela.PamelaMetaModel; +import org.openflexo.pamela.PamelaMetaModelLibrary; +import org.openflexo.pamela.exceptions.ModelDefinitionException; +import org.openflexo.pamela.factory.PamelaModelFactory; +import org.openflexo.testPamela.model.Book; +import org.openflexo.testPamela.model.Library; + +public class App { + + public Library books; + + public App() throws ModelDefinitionException { + // Instantiate the meta-model + // by computing the closure of concepts graph + PamelaMetaModel pamelaMetaModel = PamelaMetaModelLibrary.retrieveMetaModel(Library.class); + // Instantiate the factory + PamelaModelFactory factory = new PamelaModelFactory(pamelaMetaModel); + + // Instantiate a book, the Book class has been loaded because Library depends on Book + Book aBook = factory.newInstance(Book.class, "Lord of the ring"); + + this.books = factory.newInstance(Library.class); + this.books.addToBooks(aBook); + System.out.println("result is " + aBook); + if (aBook.isCorrect()) { + System.out.println("result is correct"); + } else { + System.out.println("result is not correct"); + } + } + + public static void main(String[] args) throws ModelDefinitionException { + App app = new App(); + System.out.println("library is " + app.books); + } +} diff --git a/book/src/main/java/org/openflexo/testPamela/DistributedFeatureDemo.java b/book/src/main/java/org/openflexo/testPamela/DistributedFeatureDemo.java new file mode 100644 index 00000000..0fcede1d --- /dev/null +++ b/book/src/main/java/org/openflexo/testPamela/DistributedFeatureDemo.java @@ -0,0 +1,517 @@ +/* + * PAMELA Distributed Features Demo + * + * This application demonstrates ALL distributed synchronization features: + * + * 1. STATE_REQUEST / STATE_RESPONSE - State sync for new clients + * 2. CREATE - Object creation synchronization + * 3. SET - Property setter synchronization + * 4. ADD - Collection adder synchronization + * 5. REMOVE - Collection remover synchronization + * 6. DELETE - Object deletion synchronization + * 7. VectorClock - Causality tracking + * 8. Auto state request on connect + * + * Run multiple instances to see real-time synchronization! + */ +package org.openflexo.testPamela; + +import org.openflexo.pamela.PamelaMetaModel; +import org.openflexo.pamela.PamelaMetaModelLibrary; +import org.openflexo.pamela.factory.PamelaModelFactory; +import org.openflexo.pamela.sync.RabbitMQSyncManager; +import org.openflexo.pamela.sync.SyncEditingContext; +import org.openflexo.pamela.sync.SyncOperation; +import org.openflexo.pamela.sync.SyncOperationListener; +import org.openflexo.testPamela.model.Book; +import org.openflexo.testPamela.model.Library; + +import java.util.Scanner; +import java.util.concurrent.atomic.AtomicInteger; + +public class DistributedFeatureDemo { + + private static Library library; + private static PamelaModelFactory factory; + private static SyncEditingContext syncContext; + private static String replicaName; + private static final AtomicInteger bookCounter = new AtomicInteger(1); + + // CloudAMQP configuration + private static final String AMQP_HOST = "rat.rmq2.cloudamqp.com"; + private static final int AMQP_PORT = 5671; + private static final String AMQP_USER = "gcyabtej"; + private static final String AMQP_PASS = "C91PisA-dAYuoVTxHRnzU1RCU1fERHeU"; + private static final String AMQP_VHOST = "gcyabtej"; + + public static void main(String[] args) throws Exception { + printBanner(); + + Scanner scanner = new Scanner(System.in); + + // Get replica name + System.out.print("Enter your replica name (e.g., Alice, Bob, Charlie): "); + replicaName = scanner.nextLine().trim(); + if (replicaName.isEmpty()) { + replicaName = "Replica-" + System.currentTimeMillis() % 1000; + } + + System.out.println(); + System.out.println("[" + replicaName + "] Initializing PAMELA..."); + + // Initialize PAMELA + PamelaMetaModel metaModel = PamelaMetaModelLibrary.retrieveMetaModel(Library.class); + factory = new PamelaModelFactory(metaModel); + syncContext = new SyncEditingContext(factory); + factory.setEditingContext(syncContext); + + // Configure RabbitMQ sync manager + System.out.println("[" + replicaName + "] Connecting to CloudAMQP..."); + RabbitMQSyncManager syncManager = RabbitMQSyncManager.builder() + .host(AMQP_HOST) + .port(AMQP_PORT) + .credentials(AMQP_USER, AMQP_PASS) + .virtualHost(AMQP_VHOST) + .useSsl(true) + .exchangeName("pamela-distributed-demo") + .build(); + + // Setup sync context with auto state request + syncContext.setSyncManager(syncManager); + syncManager.addListener(syncContext); + + // Add operation listener for visibility + syncManager.addListener(createOperationListener()); + + try { + syncManager.connect(); + System.out.println("[" + replicaName + "] ✓ Connected! Replica ID: " + + syncManager.getReplicaId().substring(0, 8)); + } catch (Exception e) { + System.err.println("[" + replicaName + "] ✗ Connection failed: " + e.getMessage()); + return; + } + + // Ask if joining existing session or creating new + System.out.println(); + System.out.println("╔═══════════════════════════════════════════════════════════════╗"); + System.out.println("║ 1. CREATE new library (if you're the first replica) ║"); + System.out.println("║ 2. JOIN existing library (enter Library ID from another) ║"); + System.out.println("╚═══════════════════════════════════════════════════════════════╝"); + System.out.print("Choice [1/2]: "); + + String choice = scanner.nextLine().trim(); + + if ("2".equals(choice)) { + // Join existing library + System.out.print("Enter Library ID: "); + String libraryId = scanner.nextLine().trim(); + + if (!libraryId.isEmpty()) { + // Create local library and register with same ID + library = factory.newInstance(Library.class); + syncContext.getIdentityManager().registerObject(library, libraryId); + System.out.println("[" + replicaName + "] Registered with Library ID: " + libraryId); + + // Request state from other replicas (KEY FEATURE: STATE_REQUEST) + System.out.println("[" + replicaName + "] 📡 Requesting current state from other replicas..."); + syncContext.requestStateSync(); + + // Wait for state to arrive + Thread.sleep(3000); + System.out.println("[" + replicaName + "] ✓ State sync complete!"); + System.out.println("[" + replicaName + "] Books received: " + library.getBooks().size()); + printLibrary(); + } else { + createNewLibrary(); + } + } else { + createNewLibrary(); + } + + // Print commands help + printHelp(); + + // Interactive command loop + while (true) { + System.out.print("[" + replicaName + "] > "); + String input = scanner.nextLine().trim(); + + if (input.isEmpty()) continue; + + String[] parts = input.split("\\s+", 2); + String command = parts[0].toLowerCase(); + String argument = parts.length > 1 ? parts[1] : ""; + + try { + switch (command) { + case "help": + printHelp(); + break; + + case "add": + // TEST: ADD operation + testAddOperation(argument); + break; + + case "remove": + // TEST: REMOVE operation + testRemoveOperation(argument); + break; + + case "set": + // TEST: SET operation + testSetOperation(argument); + break; + + case "move": + // TEST: REINDEX operation + testMoveOperation(argument); + break; + + case "list": + printLibrary(); + break; + + case "state": + // TEST: STATE_REQUEST + System.out.println("[" + replicaName + "] Requesting state sync..."); + syncContext.requestStateSync(); + break; + + case "info": + printInfo(syncManager); + break; + + case "quit": + case "exit": + System.out.println("[" + replicaName + "] Disconnecting..."); + syncManager.disconnect(); + System.out.println("[" + replicaName + "] Goodbye!"); + return; + + case "demo": + runFullDemo(); + break; + + default: + System.out.println("Unknown command. Type 'help' for available commands."); + } + } catch (Exception e) { + System.out.println("[" + replicaName + "] Error: " + e.getMessage()); + } + } + } + + private static void createNewLibrary() { + library = factory.newInstance(Library.class); + String libraryId = syncContext.getIdentityManager().getOrCreateObjectId(library); + System.out.println(); + System.out.println("╔═══════════════════════════════════════════════════════════════╗"); + System.out.println("║ NEW LIBRARY CREATED ║"); + System.out.println("║ Library ID: " + libraryId); + System.out.println("║ ║"); + System.out.println("║ Share this ID with other replicas to join! ║"); + System.out.println("╚═══════════════════════════════════════════════════════════════╝"); + System.out.println(); + } + + /** + * TEST: ADD operation - adds a book to the library + */ + private static void testAddOperation(String title) { + if (title.isEmpty()) { + title = "Book-" + replicaName + "-" + bookCounter.getAndIncrement(); + } + + Book book = factory.newInstance(Book.class, title); + book.setISBN(replicaName + "-" + System.currentTimeMillis()); + book.setPages(100 + (int)(Math.random() * 400)); + + library.addToBooks(book); + + System.out.println("[" + replicaName + "] ✓ ADD: Created and added '" + title + "'"); + System.out.println(" → This triggers CREATE + ADD operations broadcast to all replicas"); + } + + /** + * TEST: REMOVE operation - removes a book from the library + */ + private static void testRemoveOperation(String title) { + if (title.isEmpty()) { + System.out.println("Usage: remove "); + return; + } + + Book book = library.getBook(title); + if (book != null) { + library.removeFromBooks(book); + System.out.println("[" + replicaName + "] ✓ REMOVE: Removed '" + title + "'"); + System.out.println(" → This triggers REMOVE operation broadcast to all replicas"); + } else { + System.out.println("[" + replicaName + "] ✗ Book not found: " + title); + System.out.println(" Available books:"); + for (Book b : library.getBooks()) { + System.out.println(" - " + b.getTitle()); + } + } + } + + /** + * TEST: SET operation - modifies a book's property + */ + private static void testSetOperation(String args) { + // Format: set <property> <value> + // Example: set MyBook pages 500 + String[] parts = args.split("\\s+", 3); + if (parts.length < 3) { + System.out.println("Usage: set <book title> <property> <value>"); + System.out.println(" Properties: title, isbn, pages"); + System.out.println(" Example: set MyBook pages 500"); + return; + } + + String title = parts[0]; + String property = parts[1].toLowerCase(); + String value = parts[2]; + + Book book = library.getBook(title); + if (book == null) { + System.out.println("[" + replicaName + "] ✗ Book not found: " + title); + return; + } + + switch (property) { + case "title": + book.setTitle(value); + System.out.println("[" + replicaName + "] ✓ SET: Changed title to '" + value + "'"); + break; + case "isbn": + book.setISBN(value); + System.out.println("[" + replicaName + "] ✓ SET: Changed ISBN to '" + value + "'"); + break; + case "pages": + book.setPages(Integer.parseInt(value)); + System.out.println("[" + replicaName + "] ✓ SET: Changed pages to " + value); + break; + default: + System.out.println("Unknown property: " + property); + return; + } + System.out.println(" → This triggers SET operation broadcast to all replicas"); + } + + /** + * TEST: REINDEX operation - moves a book to a different position + */ + private static void testMoveOperation(String args) { + // Format: move <title> <new index> + String[] parts = args.split("\\s+", 2); + if (parts.length < 2) { + System.out.println("Usage: move <book title> <new index>"); + System.out.println(" Example: move MyBook 0 (moves to first position)"); + return; + } + + String title = parts[0]; + int newIndex = Integer.parseInt(parts[1]); + + Book book = library.getBook(title); + if (book == null) { + System.out.println("[" + replicaName + "] ✗ Book not found: " + title); + return; + } + + library.moveBookToIndex(book, newIndex); + System.out.println("[" + replicaName + "] ✓ MOVE: Moved '" + title + "' to index " + newIndex); + System.out.println(" → This triggers REINDEX operation broadcast to all replicas"); + } + + /** + * Run a full demo of all features + */ + private static void runFullDemo() throws InterruptedException { + System.out.println(); + System.out.println("╔═══════════════════════════════════════════════════════════════╗"); + System.out.println("║ RUNNING FULL DISTRIBUTED FEATURES DEMO ║"); + System.out.println("╚═══════════════════════════════════════════════════════════════╝"); + System.out.println(); + + // 1. ADD - Create and add books + System.out.println("▶ STEP 1: Testing ADD operation (CREATE + ADD)"); + testAddOperation("Demo-Book-1"); + Thread.sleep(500); + testAddOperation("Demo-Book-2"); + Thread.sleep(500); + testAddOperation("Demo-Book-3"); + Thread.sleep(1000); + printLibrary(); + + // 2. SET - Modify properties + System.out.println(); + System.out.println("▶ STEP 2: Testing SET operation"); + testSetOperation("Demo-Book-1 pages 999"); + Thread.sleep(500); + testSetOperation("Demo-Book-2 isbn DEMO-ISBN-123"); + Thread.sleep(1000); + printLibrary(); + + // 3. MOVE - Reorder + System.out.println(); + System.out.println("▶ STEP 3: Testing REINDEX/MOVE operation"); + testMoveOperation("Demo-Book-3 0"); + Thread.sleep(1000); + printLibrary(); + + // 4. REMOVE - Delete + System.out.println(); + System.out.println("▶ STEP 4: Testing REMOVE operation"); + testRemoveOperation("Demo-Book-2"); + Thread.sleep(1000); + printLibrary(); + + System.out.println(); + System.out.println("╔═══════════════════════════════════════════════════════════════╗"); + System.out.println("║ DEMO COMPLETE! ║"); + System.out.println("║ ║"); + System.out.println("║ If you have another replica connected, it should now have ║"); + System.out.println("║ received all these operations in real-time! ║"); + System.out.println("║ ║"); + System.out.println("║ Try 'state' command on a new replica to test STATE_SYNC ║"); + System.out.println("╚═══════════════════════════════════════════════════════════════╝"); + System.out.println(); + } + + private static void printLibrary() { + System.out.println(); + System.out.println("┌─────────────────────────────────────────────────────────────────┐"); + System.out.println("│ LIBRARY CONTENTS (" + library.getBooks().size() + " books)"); + System.out.println("├─────────────────────────────────────────────────────────────────┤"); + + int idx = 0; + for (Book book : library.getBooks()) { + System.out.printf("│ [%d] %-20s ISBN: %-20s Pages: %d%n", + idx++, + truncate(book.getTitle(), 20), + truncate(book.getISBN(), 20), + book.getPages()); + } + + if (library.getBooks().isEmpty()) { + System.out.println("│ (empty) │"); + } + + System.out.println("└─────────────────────────────────────────────────────────────────┘"); + System.out.println(); + } + + private static void printInfo(RabbitMQSyncManager syncManager) { + System.out.println(); + System.out.println("┌─────────────────────────────────────────────────────────────────┐"); + System.out.println("│ SYNC INFO │"); + System.out.println("├─────────────────────────────────────────────────────────────────┤"); + System.out.println("│ Replica Name: " + replicaName); + System.out.println("│ Replica ID: " + syncManager.getReplicaId()); + System.out.println("│ Connected: " + syncManager.isConnected()); + System.out.println("│ State Recv: " + syncContext.isStateReceived()); + System.out.println("│ Objects: " + syncContext.getIdentityManager().size()); + System.out.println("│ Vector Clock: " + syncManager.getVectorClock()); + System.out.println("└─────────────────────────────────────────────────────────────────┘"); + System.out.println(); + } + + private static void printHelp() { + System.out.println(); + System.out.println("╔═══════════════════════════════════════════════════════════════╗"); + System.out.println("║ DISTRIBUTED PAMELA COMMANDS ║"); + System.out.println("╠═══════════════════════════════════════════════════════════════╣"); + System.out.println("║ add [title] - Add a new book (tests ADD + CREATE) ║"); + System.out.println("║ remove <title> - Remove a book (tests REMOVE) ║"); + System.out.println("║ set <title> <prop> <val> - Modify property (tests SET) ║"); + System.out.println("║ move <title> <index> - Reorder book (tests REINDEX) ║"); + System.out.println("║ list - Show all books ║"); + System.out.println("║ state - Request state from replicas ║"); + System.out.println("║ info - Show sync information ║"); + System.out.println("║ demo - Run full feature demonstration ║"); + System.out.println("║ quit - Exit ║"); + System.out.println("╚═══════════════════════════════════════════════════════════════╝"); + System.out.println(); + } + + private static void printBanner() { + System.out.println(); + System.out.println("╔═══════════════════════════════════════════════════════════════════════╗"); + System.out.println("║ ║"); + System.out.println("║ ██████╗ █████╗ ███╗ ███╗███████╗██╗ █████╗ ║"); + System.out.println("║ ██╔══██╗██╔══██╗████╗ ████║██╔════╝██║ ██╔══██╗ ║"); + System.out.println("║ ██████╔╝███████║██╔████╔██║█████╗ ██║ ███████║ ║"); + System.out.println("║ ██╔═══╝ ██╔══██║██║╚██╔╝██║██╔══╝ ██║ ██╔══██║ ║"); + System.out.println("║ ██║ ██║ ██║██║ ╚═╝ ██║███████╗███████╗██║ ██║ ║"); + System.out.println("║ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝╚═╝ ╚═╝ ║"); + System.out.println("║ ║"); + System.out.println("║ D I S T R I B U T E D S Y N C D E M O ║"); + System.out.println("║ ║"); + System.out.println("║ Testing: STATE_SYNC | CREATE | SET | ADD | REMOVE | REINDEX ║"); + System.out.println("║ ║"); + System.out.println("╚═══════════════════════════════════════════════════════════════════════╝"); + System.out.println(); + } + + private static SyncOperationListener createOperationListener() { + return new SyncOperationListener() { + @Override + public void onOperationReceived(SyncOperation operation) { + System.out.println(); + System.out.println("[" + replicaName + "] 📥 RECEIVED: " + operation.getOperationType()); + System.out.println(" Object: " + operation.getObjectId().substring(0, 8) + "..."); + if (operation.getPropertyIdentifier() != null) { + System.out.println(" Property: " + operation.getPropertyIdentifier()); + } + if (operation.getNewValueSerialized() != null) { + System.out.println(" Value: " + truncate(operation.getNewValueSerialized(), 40)); + } + System.out.println(" From: " + operation.getReplicaId().substring(0, 8) + "..."); + System.out.print("[" + replicaName + "] > "); + } + + @Override + public void onStateRequested(String requestingReplicaId) { + System.out.println(); + System.out.println("[" + replicaName + "] 📡 STATE_REQUEST received from: " + + requestingReplicaId.substring(0, 8) + "..."); + System.out.println(" → Sending current state with " + library.getBooks().size() + " books"); + System.out.print("[" + replicaName + "] > "); + } + + @Override + public void onStateReceived(String stateSnapshot, String fromReplicaId) { + System.out.println(); + System.out.println("[" + replicaName + "] 📥 STATE_RESPONSE received from: " + + fromReplicaId.substring(0, 8) + "..."); + System.out.println(" → Restoring state..."); + System.out.print("[" + replicaName + "] > "); + } + + @Override + public void onConnected() { + System.out.println("[" + replicaName + "] 🔗 Connected to sync network"); + } + + @Override + public void onDisconnected(String reason) { + System.out.println("[" + replicaName + "] ⚠️ Disconnected: " + reason); + } + + @Override + public void onError(Throwable error) { + System.out.println("[" + replicaName + "] ❌ Error: " + error.getMessage()); + } + }; + } + + private static String truncate(String str, int maxLen) { + if (str == null) return ""; + if (str.length() <= maxLen) return str; + return str.substring(0, maxLen - 3) + "..."; + } +} diff --git a/book/src/main/java/org/openflexo/testPamela/ReplicaA.java b/book/src/main/java/org/openflexo/testPamela/ReplicaA.java new file mode 100644 index 00000000..d0385abf --- /dev/null +++ b/book/src/main/java/org/openflexo/testPamela/ReplicaA.java @@ -0,0 +1,133 @@ +/* + * Replica A - Collaborative Library Example + * + * This application demonstrates PAMELA distributed synchronization. + * Run this first, then start ReplicaB and enter the Library ID. + * After ReplicaB is connected, add books and see them sync! + * + * Prerequisites: + * docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3-management + */ +package org.openflexo.testPamela; + +import org.openflexo.pamela.PamelaMetaModel; +import org.openflexo.pamela.PamelaMetaModelLibrary; +import org.openflexo.pamela.factory.PamelaModelFactory; +import org.openflexo.pamela.sync.RabbitMQSyncManager; +import org.openflexo.pamela.sync.SyncEditingContext; +import org.openflexo.pamela.sync.SyncOperation; +import org.openflexo.pamela.sync.SyncOperationListener; +import org.openflexo.testPamela.model.Book; +import org.openflexo.testPamela.model.Library; + +import java.util.Scanner; + +public class ReplicaA { + + private static Library library; + private static PamelaModelFactory factory; + + public static void main(String[] args) throws Exception { + System.out.println("╔════════════════════════════════════════════════════════════╗"); + System.out.println("║ PAMELA Distributed Library - REPLICA A ║"); + System.out.println("╚════════════════════════════════════════════════════════════╝"); + System.out.println(); + + // Instantiate the meta-model + PamelaMetaModel pamelaMetaModel = PamelaMetaModelLibrary.retrieveMetaModel(Library.class); + + // Instantiate the factory with sync context + factory = new PamelaModelFactory(pamelaMetaModel); + SyncEditingContext syncContext = new SyncEditingContext(factory); + factory.setEditingContext(syncContext); + + // Configure RabbitMQ sync manager (CloudAMQP) + System.out.println("[A] Connecting to CloudAMQP..."); + RabbitMQSyncManager syncManager = RabbitMQSyncManager.builder() + .host("rat.rmq2.cloudamqp.com") + .port(5671) + .credentials("gcyabtej", "C91PisA-dAYuoVTxHRnzU1RCU1fERHeU") + .virtualHost("gcyabtej") + .useSsl(true) + .exchangeName("pamela-library-sync") + .build(); + + syncContext.setSyncManager(syncManager); + syncManager.addListener(syncContext); + + // Add a listener to show received operations from ReplicaB + syncManager.addListener(new SyncOperationListener() { + @Override + public void onOperationReceived(SyncOperation operation) { + System.out.println(); + System.out.println("[A] 📥 Received from remote: " + operation.getOperationType() + + " on " + operation.getPropertyIdentifier() + + " value=" + operation.getNewValueSerialized()); + System.out.println("[A] Current library books: " + library.getBooks().size()); + System.out.print("[A] > "); + } + }); + + try { + syncManager.connect(); + System.out.println("[A] ✓ Connected to RabbitMQ as replica: " + syncManager.getReplicaId().substring(0, 8)); + } catch (Exception e) { + System.err.println("[A] ✗ Failed to connect to RabbitMQ: " + e.getMessage()); + System.err.println("[A] Make sure RabbitMQ is running: docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3-management"); + return; + } + + // Create a library (no initial books - wait for ReplicaB to connect first!) + library = factory.newInstance(Library.class); + String libraryId = syncContext.getIdentityManager().getOrCreateObjectId(library); + System.out.println(); + System.out.println("[A] Created Library with ID: " + libraryId); + System.out.println(" ⚠️ COPY THIS ID to use in ReplicaB!"); + System.out.println(); + System.out.println(" ➡️ Start ReplicaB now, enter the ID, then add books here!"); + System.out.println(); + System.out.println("════════════════════════════════════════════════════════════════"); + System.out.println("Commands: 'add <title>' | 'remove <title>' | 'list' | 'quit'"); + System.out.println("════════════════════════════════════════════════════════════════"); + + // Interactive mode + Scanner scanner = new Scanner(System.in); + while (true) { + System.out.print("[A] > "); + String input = scanner.nextLine().trim(); + + if (input.equalsIgnoreCase("quit") || input.equalsIgnoreCase("exit")) { + break; + } else if (input.equalsIgnoreCase("list")) { + System.out.println("[A] Library: " + library); + System.out.println("[A] Books count: " + library.getBooks().size()); + for (Book b : library.getBooks()) { + System.out.println(" - " + b.getTitle() + " (ISBN: " + b.getISBN() + ")"); + } + } else if (input.toLowerCase().startsWith("add ")) { + String title = input.substring(4).trim(); + if (!title.isEmpty()) { + Book newBook = factory.newInstance(Book.class, title); + newBook.setISBN("A-" + System.currentTimeMillis()); + library.addToBooks(newBook); + System.out.println("[A] ✓ Added: " + newBook.getTitle() + " (broadcasting to replicas...)"); + } + } else if (input.toLowerCase().startsWith("remove ")) { + String title = input.substring(7).trim(); + Book bookToRemove = library.getBook(title); + if (bookToRemove != null) { + library.removeFromBooks(bookToRemove); + System.out.println("[A] ✓ Removed: " + title); + } else { + System.out.println("[A] ✗ Book not found: " + title); + } + } else if (!input.isEmpty()) { + System.out.println("[A] Unknown command. Use: add <title>, remove <title>, list, quit"); + } + } + + System.out.println("[A] Disconnecting..."); + syncManager.disconnect(); + System.out.println("[A] Goodbye!"); + } +} diff --git a/book/src/main/java/org/openflexo/testPamela/ReplicaB.java b/book/src/main/java/org/openflexo/testPamela/ReplicaB.java new file mode 100644 index 00000000..0e62fada --- /dev/null +++ b/book/src/main/java/org/openflexo/testPamela/ReplicaB.java @@ -0,0 +1,156 @@ +/* + * Replica B - Collaborative Library Example + * + * This application demonstrates PAMELA distributed synchronization. + * Run this AFTER ReplicaA to receive synchronized updates. + * + * Prerequisites: + * 1. RabbitMQ running: docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3-management + * 2. ReplicaA running and note the Library ID + */ +package org.openflexo.testPamela; + +import org.openflexo.pamela.PamelaMetaModel; +import org.openflexo.pamela.PamelaMetaModelLibrary; +import org.openflexo.pamela.exceptions.ModelDefinitionException; +import org.openflexo.pamela.factory.PamelaModelFactory; +import org.openflexo.pamela.sync.RabbitMQSyncManager; +import org.openflexo.pamela.sync.SyncEditingContext; +import org.openflexo.pamela.sync.SyncOperation; +import org.openflexo.pamela.sync.SyncOperationListener; +import org.openflexo.testPamela.model.Book; +import org.openflexo.testPamela.model.Library; + +import java.util.Scanner; + +public class ReplicaB { + + private static Library library; + private static SyncEditingContext syncContext; + private static PamelaModelFactory factory; + + public static void main(String[] args) throws Exception { + System.out.println("╔════════════════════════════════════════════════════════════╗"); + System.out.println("║ PAMELA Distributed Library - REPLICA B ║"); + System.out.println("╚════════════════════════════════════════════════════════════╝"); + System.out.println(); + + // Instantiate the meta-model + PamelaMetaModel pamelaMetaModel = PamelaMetaModelLibrary.retrieveMetaModel(Library.class); + + // Instantiate the factory with sync context + factory = new PamelaModelFactory(pamelaMetaModel); + syncContext = new SyncEditingContext(factory); + factory.setEditingContext(syncContext); + + // Configure RabbitMQ sync manager (CloudAMQP) + System.out.println("[B] Connecting to CloudAMQP..."); + RabbitMQSyncManager syncManager = RabbitMQSyncManager.builder() + .host("rat.rmq2.cloudamqp.com") + .port(5671) + .credentials("gcyabtej", "C91PisA-dAYuoVTxHRnzU1RCU1fERHeU") + .virtualHost("gcyabtej") + .useSsl(true) + .exchangeName("pamela-library-sync") + .build(); + + syncContext.setSyncManager(syncManager); + syncManager.addListener(syncContext); + + // Add a listener to show received operations + syncManager.addListener(new SyncOperationListener() { + @Override + public void onOperationReceived(SyncOperation operation) { + System.out.println(); + System.out.println("[B] 📥 Received: " + operation.getOperationType() + + " on " + operation.getPropertyIdentifier() + + " = " + operation.getNewValueSerialized()); + System.out.println("[B] Current library: " + library); + System.out.print("[B] > "); + } + }); + + try { + syncManager.connect(); + System.out.println("[B] ✓ Connected to RabbitMQ as replica: " + syncManager.getReplicaId().substring(0, 8)); + } catch (Exception e) { + System.err.println("[B] ✗ Failed to connect to RabbitMQ: " + e.getMessage()); + System.err.println("[B] Make sure RabbitMQ is running!"); + return; + } + + System.out.println(); + System.out.println("[B] Waiting for Library ID from ReplicaA..."); + System.out.print("[B] Enter Library ID (from ReplicaA): "); + + Scanner scanner = new Scanner(System.in); + String libraryId = scanner.nextLine().trim(); + + if (libraryId.isEmpty()) { + // Create a new library if no ID provided + library = factory.newInstance(Library.class); + libraryId = syncContext.getIdentityManager().getOrCreateObjectId(library); + System.out.println("[B] Created new Library with ID: " + libraryId); + } else { + // Create a local library and register with the same ID + library = factory.newInstance(Library.class); + syncContext.getIdentityManager().registerObject(library, libraryId); + System.out.println("[B] ✓ Registered local Library with shared ID: " + libraryId); + + // Request state from other replicas to get existing books + System.out.println("[B] 📡 Requesting state from other replicas..."); + syncContext.requestStateSync(); + + // Wait a moment for state to arrive + Thread.sleep(2000); + System.out.println("[B] ✓ State sync complete. Books received: " + library.getBooks().size()); + } + + System.out.println(); + System.out.println("[B] Library state: " + library); + System.out.println(); + System.out.println("════════════════════════════════════════════════════════════════"); + System.out.println("Commands: 'add <title>' | 'remove <title>' | 'list' | 'quit'"); + System.out.println("Waiting for updates from ReplicaA..."); + System.out.println("════════════════════════════════════════════════════════════════"); + + // Interactive mode + while (true) { + System.out.print("[B] > "); + String input = scanner.nextLine().trim(); + + if (input.equalsIgnoreCase("quit") || input.equalsIgnoreCase("exit")) { + break; + } else if (input.equalsIgnoreCase("list")) { + System.out.println("[B] Library: " + library); + System.out.println("[B] Books count: " + library.getBooks().size()); + for (Book b : library.getBooks()) { + System.out.println(" - " + b.getTitle() + " (ISBN: " + b.getISBN() + ")"); + } + } else if (input.toLowerCase().startsWith("add ")) { + String title = input.substring(4).trim(); + if (!title.isEmpty()) { + Book newBook = factory.newInstance(Book.class, title); + newBook.setISBN("NEW-B-" + System.currentTimeMillis()); + library.addToBooks(newBook); + System.out.println("[B] ✓ Added: " + newBook); + } + } else if (input.toLowerCase().startsWith("remove ")) { + String title = input.substring(7).trim(); + Book bookToRemove = library.getBook(title); + if (bookToRemove != null) { + library.removeFromBooks(bookToRemove); + System.out.println("[B] ✓ Removed: " + title); + } else { + System.out.println("[B] ✗ Book not found: " + title); + } + } else if (!input.isEmpty()) { + System.out.println("[B] Unknown command. Use: add <title>, remove <title>, list, quit"); + } + } + + System.out.println("[B] Disconnecting..."); + syncManager.disconnect(); + System.out.println("[B] Goodbye!"); + } +} diff --git a/book/src/main/java/org/openflexo/testPamela/ReplicaC.java b/book/src/main/java/org/openflexo/testPamela/ReplicaC.java new file mode 100644 index 00000000..c95c014d --- /dev/null +++ b/book/src/main/java/org/openflexo/testPamela/ReplicaC.java @@ -0,0 +1,148 @@ +/* + * Replica C - Collaborative Library Example + * + * This application demonstrates PAMELA distributed synchronization. + * Run this AFTER ReplicaA to receive synchronized updates (3rd replica). + * + * Prerequisites: + * 1. RabbitMQ running: docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3-management + * 2. ReplicaA running and note the Library ID + */ +package org.openflexo.testPamela; + +import org.openflexo.pamela.PamelaMetaModel; +import org.openflexo.pamela.PamelaMetaModelLibrary; +import org.openflexo.pamela.exceptions.ModelDefinitionException; +import org.openflexo.pamela.factory.PamelaModelFactory; +import org.openflexo.pamela.sync.RabbitMQSyncManager; +import org.openflexo.pamela.sync.SyncEditingContext; +import org.openflexo.pamela.sync.SyncOperation; +import org.openflexo.pamela.sync.SyncOperationListener; +import org.openflexo.testPamela.model.Book; +import org.openflexo.testPamela.model.Library; + +import java.util.Scanner; + +public class ReplicaC { + + private static Library library; + private static SyncEditingContext syncContext; + private static PamelaModelFactory factory; + + public static void main(String[] args) throws Exception { + System.out.println("╔════════════════════════════════════════════════════════════╗"); + System.out.println("║ PAMELA Distributed Library - REPLICA C ║"); + System.out.println("╚════════════════════════════════════════════════════════════╝"); + System.out.println(); + + // Instantiate the meta-model + PamelaMetaModel pamelaMetaModel = PamelaMetaModelLibrary.retrieveMetaModel(Library.class); + + // Instantiate the factory with sync context + factory = new PamelaModelFactory(pamelaMetaModel); + syncContext = new SyncEditingContext(factory); + factory.setEditingContext(syncContext); + + // Configure RabbitMQ sync manager (CloudAMQP) + System.out.println("[C] Connecting to CloudAMQP..."); + RabbitMQSyncManager syncManager = RabbitMQSyncManager.builder() + .host("rat.rmq2.cloudamqp.com") + .port(5671) + .credentials("gcyabtej", "C91PisA-dAYuoVTxHRnzU1RCU1fERHeU") + .virtualHost("gcyabtej") + .useSsl(true) + .exchangeName("pamela-library-sync") + .build(); + + syncContext.setSyncManager(syncManager); + syncManager.addListener(syncContext); + + // Add a listener to show received operations + syncManager.addListener(new SyncOperationListener() { + @Override + public void onOperationReceived(SyncOperation operation) { + System.out.println(); + System.out.println("[C] 📥 Received: " + operation.getOperationType() + + " on " + operation.getPropertyIdentifier() + + " = " + operation.getNewValueSerialized()); + System.out.println("[C] Current library: " + library); + System.out.print("[C] > "); + } + }); + + try { + syncManager.connect(); + System.out.println("[C] ✓ Connected to RabbitMQ as replica: " + syncManager.getReplicaId().substring(0, 8)); + } catch (Exception e) { + System.err.println("[C] ✗ Failed to connect to RabbitMQ: " + e.getMessage()); + System.err.println("[C] Make sure RabbitMQ is running!"); + return; + } + + System.out.println(); + System.out.println("[C] Waiting for Library ID from ReplicaA..."); + System.out.print("[C] Enter Library ID (from ReplicaA): "); + + Scanner scanner = new Scanner(System.in); + String libraryId = scanner.nextLine().trim(); + + if (libraryId.isEmpty()) { + // Create a new library if no ID provided + library = factory.newInstance(Library.class); + libraryId = syncContext.getIdentityManager().getOrCreateObjectId(library); + System.out.println("[C] Created new Library with ID: " + libraryId); + } else { + // Create a local library and register with the same ID + library = factory.newInstance(Library.class); + syncContext.getIdentityManager().registerObject(library, libraryId); + System.out.println("[C] ✓ Registered local Library with shared ID: " + libraryId); + } + + System.out.println(); + System.out.println("[C] Library state: " + library); + System.out.println(); + System.out.println("════════════════════════════════════════════════════════════════"); + System.out.println("Commands: 'add <title>' | 'remove <title>' | 'list' | 'quit'"); + System.out.println("Waiting for updates from ReplicaA..."); + System.out.println("════════════════════════════════════════════════════════════════"); + + // Interactive mode + while (true) { + System.out.print("[C] > "); + String input = scanner.nextLine().trim(); + + if (input.equalsIgnoreCase("quit") || input.equalsIgnoreCase("exit")) { + break; + } else if (input.equalsIgnoreCase("list")) { + System.out.println("[C] Library: " + library); + System.out.println("[C] Books count: " + library.getBooks().size()); + for (Book b : library.getBooks()) { + System.out.println(" - " + b.getTitle() + " (ISBN: " + b.getISBN() + ")"); + } + } else if (input.toLowerCase().startsWith("add ")) { + String title = input.substring(4).trim(); + if (!title.isEmpty()) { + Book newBook = factory.newInstance(Book.class, title); + newBook.setISBN("NEW-C-" + System.currentTimeMillis()); + library.addToBooks(newBook); + System.out.println("[C] ✓ Added: " + newBook); + } + } else if (input.toLowerCase().startsWith("remove ")) { + String title = input.substring(7).trim(); + Book bookToRemove = library.getBook(title); + if (bookToRemove != null) { + library.removeFromBooks(bookToRemove); + System.out.println("[C] ✓ Removed: " + title); + } else { + System.out.println("[C] ✗ Book not found: " + title); + } + } else if (!input.isEmpty()) { + System.out.println("[C] Unknown command. Use: add <title>, remove <title>, list, quit"); + } + } + + System.out.println("[C] Disconnecting..."); + syncManager.disconnect(); + System.out.println("[C] Goodbye!"); + } +} diff --git a/book/src/main/java/org/openflexo/testPamela/model/Book.java b/book/src/main/java/org/openflexo/testPamela/model/Book.java new file mode 100644 index 00000000..c140cc6a --- /dev/null +++ b/book/src/main/java/org/openflexo/testPamela/model/Book.java @@ -0,0 +1,74 @@ +/* + * A book pamela model + */ +package org.openflexo.testPamela.model; + +import org.openflexo.pamela.annotations.*; +import org.checkerframework.checker.units.qual.s; +import org.openflexo.pamela.AccessibleProxyObject; + +@ModelEntity +@Imports({@Import(org.openflexo.testPamela.model.Novel.class), @Import(org.openflexo.testPamela.model.Journal.class)}) +@ImplementationClass(Book.BookImpl.class) +public interface Book extends AccessibleProxyObject { + + static final String TITLE = "title"; + static final String ISBN = "ISBN"; + static final String PAGES = "pages"; + + @Initializer + Book init(@Parameter(TITLE)String aTitle); + + @Getter(TITLE) + String getTitle(); + + @Setter(TITLE) + void setTitle(String aTitle); + + @Getter(ISBN) + String getISBN(); + + @Setter(ISBN) + void setISBN(String value); + + @Getter(PAGES) + Integer getPages(); + + @Setter(PAGES) + void setPages(Integer value); + + boolean isCorrect(); + + // Provides a partial implementation for Book + static abstract class BookImpl implements Book { + @Override + public String getISBN() { + String isbn = (String) performSuperGetter(ISBN); + if (isbn == null) { + return "Unknown"; + } + return isbn; + } + + @Override + public Integer getPages() { + Integer pages = (Integer) performSuperGetter(PAGES); + if (pages == null) { + return 100; + } + return pages; + } + + @Override + public String toString() { + String title = getTitle(); + String isbn = getISBN(); + Integer pages = getPages(); + return "Book(" + title + "," + isbn + "," + pages + "," + getClass().getSimpleName() + ")"; + } + + public boolean isCorrect() { + return getTitle() != null && getISBN() != null; + } + } +} diff --git a/book/src/main/java/org/openflexo/testPamela/model/Journal.java b/book/src/main/java/org/openflexo/testPamela/model/Journal.java new file mode 100644 index 00000000..152dcb49 --- /dev/null +++ b/book/src/main/java/org/openflexo/testPamela/model/Journal.java @@ -0,0 +1,10 @@ +/* + * A book pamela model + */ +package org.openflexo.testPamela.model; + +import org.openflexo.pamela.annotations.*; + +@ModelEntity +public interface Journal extends Book { +} diff --git a/book/src/main/java/org/openflexo/testPamela/model/Library.java b/book/src/main/java/org/openflexo/testPamela/model/Library.java new file mode 100644 index 00000000..74f1c9cc --- /dev/null +++ b/book/src/main/java/org/openflexo/testPamela/model/Library.java @@ -0,0 +1,64 @@ +/* + * A book pamela model + */ +package org.openflexo.testPamela.model; + +import org.openflexo.pamela.annotations.*; +import org.openflexo.pamela.annotations.Getter.Cardinality; +import org.openflexo.pamela.AccessibleProxyObject; + +import java.io.File; +import java.io.FileFilter; +import java.util.Iterator; +import java.util.List; + +@ModelEntity +@ImplementationClass(Library.LibraryImpl.class) +public interface Library extends AccessibleProxyObject, Iterable<Book> { + + static String BOOKS = "books"; + static String FILE = "file"; + + @Getter(value = BOOKS, cardinality = Cardinality.LIST) + List<Book> getBooks(); + + @Getter(value = FILE, ignoreType = true) + FileFilter getFile(); + + @Setter(FILE) + void setFile(FileFilter file); + + @Adder(BOOKS) + void addToBooks(Book aBook); + + @Remover(BOOKS) + void removeFromBooks(Book aBook); + + @Reindexer(BOOKS) + void moveBookToIndex(Book aBook, int index); + + @Finder(collection = BOOKS, attribute = "title") + Book getBook(String title); + + // Provides a partial implementation for Book + static abstract class LibraryImpl implements Library { + @Override + public String toString() { + StringBuilder str = new StringBuilder("["); + boolean first =true; + for (Book b : getBooks()) { + if (!first) { + str.append(","); + } + str.append(b); + } + str.append("]"); + return str.toString(); + } + + @Override + public Iterator<Book> iterator() { + return getBooks().iterator(); + } + } +} diff --git a/book/src/main/java/org/openflexo/testPamela/model/Novel.java b/book/src/main/java/org/openflexo/testPamela/model/Novel.java new file mode 100644 index 00000000..99271fd3 --- /dev/null +++ b/book/src/main/java/org/openflexo/testPamela/model/Novel.java @@ -0,0 +1,10 @@ +/* + * A book pamela model + */ +package org.openflexo.testPamela.model; + +import org.openflexo.pamela.annotations.*; + +@ModelEntity +public interface Novel extends Book { +} diff --git a/book/src/test/java/org/openflexo/testPamela/AppTest.java b/book/src/test/java/org/openflexo/testPamela/AppTest.java new file mode 100644 index 00000000..968269db --- /dev/null +++ b/book/src/test/java/org/openflexo/testPamela/AppTest.java @@ -0,0 +1,38 @@ +/* + * This Java source file was generated by the Gradle 'init' task. + */ +package org.openflexo.testPamela; + +import org.junit.Test; +import static org.junit.Assert.*; +import org.openflexo.testPamela.model.Book; +import org.openflexo.pamela.exceptions.ModelDefinitionException; + +public class AppTest { + + @Test + public void testLord() throws ModelDefinitionException { + App classUnderTest = new App(); + boolean found = false; + for (Book b : classUnderTest.books) { + if (b.getTitle().equals("Lord of the ring")) { + found = true; + break; + } + } + assertTrue("app should have 'Lord of the ring'", found); + } + + @Test + public void testOther() throws ModelDefinitionException { + App classUnderTest = new App(); + boolean found = false; + for (Book b : classUnderTest.books) { + if (b.getTitle().equals("God of the ring")) { + found = true; + break; + } + } + assertFalse("app should not have 'God of the ring'", found); + } +} diff --git a/pamela-core/build.gradle b/pamela-core/build.gradle index 5090131e..e2e5dcde 100644 --- a/pamela-core/build.gradle +++ b/pamela-core/build.gradle @@ -6,6 +6,11 @@ dependencies { // Lots of test errors when using 3.30.2 // Error when using 3.23 (see below) + // RabbitMQ for collaborative synchronization + api group: 'com.rabbitmq', name: 'amqp-client', version: '5.20.0' + + // Jackson for JSON serialization of sync operations + api group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.16.0' } // /Users/fdagnat/git/pamela/pamela-core/src/main/java/org/openflexo/pamela/factory/ModelFactory.java:399: error: incompatible types: Class<CAP#1> cannot be converted to Class<? extends I> // return proxyFactory.getSuperclass(); diff --git a/pamela-core/src/main/java/org/openflexo/pamela/factory/DelegateImplementation.java b/pamela-core/src/main/java/org/openflexo/pamela/factory/DelegateImplementation.java index f9a05207..6044cb9b 100644 --- a/pamela-core/src/main/java/org/openflexo/pamela/factory/DelegateImplementation.java +++ b/pamela-core/src/main/java/org/openflexo/pamela/factory/DelegateImplementation.java @@ -149,20 +149,20 @@ public Object invoke(Object self, Method method, Method proceed, Object[] args) if (getUndoManager() != null) { if (oldValue != args[0]) { getUndoManager() - .addEdit(new SetCommand<>(masterObject, getModelEntity(), property, oldValue, args[0], getModelFactory())); + .addEdit(new SetCommand<>(masterObject, getModelEntity(), property, oldValue, args[0], getModelFactory(), getCurrentReplicaId())); } } } if (PamelaUtils.methodIsEquivalentTo(method, property.getAdderMethod())) { // System.out.println("DETECTS ADD with " + proceed + " instead of " + method); if (getUndoManager() != null) { - getUndoManager().addEdit(new AddCommand<>(masterObject, getModelEntity(), property, args[0], getModelFactory())); + getUndoManager().addEdit(new AddCommand<>(masterObject, getModelEntity(), property, args[0], getModelFactory(), getCurrentReplicaId())); } } if (PamelaUtils.methodIsEquivalentTo(method, property.getRemoverMethod())) { // System.out.println("DETECTS REMOVE with " + proceed + " instead of " + method); if (getUndoManager() != null) { - getUndoManager().addEdit(new RemoveCommand<>(masterObject, getModelEntity(), property, args[0], getModelFactory())); + getUndoManager().addEdit(new RemoveCommand<>(masterObject, getModelEntity(), property, args[0], getModelFactory(), getCurrentReplicaId())); } } } @@ -249,4 +249,17 @@ public UndoManager getUndoManager() { return null; } + /** + * Get the current replica ID from the SyncEditingContext. + * Returns null if not in a sync context or if no SyncManager is configured. + */ + private String getCurrentReplicaId() { + EditingContext context = getModelFactory().getEditingContext(); + if (context instanceof org.openflexo.pamela.sync.SyncEditingContext) { + org.openflexo.pamela.sync.SyncEditingContext syncContext = (org.openflexo.pamela.sync.SyncEditingContext) context; + return syncContext.getReplicaId(); + } + return null; + } + } diff --git a/pamela-core/src/main/java/org/openflexo/pamela/factory/PamelaModelFactory.java b/pamela-core/src/main/java/org/openflexo/pamela/factory/PamelaModelFactory.java index 7a752950..ba1ee5d2 100644 --- a/pamela-core/src/main/java/org/openflexo/pamela/factory/PamelaModelFactory.java +++ b/pamela-core/src/main/java/org/openflexo/pamela/factory/PamelaModelFactory.java @@ -66,6 +66,7 @@ import org.openflexo.pamela.model.ModelProperty; import org.openflexo.pamela.model.StringConverterLibrary.Converter; import org.openflexo.pamela.undo.CreateCommand; +import org.openflexo.pamela.sync.SyncEditingContext; import org.openflexo.pamela.xml.XMLSaxDeserializer; import org.openflexo.pamela.xml.XMLSerializer; @@ -449,7 +450,27 @@ public <I> I newInstance(Class<I> implementedInterface, Object... args) { I returned = proxyFactory.newInstance(args); if (getEditingContext() != null) { if (getEditingContext().getUndoManager() != null) { - getEditingContext().getUndoManager().addEdit(new CreateCommand<>(returned, proxyFactory.getModelEntity(), this)); + getEditingContext().getUndoManager().addEdit(new CreateCommand<>(returned, proxyFactory.getModelEntity(), this, getCurrentReplicaId())); + } + // Broadcast CREATE operation for collaborative sync + if (getEditingContext() instanceof SyncEditingContext) { + SyncEditingContext syncContext = (SyncEditingContext) getEditingContext(); + if (!syncContext.isApplyingRemoteOperation()) { + ProxyMethodHandler<?> handler = getHandler(returned); + if (handler != null) { + handler.broadcastCreateOperation(); + } + } + } + // Broadcast CREATE operation for collaborative sync + if (getEditingContext() instanceof SyncEditingContext) { + SyncEditingContext syncContext = (SyncEditingContext) getEditingContext(); + if (!syncContext.isApplyingRemoteOperation()) { + ProxyMethodHandler<?> handler = getHandler(returned); + if (handler != null) { + handler.broadcastCreateOperation(); + } + } } } // this.getModelContext().getPatternContext().leavingConstructor(); @@ -489,7 +510,27 @@ public <I> I _newInstance(Class<I> implementedInterface, boolean useExtended, Ob I returned = proxyFactory.newInstance(args); if (getEditingContext() != null) { if (getEditingContext().getUndoManager() != null) { - getEditingContext().getUndoManager().addEdit(new CreateCommand<>(returned, proxyFactory.getModelEntity(), this)); + getEditingContext().getUndoManager().addEdit(new CreateCommand<>(returned, proxyFactory.getModelEntity(), this, getCurrentReplicaId())); + } + // Broadcast CREATE operation for collaborative sync + if (getEditingContext() instanceof SyncEditingContext) { + SyncEditingContext syncContext = (SyncEditingContext) getEditingContext(); + if (!syncContext.isApplyingRemoteOperation()) { + ProxyMethodHandler<?> handler = getHandler(returned); + if (handler != null) { + handler.broadcastCreateOperation(); + } + } + } + // Broadcast CREATE operation for collaborative sync + if (getEditingContext() instanceof SyncEditingContext) { + SyncEditingContext syncContext = (SyncEditingContext) getEditingContext(); + if (!syncContext.isApplyingRemoteOperation()) { + ProxyMethodHandler<?> handler = getHandler(returned); + if (handler != null) { + handler.broadcastCreateOperation(); + } + } } } return returned; @@ -1015,6 +1056,18 @@ public EditingContext getEditingContext() { return editingContext; } + /** + * Get the current replica ID from the SyncEditingContext. + * Returns null if not in a sync context or if no SyncManager is configured. + */ + private String getCurrentReplicaId() { + if (editingContext instanceof SyncEditingContext) { + SyncEditingContext syncContext = (SyncEditingContext) editingContext; + return syncContext.getReplicaId(); + } + return null; + } + /** * Sets {@link EditingContext} associated with this factory.<br> * When not null, new instances created with this factory are automatically registered in this EditingContext diff --git a/pamela-core/src/main/java/org/openflexo/pamela/factory/ProxyMethodHandler.java b/pamela-core/src/main/java/org/openflexo/pamela/factory/ProxyMethodHandler.java index 61c76901..c3003262 100644 --- a/pamela-core/src/main/java/org/openflexo/pamela/factory/ProxyMethodHandler.java +++ b/pamela-core/src/main/java/org/openflexo/pamela/factory/ProxyMethodHandler.java @@ -122,6 +122,7 @@ import org.openflexo.pamela.undo.RemoveCommand; import org.openflexo.pamela.undo.SetCommand; import org.openflexo.pamela.undo.UndoManager; +import org.openflexo.pamela.sync.SyncEditingContext; import org.openflexo.toolbox.HasPropertyChangeSupport; import com.google.common.base.Defaults; @@ -230,6 +231,19 @@ public UndoManager getUndoManager() { return null; } + /** + * Get the current replica ID from the SyncEditingContext. + * Returns null if not in a sync context or if no SyncManager is configured. + */ + private String getCurrentReplicaId() { + EditingContext context = getModelFactory().getEditingContext(); + if (context instanceof org.openflexo.pamela.sync.SyncEditingContext) { + org.openflexo.pamela.sync.SyncEditingContext syncContext = (org.openflexo.pamela.sync.SyncEditingContext) context; + return syncContext.getCurrentOperationReplicaId(); + } + return null; + } + public EditingContext getEditingContext() { return editingContext; } @@ -422,7 +436,7 @@ private Object _invoke(Object self, Method method, Method proceed, Object[] args if (getUndoManager() != null) { if (oldValue != args[0]) { getUndoManager().addEdit( - new SetCommand<>(getObject(), getModelEntity(), property, oldValue, args[0], getModelFactory())); + new SetCommand<>(getObject(), getModelEntity(), property, oldValue, args[0], getModelFactory(), getCurrentReplicaId())); } } if (property.isSerializable()) { @@ -434,7 +448,7 @@ private Object _invoke(Object self, Method method, Method proceed, Object[] args // We will invoke it, but also notify UndoManager, and call setModified() after adder invoking // System.out.println("DETECTS ADD with " + proceed + " instead of " + method); if (getUndoManager() != null) { - getUndoManager().addEdit(new AddCommand<>(getObject(), getModelEntity(), property, args[0], getModelFactory())); + getUndoManager().addEdit(new AddCommand<>(getObject(), getModelEntity(), property, args[0], getModelFactory(), getCurrentReplicaId())); } if (property.isSerializable()) { callSetModifiedAtTheEnd = true; @@ -445,7 +459,7 @@ private Object _invoke(Object self, Method method, Method proceed, Object[] args // We will invoke it, but also notify UndoManager, and call setModified() after remover invoking // System.out.println("DETECTS REMOVE with " + proceed + " instead of " + method); if (getUndoManager() != null) { - getUndoManager().addEdit(new RemoveCommand<>(getObject(), getModelEntity(), property, args[0], getModelFactory())); + getUndoManager().addEdit(new RemoveCommand<>(getObject(), getModelEntity(), property, args[0], getModelFactory(), getCurrentReplicaId())); } if (property.isSerializable()) { callSetModifiedAtTheEnd = true; @@ -1061,7 +1075,17 @@ else if (property.getCardinality() == Cardinality.LIST) { } if (trackAtomicEdit && getUndoManager() != null) { - getUndoManager().addEdit(new DeleteCommand<>(getObject(), getModelEntity(), getModelFactory())); + getUndoManager().addEdit(new DeleteCommand<>(getObject(), getModelEntity(), getModelFactory(), getCurrentReplicaId())); + } + + // Broadcast delete operation to other replicas + if (trackAtomicEdit) { + broadcastDeleteOperation(); + } + + // Broadcast delete operation to other replicas + if (trackAtomicEdit) { + broadcastDeleteOperation(); } deleted = true; @@ -1101,7 +1125,7 @@ protected boolean internallyInvokeUndeleter(boolean restoreProperties, boolean t undeleting = true; if (trackAtomicEdit && getUndoManager() != null) { - getUndoManager().addEdit(new CreateCommand<>(getObject(), getModelEntity(), getModelFactory())); + getUndoManager().addEdit(new CreateCommand<>(getObject(), getModelEntity(), getModelFactory(), getCurrentReplicaId())); } if (restoreProperties) { @@ -1415,10 +1439,15 @@ private <T> void internallyInvokeSetter(ModelProperty<? super I> property, Setta Object oldValue = invokeGetter(property); if (trackAtomicEdit && getUndoManager() != null) { if (oldValue != value) { - getUndoManager().addEdit(new SetCommand<>(getObject(), getModelEntity(), property, oldValue, value, getModelFactory())); + getUndoManager().addEdit(new SetCommand<>(getObject(), getModelEntity(), property, oldValue, value, getModelFactory(), getCurrentReplicaId())); } } propertyImplementation.set(value); + + // Broadcast sync operation if connected + if (trackAtomicEdit && oldValue != value) { + broadcastSetOperation(property, oldValue, value); + } } private <T> void internallyInvokeUpdater(ModelProperty<? super I> property, SettablePropertyImplementation<I, T> propertyImplementation, @@ -1426,7 +1455,7 @@ private <T> void internallyInvokeUpdater(ModelProperty<? super I> property, Sett if (trackAtomicEdit && getUndoManager() != null) { Object oldValue = invokeGetter(property); if (oldValue != value) { - getUndoManager().addEdit(new SetCommand<>(getObject(), getModelEntity(), property, oldValue, value, getModelFactory())); + getUndoManager().addEdit(new SetCommand<>(getObject(), getModelEntity(), property, oldValue, value, getModelFactory(), getCurrentReplicaId())); } } propertyImplementation.update(value); @@ -1436,18 +1465,28 @@ private <T> void internallyInvokeAdder(ModelProperty<? super I> property, Multip T value, int index, boolean trackAtomicEdit) throws ModelDefinitionException { // System.out.println("Invoke ADDER "+property.getPropertyIdentifier()); if (trackAtomicEdit && getUndoManager() != null) { - getUndoManager().addEdit(new AddCommand<>(getObject(), getModelEntity(), property, value, getModelFactory())); + getUndoManager().addEdit(new AddCommand<>(getObject(), getModelEntity(), property, value, getModelFactory(), getCurrentReplicaId())); } propertyImplementation.addTo(value, index); + + // Broadcast sync operation if connected + if (trackAtomicEdit) { + broadcastAddOperation(property, value, index); + } } private <T> void internallyInvokeRemover(ModelProperty<? super I> property, MultiplePropertyImplementation<I, T> propertyImplementation, T value, boolean trackAtomicEdit) throws ModelDefinitionException { // System.out.println("Invoke ADDER "+property.getPropertyIdentifier()); if (trackAtomicEdit && getUndoManager() != null) { - getUndoManager().addEdit(new RemoveCommand<>(getObject(), getModelEntity(), property, value, getModelFactory())); + getUndoManager().addEdit(new RemoveCommand<>(getObject(), getModelEntity(), property, value, getModelFactory(), getCurrentReplicaId())); } propertyImplementation.removeFrom(value); + + // Broadcast sync operation if connected + if (trackAtomicEdit) { + broadcastRemoveOperation(property, value); + } } private <T> void internallyInvokeReindexer(ModelProperty<? super I> property, @@ -1455,8 +1494,8 @@ private <T> void internallyInvokeReindexer(ModelProperty<? super I> property, throws ModelDefinitionException { // System.out.println("Invoke ADDER "+property.getPropertyIdentifier()); if (trackAtomicEdit && getUndoManager() != null) { - getUndoManager().addEdit(new RemoveCommand<>(getObject(), getModelEntity(), property, value, getModelFactory())); - getUndoManager().addEdit(new AddCommand<>(getObject(), getModelEntity(), property, value, getModelFactory())); + getUndoManager().addEdit(new RemoveCommand<>(getObject(), getModelEntity(), property, value, getModelFactory(), getCurrentReplicaId())); + getUndoManager().addEdit(new AddCommand<>(getObject(), getModelEntity(), property, value, getModelFactory(), getCurrentReplicaId())); } propertyImplementation.reindex(value, index); } @@ -2637,4 +2676,79 @@ private void checkOnExit(Method method, Object[] args) { } + // ======================================================================== + // Synchronization support methods for collaborative editing via RabbitMQ + // ======================================================================== + + /** + * Get the SyncEditingContext if available + * + * @return SyncEditingContext or null if not in sync mode + */ + private SyncEditingContext getSyncEditingContext() { + EditingContext ctx = getEditingContext(); + if (ctx instanceof SyncEditingContext) { + return (SyncEditingContext) ctx; + } + return null; + } + + /** + * Check if we should broadcast operations (not applying remote operations) + */ + private boolean shouldBroadcast() { + SyncEditingContext syncCtx = getSyncEditingContext(); + return syncCtx != null && !syncCtx.isApplyingRemoteOperation(); + } + + /** + * Broadcast a SET operation to other replicas + */ + private void broadcastSetOperation(ModelProperty<? super I> property, Object oldValue, Object newValue) { + SyncEditingContext syncCtx = getSyncEditingContext(); + if (syncCtx != null && !syncCtx.isApplyingRemoteOperation()) { + syncCtx.broadcastSet(getObject(), property, oldValue, newValue); + } + } + + /** + * Broadcast an ADD operation to other replicas + */ + private void broadcastAddOperation(ModelProperty<? super I> property, Object addedValue, int index) { + SyncEditingContext syncCtx = getSyncEditingContext(); + if (syncCtx != null && !syncCtx.isApplyingRemoteOperation()) { + syncCtx.broadcastAdd(getObject(), property, addedValue, index); + } + } + + /** + * Broadcast a REMOVE operation to other replicas + */ + private void broadcastRemoveOperation(ModelProperty<? super I> property, Object removedValue) { + SyncEditingContext syncCtx = getSyncEditingContext(); + if (syncCtx != null && !syncCtx.isApplyingRemoteOperation()) { + syncCtx.broadcastRemove(getObject(), property, removedValue); + } + } + + /** + * Broadcast a DELETE operation to other replicas + */ + public void broadcastDeleteOperation() { + SyncEditingContext syncCtx = getSyncEditingContext(); + if (syncCtx != null && !syncCtx.isApplyingRemoteOperation()) { + syncCtx.broadcastDelete(getObject()); + } + } + + /** + * Broadcast a CREATE operation to other replicas + */ + public void broadcastCreateOperation() { + SyncEditingContext syncCtx = getSyncEditingContext(); + if (syncCtx != null && !syncCtx.isApplyingRemoteOperation()) { + syncCtx.broadcastCreate(getObject(), getModelEntity().getImplementedInterface().getName()); + } + } + } diff --git a/pamela-core/src/main/java/org/openflexo/pamela/sync/ObjectIdentityManager.java b/pamela-core/src/main/java/org/openflexo/pamela/sync/ObjectIdentityManager.java new file mode 100644 index 00000000..ae936473 --- /dev/null +++ b/pamela-core/src/main/java/org/openflexo/pamela/sync/ObjectIdentityManager.java @@ -0,0 +1,167 @@ +/** + * Copyright (c) 2024, Openflexo + * + * This file is part of Pamela-core, a component of the software infrastructure + * developed at Openflexo. + * + * Openflexo is dual-licensed under the European Union Public License (EUPL, either + * version 1.1 of the License, or any later version), which is available at + * https://joinup.ec.europa.eu/software/page/eupl/licence-eupl + * and the GNU General Public License (GPL, either version 3 of the License, or any + * later version), which is available at http://www.gnu.org/licenses/gpl.html. + */ + +package org.openflexo.pamela.sync; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Manages unique identifiers for PAMELA objects across distributed instances. + * Each object gets a UUID that is stable across all replicas. + * + * @author PAMELA Team + */ +public class ObjectIdentityManager { + + // Maps objects to their unique IDs + private final Map<Object, String> objectToId = new ConcurrentHashMap<>(); + + // Maps IDs back to objects + private final Map<String, Object> idToObject = new ConcurrentHashMap<>(); + + /** + * Register an object with a new generated UUID + * + * @param object the object to register + * @return the generated UUID + */ + public String registerObject(Object object) { + String existingId = objectToId.get(object); + if (existingId != null) { + return existingId; + } + + String newId = UUID.randomUUID().toString(); + objectToId.put(object, newId); + idToObject.put(newId, object); + return newId; + } + + /** + * Register an object with a specific ID (used when receiving remote objects) + * + * @param object the object to register + * @param objectId the specific ID to use + */ + public void registerObject(Object object, String objectId) { + objectToId.put(object, objectId); + idToObject.put(objectId, object); + } + + /** + * Get the ID for an object + * + * @param object the object + * @return the object's ID, or null if not registered + */ + public String getObjectId(Object object) { + return objectToId.get(object); + } + + /** + * Get the ID for an object, registering it if necessary + * + * @param object the object + * @return the object's ID + */ + public String getOrCreateObjectId(Object object) { + String id = objectToId.get(object); + if (id == null) { + id = registerObject(object); + } + return id; + } + + /** + * Get an object by its ID + * + * @param objectId the object ID + * @return the object, or null if not found + */ + public Object getObject(String objectId) { + return idToObject.get(objectId); + } + + /** + * Check if an object is registered + * + * @param object the object + * @return true if registered + */ + public boolean isRegistered(Object object) { + return objectToId.containsKey(object); + } + + /** + * Check if an ID is registered + * + * @param objectId the object ID + * @return true if registered + */ + public boolean hasObject(String objectId) { + return idToObject.containsKey(objectId); + } + + /** + * Unregister an object + * + * @param object the object to unregister + */ + public void unregisterObject(Object object) { + String id = objectToId.remove(object); + if (id != null) { + idToObject.remove(id); + } + } + + /** + * Unregister an object by ID + * + * @param objectId the object ID + */ + public void unregisterById(String objectId) { + Object object = idToObject.remove(objectId); + if (object != null) { + objectToId.remove(object); + } + } + + /** + * Clear all registrations + */ + public void clear() { + objectToId.clear(); + idToObject.clear(); + } + + /** + * Get the number of registered objects + * + * @return the count + */ + public int size() { + return objectToId.size(); + } + + /** + * Get all registered objects as a map from ID to object. + * Returns a copy of the internal map to prevent modification. + * + * @return a map of object ID to object + */ + public Map<String, Object> getAllObjects() { + return new ConcurrentHashMap<>(idToObject); + } +} diff --git a/pamela-core/src/main/java/org/openflexo/pamela/sync/RabbitMQSyncManager.java b/pamela-core/src/main/java/org/openflexo/pamela/sync/RabbitMQSyncManager.java new file mode 100644 index 00000000..bdc972a1 --- /dev/null +++ b/pamela-core/src/main/java/org/openflexo/pamela/sync/RabbitMQSyncManager.java @@ -0,0 +1,505 @@ +/** + * Copyright (c) 2024, Openflexo + * + * This file is part of Pamela-core, a component of the software infrastructure + * developed at Openflexo. + * + * Openflexo is dual-licensed under the European Union Public License (EUPL, either + * version 1.1 of the License, or any later version), which is available at + * https://joinup.ec.europa.eu/software/page/eupl/licence-eupl + * and the GNU General Public License (GPL, either version 3 of the License, or any + * later version), which is available at http://www.gnu.org/licenses/gpl.html. + */ + +package org.openflexo.pamela.sync; + +import com.rabbitmq.client.*; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeoutException; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * RabbitMQ-based synchronization manager for PAMELA collaborative editing. + * Handles publishing operations to the message broker and consuming operations from other replicas. + * + * @author PAMELA Team + */ +public class RabbitMQSyncManager implements SyncManager, AutoCloseable { + + private static final Logger logger = Logger.getLogger(RabbitMQSyncManager.class.getName()); + + // RabbitMQ connection settings + private final String host; + private final int port; + private final String username; + private final String password; + private final String virtualHost; + private final boolean useSsl; + + // Exchange and queue configuration + private final String exchangeName; + private final String routingKey; + + // Unique identifier for this replica + private final String replicaId; + + // Vector clock for this replica + private final VectorClock vectorClock; + + // RabbitMQ connection and channel + private Connection connection; + private Channel publishChannel; + private Channel consumeChannel; + private String queueName; + + // Listeners for received operations + private final List<SyncOperationListener> listeners = new CopyOnWriteArrayList<>(); + + // Connection state + private volatile boolean connected = false; + private volatile boolean closing = false; + + /** + * Create a RabbitMQ sync manager with default settings + */ + public RabbitMQSyncManager() { + this("localhost", 5672, "guest", "guest", "/", "pamela.sync", "operations", false); + } + + /** + * Create a RabbitMQ sync manager with custom settings + */ + public RabbitMQSyncManager(String host, int port, String username, String password, + String virtualHost, String exchangeName, String routingKey, boolean useSsl) { + this.host = host; + this.port = port; + this.username = username; + this.password = password; + this.virtualHost = virtualHost; + this.exchangeName = exchangeName; + this.routingKey = routingKey; + this.useSsl = useSsl; + this.replicaId = UUID.randomUUID().toString(); + this.vectorClock = new VectorClock(); + } + + /** + * Get the unique replica identifier + */ + public String getReplicaId() { + return replicaId; + } + + /** + * Get the current vector clock + */ + public VectorClock getVectorClock() { + return vectorClock; + } + + /** + * Check if connected to RabbitMQ + */ + public boolean isConnected() { + return connected; + } + + /** + * Connect to RabbitMQ and start consuming messages + */ + public void connect() throws IOException, TimeoutException { + if (connected) { + logger.warning("Already connected to RabbitMQ"); + return; + } + + ConnectionFactory factory = new ConnectionFactory(); + factory.setHost(host); + factory.setPort(port); + factory.setUsername(username); + factory.setPassword(password); + factory.setVirtualHost(virtualHost); + + // Enable SSL if configured + if (useSsl) { + try { + factory.useSslProtocol(); + } catch (Exception e) { + throw new IOException("Failed to enable SSL", e); + } + } + + // Enable automatic recovery + factory.setAutomaticRecoveryEnabled(true); + factory.setNetworkRecoveryInterval(5000); + + connection = factory.newConnection("PAMELA-" + replicaId.substring(0, 8)); + + // Create channels for publishing and consuming + publishChannel = connection.createChannel(); + consumeChannel = connection.createChannel(); + + // Declare the fanout exchange for broadcast + publishChannel.exchangeDeclare(exchangeName, BuiltinExchangeType.FANOUT, true); + + // Create an exclusive queue for this replica + queueName = consumeChannel.queueDeclare().getQueue(); + consumeChannel.queueBind(queueName, exchangeName, routingKey); + + // Set up consumer + DeliverCallback deliverCallback = (consumerTag, delivery) -> { + if (closing) return; + + try { + SyncOperation operation = SyncOperationSerializer.deserializeFromBytes(delivery.getBody()); + + // Ignore operations from this replica + if (replicaId.equals(operation.getReplicaId())) { + return; + } + + // Handle STATE_REQUEST and STATE_RESPONSE specially + if (operation.getOperationType() == SyncOperation.OperationType.STATE_REQUEST) { + // Another replica is requesting state + notifyStateRequested(operation.getReplicaId()); + return; + } + + if (operation.getOperationType() == SyncOperation.OperationType.STATE_RESPONSE) { + // Check if this response is for us (targetReplicaId stored in propertyIdentifier) + String targetReplicaId = operation.getPropertyIdentifier(); + if (targetReplicaId == null || targetReplicaId.equals(replicaId)) { + notifyStateReceived(operation.getNewValueSerialized(), operation.getReplicaId()); + } + return; + } + + // Update our vector clock + if (operation.getVectorClock() != null) { + vectorClock.merge(operation.getVectorClock()); + } + + // Notify listeners + for (SyncOperationListener listener : listeners) { + try { + listener.onOperationReceived(operation); + } catch (Exception e) { + logger.log(Level.SEVERE, "Error in operation listener", e); + } + } + + } catch (SyncOperationSerializer.SyncSerializationException e) { + logger.log(Level.SEVERE, "Failed to deserialize operation", e); + notifyError(e); + } + }; + + CancelCallback cancelCallback = consumerTag -> { + if (!closing) { + logger.warning("Consumer cancelled: " + consumerTag); + notifyDisconnected("Consumer cancelled"); + } + }; + + consumeChannel.basicConsume(queueName, true, deliverCallback, cancelCallback); + + connected = true; + logger.info("Connected to RabbitMQ at " + host + ":" + port + " as replica " + replicaId); + notifyConnected(); + } + + /** + * Publish a synchronization operation to all replicas + * + * @param operation the operation to publish + */ + @Override + public void publishOperation(SyncOperation operation) { + if (!connected) { + logger.warning("Cannot publish: not connected to RabbitMQ"); + return; + } + + try { + // Increment our vector clock + vectorClock.increment(replicaId); + + // Create a new operation with the updated vector clock + SyncOperation operationWithClock = new SyncOperation.Builder(operation.getOperationType()) + .operationId(operation.getOperationId()) + .timestamp(operation.getTimestamp()) + .replicaId(operation.getReplicaId()) + .objectId(operation.getObjectId()) + .entityType(operation.getEntityType()) + .propertyIdentifier(operation.getPropertyIdentifier()) + .oldValue(operation.getOldValueSerialized()) + .newValue(operation.getNewValueSerialized()) + .valueType(operation.getValueType()) + .index(operation.getIndex()) + .vectorClock(vectorClock.copy()) + .build(); + + byte[] body = SyncOperationSerializer.serializeToBytes(operationWithClock); + + // Publish with persistent delivery mode + AMQP.BasicProperties props = new AMQP.BasicProperties.Builder() + .deliveryMode(2) // persistent + .contentType("application/json") + .correlationId(operation.getOperationId()) + .build(); + + publishChannel.basicPublish(exchangeName, routingKey, props, body); + + logger.fine("Published operation: " + operation); + + } catch (SyncOperationSerializer.SyncSerializationException e) { + logger.log(Level.SEVERE, "Failed to serialize operation", e); + } catch (IOException e) { + logger.log(Level.SEVERE, "Failed to publish operation", e); + notifyError(e); + } + } + + /** + * Add a listener for synchronization operations + */ + public void addListener(SyncOperationListener listener) { + listeners.add(listener); + } + + /** + * Remove a listener + */ + public void removeListener(SyncOperationListener listener) { + listeners.remove(listener); + } + + /** + * Disconnect from RabbitMQ + */ + public void disconnect() { + if (!connected) { + return; + } + + closing = true; + + try { + if (consumeChannel != null && consumeChannel.isOpen()) { + consumeChannel.close(); + } + if (publishChannel != null && publishChannel.isOpen()) { + publishChannel.close(); + } + if (connection != null && connection.isOpen()) { + connection.close(); + } + } catch (IOException | TimeoutException e) { + logger.log(Level.WARNING, "Error during disconnect", e); + } + + connected = false; + closing = false; + logger.info("Disconnected from RabbitMQ"); + notifyDisconnected("Manual disconnect"); + } + + @Override + public void close() { + disconnect(); + } + + private void notifyConnected() { + for (SyncOperationListener listener : listeners) { + try { + listener.onConnected(); + } catch (Exception e) { + logger.log(Level.WARNING, "Error in connection listener", e); + } + } + } + + private void notifyDisconnected(String reason) { + for (SyncOperationListener listener : listeners) { + try { + listener.onDisconnected(reason); + } catch (Exception e) { + logger.log(Level.WARNING, "Error in disconnection listener", e); + } + } + } + + private void notifyError(Throwable error) { + for (SyncOperationListener listener : listeners) { + try { + listener.onError(error); + } catch (Exception e) { + logger.log(Level.WARNING, "Error in error listener", e); + } + } + } + + private void notifyStateRequested(String requestingReplicaId) { + for (SyncOperationListener listener : listeners) { + try { + listener.onStateRequested(requestingReplicaId); + } catch (Exception e) { + logger.log(Level.WARNING, "Error in state request listener", e); + } + } + } + + private void notifyStateReceived(String stateSnapshot, String fromReplicaId) { + for (SyncOperationListener listener : listeners) { + try { + listener.onStateReceived(stateSnapshot, fromReplicaId); + } catch (Exception e) { + logger.log(Level.WARNING, "Error in state received listener", e); + } + } + } + + /** + * Request the current state from other replicas. + * Used when a new client joins and needs to synchronize. + */ + @Override + public void requestState() { + if (!connected) { + logger.warning("Cannot request state: not connected to RabbitMQ"); + return; + } + + try { + SyncOperation stateRequest = new SyncOperation.Builder(SyncOperation.OperationType.STATE_REQUEST) + .replicaId(replicaId) + .objectId("state-request") + .entityType("StateRequest") + .vectorClock(vectorClock.copy()) + .build(); + + byte[] body = SyncOperationSerializer.serializeToBytes(stateRequest); + + AMQP.BasicProperties props = new AMQP.BasicProperties.Builder() + .deliveryMode(2) + .contentType("application/json") + .correlationId(stateRequest.getOperationId()) + .build(); + + publishChannel.basicPublish(exchangeName, routingKey, props, body); + logger.info("Requested state from other replicas"); + + } catch (SyncOperationSerializer.SyncSerializationException e) { + logger.log(Level.SEVERE, "Failed to serialize state request", e); + } catch (IOException e) { + logger.log(Level.SEVERE, "Failed to publish state request", e); + notifyError(e); + } + } + + /** + * Send the current state as a response to a state request. + * + * @param stateSnapshot the serialized state snapshot + * @param targetReplicaId the replica that requested the state (optional, null for broadcast) + */ + @Override + public void sendStateResponse(String stateSnapshot, String targetReplicaId) { + if (!connected) { + logger.warning("Cannot send state response: not connected to RabbitMQ"); + return; + } + + try { + SyncOperation stateResponse = new SyncOperation.Builder(SyncOperation.OperationType.STATE_RESPONSE) + .replicaId(replicaId) + .objectId("state-response") + .entityType("StateResponse") + .propertyIdentifier(targetReplicaId) // Store target replica ID here + .newValue(stateSnapshot) + .vectorClock(vectorClock.copy()) + .build(); + + byte[] body = SyncOperationSerializer.serializeToBytes(stateResponse); + + AMQP.BasicProperties props = new AMQP.BasicProperties.Builder() + .deliveryMode(2) + .contentType("application/json") + .correlationId(stateResponse.getOperationId()) + .build(); + + publishChannel.basicPublish(exchangeName, routingKey, props, body); + logger.info("Sent state response to replica: " + (targetReplicaId != null ? targetReplicaId : "all")); + + } catch (SyncOperationSerializer.SyncSerializationException e) { + logger.log(Level.SEVERE, "Failed to serialize state response", e); + } catch (IOException e) { + logger.log(Level.SEVERE, "Failed to publish state response", e); + notifyError(e); + } + } + + /** + * Builder for creating RabbitMQSyncManager instances + */ + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private String host = "localhost"; + private int port = 5672; + private String username = "guest"; + private String password = "guest"; + private String virtualHost = "/"; + private String exchangeName = "pamela.sync"; + private String routingKey = "operations"; + private boolean useSsl = false; + + public Builder host(String host) { + this.host = host; + return this; + } + + public Builder port(int port) { + this.port = port; + return this; + } + + public Builder credentials(String username, String password) { + this.username = username; + this.password = password; + return this; + } + + public Builder virtualHost(String virtualHost) { + this.virtualHost = virtualHost; + return this; + } + + public Builder exchangeName(String exchangeName) { + this.exchangeName = exchangeName; + return this; + } + + public Builder routingKey(String routingKey) { + this.routingKey = routingKey; + return this; + } + + public Builder useSsl(boolean useSsl) { + this.useSsl = useSsl; + return this; + } + + public RabbitMQSyncManager build() { + return new RabbitMQSyncManager(host, port, username, password, virtualHost, exchangeName, routingKey, useSsl); + } + } +} diff --git a/pamela-core/src/main/java/org/openflexo/pamela/sync/SyncEditingContext.java b/pamela-core/src/main/java/org/openflexo/pamela/sync/SyncEditingContext.java new file mode 100644 index 00000000..42d64218 --- /dev/null +++ b/pamela-core/src/main/java/org/openflexo/pamela/sync/SyncEditingContext.java @@ -0,0 +1,1030 @@ +/** + * Copyright (c) 2024, Openflexo + * + * This file is part of Pamela-core, a component of the software infrastructure + * developed at Openflexo. + * + * Openflexo is dual-licensed under the European Union Public License (EUPL, either + * version 1.1 of the License, or any later version), which is available at + * https://joinup.ec.europa.eu/software/page/eupl/licence-eupl + * and the GNU General Public License (GPL, either version 3 of the License, or any + * later version), which is available at http://www.gnu.org/licenses/gpl.html. + */ + +package org.openflexo.pamela.sync; + +import org.openflexo.pamela.factory.EditingContextImpl; +import org.openflexo.pamela.factory.PamelaModelFactory; +import org.openflexo.pamela.factory.ProxyMethodHandler; +import org.openflexo.pamela.model.ModelProperty; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Synchronized editing context that broadcasts PAMELA operations via RabbitMQ. + * This class extends the standard EditingContext to add real-time collaborative + * synchronization capabilities. + * + * @author PAMELA Team + */ +public class SyncEditingContext extends EditingContextImpl implements SyncOperationListener { + + private static final Logger logger = Logger.getLogger(SyncEditingContext.class.getName()); + + private SyncManager syncManager; + private final ObjectIdentityManager identityManager; + private final SyncValueSerializer valueSerializer; + private PamelaModelFactory modelFactory; + + // Flag to prevent recursive sync when applying remote operations + private final ThreadLocal<Boolean> applyingRemoteOperation = ThreadLocal.withInitial(() -> false); + + // Stores the replicaId of the remote operation currently being applied + // Used by ProxyMethodHandler.getCurrentReplicaId() to tag AtomicEdits with the correct replicaId + private final ThreadLocal<String> currentRemoteReplicaId = new ThreadLocal<>(); + + // Buffer for operations during object creation (ensures CREATE is sent before SETs) + // Key: objectId, Value: list of buffered operations + private final Map<String, List<SyncOperation>> pendingOperations = new ConcurrentHashMap<>(); + + // Track objects for which CREATE has been sent (by objectId) + private final Map<String, Boolean> createdObjects = new ConcurrentHashMap<>(); + + // Flag to automatically request state from other replicas on connection + private boolean autoRequestStateOnConnect = false; + + // Flag to track if state has been received (to avoid multiple requests) + private volatile boolean stateReceived = false; + + /** + * Create a synchronized editing context without a sync manager. + * Call setSyncManager() to set one later. + */ + public SyncEditingContext() { + super(); + this.syncManager = null; + this.identityManager = new ObjectIdentityManager(); + this.valueSerializer = new SyncValueSerializer(); + } + + /** + * Create a synchronized editing context with a PamelaModelFactory. + * Call setSyncManager() to set a sync manager later. + */ + public SyncEditingContext(PamelaModelFactory modelFactory) { + super(); + this.modelFactory = modelFactory; + this.syncManager = null; + this.identityManager = new ObjectIdentityManager(); + this.valueSerializer = new SyncValueSerializer(); + } + + /** + * Create a synchronized editing context with custom sync manager + */ + public SyncEditingContext(SyncManager syncManager) { + super(); + this.syncManager = syncManager; + this.identityManager = new ObjectIdentityManager(); + this.valueSerializer = new SyncValueSerializer(); + if (this.syncManager != null) { + this.syncManager.addListener(this); + } + } + + /** + * Set the model factory for this context + */ + public void setModelFactory(PamelaModelFactory modelFactory) { + this.modelFactory = modelFactory; + } + + /** + * Get the sync manager + */ + public SyncManager getSyncManager() { + return syncManager; + } + + /** + * Set the sync manager + */ + public void setSyncManager(SyncManager syncManager) { + this.syncManager = syncManager; + if (this.syncManager != null) { + this.syncManager.addListener(this); + } + } + + /** + * Get the identity manager + */ + public ObjectIdentityManager getIdentityManager() { + return identityManager; + } + + /** + * Get the replica ID + */ + public String getReplicaId() { + return syncManager != null ? syncManager.getReplicaId() : null; + } + + /** + * Check if currently applying a remote operation + */ + public boolean isApplyingRemoteOperation() { + return applyingRemoteOperation.get(); + } + + /** + * Get the replicaId of the operation currently being processed. + * If applying a remote operation, returns the remote replica's ID. + * Otherwise, returns the local replica's ID. + * This is used by ProxyMethodHandler to tag AtomicEdits with the correct replicaId. + */ + public String getCurrentOperationReplicaId() { + String remoteId = currentRemoteReplicaId.get(); + if (remoteId != null) { + return remoteId; + } + return getReplicaId(); + } + + /** + * Get the interface name for a PAMELA object. + * Returns the implemented interface name (e.g., "org.example.Book") instead of + * the proxy class name (e.g., "Book$BookImpl_$$_jvst806_1"). + */ + private <I> String getEntityTypeName(I object) { + if (modelFactory != null && modelFactory.isProxyObject(object)) { + ProxyMethodHandler<?> handler = modelFactory.getHandler(object); + if (handler != null) { + return handler.getModelEntity().getImplementedInterface().getName(); + } + } + return object.getClass().getName(); + } + + /** + * Broadcast a SET operation + */ + public <I> void broadcastSet(I object, ModelProperty<? super I> property, Object oldValue, Object newValue) { + if (syncManager == null || isApplyingRemoteOperation() || !syncManager.isConnected()) { + return; + } + + try { + String objectId = identityManager.getOrCreateObjectId(object); + String entityType = getEntityTypeName(object); + + SyncOperation operation = new SyncOperation.Builder(SyncOperation.OperationType.SET) + .replicaId(syncManager.getReplicaId()) + .objectId(objectId) + .entityType(entityType) + .propertyIdentifier(property.getPropertyIdentifier()) + .oldValue(valueSerializer.serialize(oldValue)) + .newValue(valueSerializer.serialize(newValue)) + .valueType(property.getType().getName()) + .build(); + + // Check if CREATE has been sent for this object - if not, buffer the operation + if (!createdObjects.containsKey(objectId)) { + // Buffer the operation to be sent after CREATE + pendingOperations.computeIfAbsent(objectId, k -> new ArrayList<>()).add(operation); + logger.fine("Buffered SET operation for object not yet created: " + objectId); + } else { + syncManager.publishOperation(operation); + } + + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to broadcast SET operation", e); + } + } + + /** + * Broadcast an ADD operation + */ + public <I> void broadcastAdd(I object, ModelProperty<? super I> property, Object addedValue, int index) { + if (syncManager == null || isApplyingRemoteOperation() || !syncManager.isConnected()) { + return; + } + + try { + String objectId = identityManager.getOrCreateObjectId(object); + String entityType = getEntityTypeName(object); + + // For PAMELA objects, use reference serialization + String serializedValue; + if (addedValue != null && modelFactory != null && modelFactory.isProxyObject(addedValue)) { + // Ensure added object is registered and use reference + identityManager.getOrCreateObjectId(addedValue); + serializedValue = valueSerializer.serializeReference(addedValue, identityManager); + } else { + serializedValue = valueSerializer.serialize(addedValue); + } + + SyncOperation operation = new SyncOperation.Builder(SyncOperation.OperationType.ADD) + .replicaId(syncManager.getReplicaId()) + .objectId(objectId) + .entityType(entityType) + .propertyIdentifier(property.getPropertyIdentifier()) + .newValue(serializedValue) + .valueType(property.getType().getName()) + .index(index) + .build(); + + syncManager.publishOperation(operation); + + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to broadcast ADD operation", e); + } + } + + /** + * Broadcast a REMOVE operation + */ + public <I> void broadcastRemove(I object, ModelProperty<? super I> property, Object removedValue) { + if (syncManager == null || isApplyingRemoteOperation() || !syncManager.isConnected()) { + return; + } + + try { + String objectId = identityManager.getOrCreateObjectId(object); + String entityType = getEntityTypeName(object); + + // For PAMELA objects, use reference serialization + String serializedValue; + if (removedValue != null && modelFactory != null && modelFactory.isProxyObject(removedValue)) { + serializedValue = valueSerializer.serializeReference(removedValue, identityManager); + } else { + serializedValue = valueSerializer.serialize(removedValue); + } + + SyncOperation operation = new SyncOperation.Builder(SyncOperation.OperationType.REMOVE) + .replicaId(syncManager.getReplicaId()) + .objectId(objectId) + .entityType(entityType) + .propertyIdentifier(property.getPropertyIdentifier()) + .oldValue(serializedValue) + .valueType(property.getType().getName()) + .build(); + + syncManager.publishOperation(operation); + + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to broadcast REMOVE operation", e); + } + } + + /** + * Broadcast a CREATE operation + */ + public <I> void broadcastCreate(I object, String entityType) { + if (syncManager == null || isApplyingRemoteOperation() || !syncManager.isConnected()) { + return; + } + + try { + String objectId = identityManager.getOrCreateObjectId(object); + + SyncOperation operation = new SyncOperation.Builder(SyncOperation.OperationType.CREATE) + .replicaId(syncManager.getReplicaId()) + .objectId(objectId) + .entityType(entityType) + .build(); + + // Send CREATE first + syncManager.publishOperation(operation); + + // Mark object as created + createdObjects.put(objectId, Boolean.TRUE); + + // Then send any buffered operations for this object + List<SyncOperation> buffered = pendingOperations.remove(objectId); + if (buffered != null) { + for (SyncOperation bufferedOp : buffered) { + syncManager.publishOperation(bufferedOp); + } + logger.fine("Sent " + buffered.size() + " buffered operations for: " + objectId); + } + + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to broadcast CREATE operation", e); + } + } + + /** + * Broadcast a DELETE operation + */ + public <I> void broadcastDelete(I object) { + if (syncManager == null || isApplyingRemoteOperation() || !syncManager.isConnected()) { + return; + } + + try { + String objectId = identityManager.getObjectId(object); + if (objectId == null) { + logger.warning("Cannot broadcast delete for unregistered object"); + return; + } + + String entityType = getEntityTypeName(object); + + SyncOperation operation = new SyncOperation.Builder(SyncOperation.OperationType.DELETE) + .replicaId(syncManager.getReplicaId()) + .objectId(objectId) + .entityType(entityType) + .build(); + + syncManager.publishOperation(operation); + + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to broadcast DELETE operation", e); + } + } + + // SyncOperationListener implementation + + @Override + public void onOperationReceived(SyncOperation operation) { + logger.info(">>> Received operation: " + operation.getOperationType() + + " objectId=" + operation.getObjectId() + + " property=" + operation.getPropertyIdentifier()); + + if (modelFactory == null) { + logger.warning("ModelFactory not set, cannot apply remote operation"); + return; + } + + applyingRemoteOperation.set(true); + currentRemoteReplicaId.set(operation.getReplicaId()); + try { + switch (operation.getOperationType()) { + case CREATE: + applyRemoteCreate(operation); + break; + case DELETE: + applyRemoteDelete(operation); + break; + case SET: case ADD: case REMOVE: + applyRemoteModification(operation); + break; + default: + logger.warning("Unknown operation type: " + operation.getOperationType()); + } + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to apply remote operation: " + operation, e); + } finally { + applyingRemoteOperation.set(false); + currentRemoteReplicaId.remove(); + } + } + + @Override + public void onConnected() { + logger.info("Sync connected as replica: " + syncManager.getReplicaId()); + + // Automatically request state from other replicas if enabled + if (autoRequestStateOnConnect && !stateReceived) { + logger.info("Automatically requesting state from other replicas..."); + requestStateSync(); + } + } + + @Override + public void onDisconnected(String reason) { + logger.info("Sync disconnected: " + reason); + } + + @Override + public void onError(Throwable error) { + logger.log(Level.SEVERE, "Sync error", error); + } + + @Override + public void onStateRequested(String requestingReplicaId) { + logger.info("State requested by replica: " + requestingReplicaId); + if (syncManager == null) { + return; + } + + try { + String stateSnapshot = serializeCurrentState(); + syncManager.sendStateResponse(stateSnapshot, requestingReplicaId); + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to send state response", e); + } + } + + @Override + public void onStateReceived(String stateSnapshot, String fromReplicaId) { + logger.info("State received from replica: " + fromReplicaId); + + // Mark state as received to prevent duplicate requests + stateReceived = true; + + // Set the remote replicaId so AtomicEdits are tagged correctly + currentRemoteReplicaId.set(fromReplicaId); + try { + restoreFromSnapshot(stateSnapshot); + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to restore from snapshot", e); + } finally { + currentRemoteReplicaId.remove(); + } + } + + /** + * Enable or disable automatic state request on connection. + * When enabled, the context will automatically request state from other replicas + * upon connecting to the sync manager. + * + * @param autoRequest true to enable automatic state request + */ + public void setAutoRequestStateOnConnect(boolean autoRequest) { + this.autoRequestStateOnConnect = autoRequest; + } + + /** + * Check if automatic state request on connect is enabled. + * + * @return true if enabled + */ + public boolean isAutoRequestStateOnConnect() { + return autoRequestStateOnConnect; + } + + /** + * Check if state has been received from another replica. + * + * @return true if state was received + */ + public boolean isStateReceived() { + return stateReceived; + } + + /** + * Creates and configures an UndoManager for this sync editing context. + * The UndoManager is configured with the local replica ID to filter out remote edits. + */ + @Override + public org.openflexo.pamela.undo.UndoManager createUndoManager() { + org.openflexo.pamela.undo.UndoManager undoManager = super.createUndoManager(); + + // Configure the UndoManager with the local replica ID + // This ensures it only tracks edits from the local replica + if (syncManager != null) { + undoManager.setLocalReplicaId(syncManager.getReplicaId()); + } + + return undoManager; + } + + /** + * Request state from other replicas. + * Call this when a new client joins to get the current state. + */ + public void requestStateSync() { + if (syncManager != null && syncManager.isConnected()) { + syncManager.requestState(); + } + } + + /** + * Serialize the current state of all tracked objects into a snapshot. + * + * @return JSON string containing all objects and their properties + */ + public String serializeCurrentState() { + StringBuilder json = new StringBuilder(); + json.append("{\"objects\":["); + + Map<String, Object> allObjects = identityManager.getAllObjects(); + boolean first = true; + + for (Map.Entry<String, Object> entry : allObjects.entrySet()) { + String objectId = entry.getKey(); + Object object = entry.getValue(); + + if (!first) { + json.append(","); + } + first = false; + + json.append("{"); + json.append("\"id\":\"").append(escapeJson(objectId)).append("\","); + json.append("\"type\":\"").append(escapeJson(getEntityTypeName(object))).append("\","); + json.append("\"properties\":{"); + + try { + ProxyMethodHandler<?> handler = modelFactory.getHandler(object); + if (handler != null) { + boolean firstProp = true; + java.util.Iterator<? extends ModelProperty<?>> propIterator = handler.getModelEntity().getProperties(); + while (propIterator.hasNext()) { + ModelProperty<?> property = propIterator.next(); + if (property.getGetter() != null) { + Object value = handler.invokeGetter(property.getPropertyIdentifier()); + if (value != null) { + if (!firstProp) { + json.append(","); + } + firstProp = false; + + String serializedValue; + if (modelFactory.isProxyObject(value)) { + serializedValue = valueSerializer.serializeReference(value, identityManager); + } else if (value instanceof List) { + serializedValue = serializeList((List<?>) value); + } else { + serializedValue = valueSerializer.serialize(value); + } + + json.append("\"").append(escapeJson(property.getPropertyIdentifier())).append("\":"); + json.append("\"").append(escapeJson(serializedValue)).append("\""); + } + } + } + } + } catch (Exception e) { + logger.log(Level.WARNING, "Failed to serialize object: " + objectId, e); + } + + json.append("}}"); + } + + json.append("]}"); + return json.toString(); + } + + /** + * Restore the local state from a snapshot received from another replica. + * + * @param stateSnapshot JSON string containing the state snapshot + */ + public void restoreFromSnapshot(String stateSnapshot) { + if (stateSnapshot == null || stateSnapshot.isEmpty()) { + logger.warning("Empty state snapshot received"); + return; + } + + applyingRemoteOperation.set(true); + try { + // Simple JSON parsing for the snapshot format + // Format: {"objects":[{"id":"...","type":"...","properties":{...}}, ...]} + + int objectsStart = stateSnapshot.indexOf("["); + int objectsEnd = stateSnapshot.lastIndexOf("]"); + if (objectsStart < 0 || objectsEnd < 0) { + logger.warning("Invalid snapshot format"); + return; + } + + String objectsJson = stateSnapshot.substring(objectsStart + 1, objectsEnd); + if (objectsJson.trim().isEmpty()) { + logger.info("Empty state snapshot - no objects to restore"); + return; + } + + // Parse each object + List<ObjectSnapshot> snapshots = parseObjectSnapshots(objectsJson); + + // First pass: create all objects + for (ObjectSnapshot snapshot : snapshots) { + if (!identityManager.hasObject(snapshot.id)) { + try { + Class<?> entityClass = Class.forName(snapshot.type); + Object newObject = modelFactory._newInstance(entityClass, false); + + ProxyMethodHandler<?> handler = modelFactory.getHandler(newObject); + if (handler != null) { + handler.setDeserializing(true); + } + + identityManager.registerObject(newObject, snapshot.id); + createdObjects.put(snapshot.id, Boolean.TRUE); + logger.fine("Created object from snapshot: " + snapshot.id); + } catch (Exception e) { + logger.log(Level.WARNING, "Failed to create object from snapshot: " + snapshot.id, e); + } + } + } + + // Second pass: set properties (after all objects exist for reference resolution) + for (ObjectSnapshot snapshot : snapshots) { + Object object = identityManager.getObject(snapshot.id); + if (object == null) { + continue; + } + + try { + ProxyMethodHandler<?> handler = modelFactory.getHandler(object); + if (handler != null) { + for (Map.Entry<String, String> prop : snapshot.properties.entrySet()) { + try { + ModelProperty<?> modelProperty = handler.getModelEntity().getModelProperty(prop.getKey()); + if (modelProperty != null) { + String serializedValue = prop.getValue(); + + // Handle list properties specially + if (serializedValue.startsWith("[") && serializedValue.endsWith("]")) { + // It's a list - use adder for each element + restoreListProperty(handler, modelProperty, serializedValue); + } else { + Object value = valueSerializer.deserialize(serializedValue, modelProperty.getType(), this); + handler.invokeSetter(prop.getKey(), value); + } + } + } catch (Exception e) { + logger.log(Level.FINE, "Failed to set property: " + prop.getKey(), e); + } + } + } + } catch (Exception e) { + logger.log(Level.WARNING, "Failed to restore object properties: " + snapshot.id, e); + } + } + + logger.info("Restored " + snapshots.size() + " objects from snapshot"); + + } finally { + applyingRemoteOperation.set(false); + } + } + + /** + * Restore a list property from serialized format using adder method. + */ + private void restoreListProperty(ProxyMethodHandler<?> handler, ModelProperty<?> property, String serializedList) { + // Remove brackets: [item1,item2,item3] -> item1,item2,item3 + String content = serializedList.substring(1, serializedList.length() - 1); + if (content.trim().isEmpty()) { + return; // Empty list + } + + // Split by comma (careful with nested structures) + List<String> items = splitListItems(content); + + for (String item : items) { + item = item.trim(); + if (item.isEmpty()) { + continue; + } + + try { + // Deserialize the item + Object value = valueSerializer.deserialize(item, property.getType(), this); + if (value != null) { + // Use adder to add to the list + handler.invokeAdder(property.getPropertyIdentifier(), value); + logger.fine("Added item to list property " + property.getPropertyIdentifier() + ": " + value); + } + } catch (Exception e) { + logger.log(Level.WARNING, "Failed to add item to list: " + item, e); + } + } + } + + /** + * Split list items by comma, handling nested structures. + */ + private List<String> splitListItems(String content) { + List<String> items = new ArrayList<>(); + int depth = 0; + StringBuilder current = new StringBuilder(); + + for (int i = 0; i < content.length(); i++) { + char c = content.charAt(i); + if (c == '[' || c == '{') { + depth++; + current.append(c); + } else if (c == ']' || c == '}') { + depth--; + current.append(c); + } else if (c == ',' && depth == 0) { + items.add(current.toString()); + current = new StringBuilder(); + } else { + current.append(c); + } + } + + if (current.length() > 0) { + items.add(current.toString()); + } + + return items; + } + + private String serializeList(List<?> list) { + StringBuilder sb = new StringBuilder("["); + boolean first = true; + for (Object item : list) { + if (!first) { + sb.append(","); + } + first = false; + + if (item != null && modelFactory != null && modelFactory.isProxyObject(item)) { + sb.append(valueSerializer.serializeReference(item, identityManager)); + } else { + sb.append(valueSerializer.serialize(item)); + } + } + sb.append("]"); + return sb.toString(); + } + + private String escapeJson(String value) { + if (value == null) return ""; + return value.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } + + private static class ObjectSnapshot { + String id; + String type; + Map<String, String> properties = new java.util.HashMap<>(); + } + + private List<ObjectSnapshot> parseObjectSnapshots(String objectsJson) { + List<ObjectSnapshot> snapshots = new ArrayList<>(); + + // Simple parsing - find each object block + int depth = 0; + int objectStart = -1; + + for (int i = 0; i < objectsJson.length(); i++) { + char c = objectsJson.charAt(i); + if (c == '{') { + if (depth == 0) { + objectStart = i; + } + depth++; + } else if (c == '}') { + depth--; + if (depth == 0 && objectStart >= 0) { + String objectJson = objectsJson.substring(objectStart, i + 1); + ObjectSnapshot snapshot = parseObjectSnapshot(objectJson); + if (snapshot != null) { + snapshots.add(snapshot); + } + objectStart = -1; + } + } + } + + return snapshots; + } + + private ObjectSnapshot parseObjectSnapshot(String objectJson) { + ObjectSnapshot snapshot = new ObjectSnapshot(); + + // Extract id + snapshot.id = extractJsonValue(objectJson, "id"); + snapshot.type = extractJsonValue(objectJson, "type"); + + if (snapshot.id == null || snapshot.type == null) { + return null; + } + + // Extract properties block + int propsStart = objectJson.indexOf("\"properties\":{"); + if (propsStart >= 0) { + propsStart += 14; // length of "properties":{ + int propsEnd = findMatchingBrace(objectJson, propsStart - 1); + if (propsEnd > propsStart) { + String propsJson = objectJson.substring(propsStart, propsEnd); + parseProperties(propsJson, snapshot.properties); + } + } + + return snapshot; + } + + private String extractJsonValue(String json, String key) { + String pattern = "\"" + key + "\":\""; + int start = json.indexOf(pattern); + if (start < 0) return null; + start += pattern.length(); + + int end = start; + while (end < json.length()) { + char c = json.charAt(end); + if (c == '"' && json.charAt(end - 1) != '\\') { + break; + } + end++; + } + + if (end > start) { + return unescapeJson(json.substring(start, end)); + } + return null; + } + + private int findMatchingBrace(String json, int openPos) { + int depth = 0; + for (int i = openPos; i < json.length(); i++) { + char c = json.charAt(i); + if (c == '{') depth++; + else if (c == '}') { + depth--; + if (depth == 0) return i; + } + } + return -1; + } + + private void parseProperties(String propsJson, Map<String, String> properties) { + // Simple key-value parsing for "key":"value" pairs + int i = 0; + while (i < propsJson.length()) { + // Find key start + int keyStart = propsJson.indexOf('"', i); + if (keyStart < 0) break; + keyStart++; + + int keyEnd = propsJson.indexOf('"', keyStart); + if (keyEnd < 0) break; + + String key = propsJson.substring(keyStart, keyEnd); + + // Find value after ":" + int colonPos = propsJson.indexOf(':', keyEnd); + if (colonPos < 0) break; + + int valueStart = propsJson.indexOf('"', colonPos); + if (valueStart < 0) break; + valueStart++; + + int valueEnd = valueStart; + while (valueEnd < propsJson.length()) { + char c = propsJson.charAt(valueEnd); + if (c == '"' && propsJson.charAt(valueEnd - 1) != '\\') { + break; + } + valueEnd++; + } + + if (valueEnd > valueStart) { + properties.put(key, unescapeJson(propsJson.substring(valueStart, valueEnd))); + } + + i = valueEnd + 1; + } + } + + private String unescapeJson(String value) { + return value.replace("\\\"", "\"") + .replace("\\\\", "\\") + .replace("\\n", "\n") + .replace("\\r", "\r") + .replace("\\t", "\t"); + } + + // Private methods for applying remote operations + + private void applyRemoteModification(SyncOperation operation){ + Object target = identityManager.getObject(operation.getObjectId()); + if (target == null) + // Object doesn't exist yet - try to create it first (might happen because of reordering operations) + target = ensureRemoteObjectExists(operation.getObjectId(), operation.getEntityType()); + + try { + ProxyMethodHandler<?> handler = modelFactory.getHandler(target); + if (handler != null) { + ModelProperty<?> property = handler.getModelEntity().getModelProperty(operation.getPropertyIdentifier()); + if (property != null) { + Object newValue = valueSerializer.deserialize( + operation.getNewValueSerialized(), + property.getType(), + this + ); + switch(operation.getOperationType()){ + case SET: + handler.invokeSetter(operation.getPropertyIdentifier(), newValue); + break; + case ADD: + handler.invokeAdder(operation.getPropertyIdentifier(), newValue); + break; + case REMOVE: + handler.invokeRemover(operation.getPropertyIdentifier(), newValue); + break; + default: + break; + } + } + } + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to apply" + operation.getOperationType(), e); + } + } + + + private void applyRemoteCreate(SyncOperation operation) { + // Check if object already exists + if (identityManager.hasObject(operation.getObjectId())) { + logger.fine("Object already exists: " + operation.getObjectId()); + return; + } + + try { + // Load the entity class + Class<?> entityClass = Class.forName(operation.getEntityType()); + + // Create a new instance using _newInstance (like deserialization does) + // This creates the object without requiring the initializer + Object newObject = modelFactory._newInstance(entityClass, false); + + // Mark the object as deserializing so it can receive property updates + // without failing the "uninitialized" check + ProxyMethodHandler<?> handler = modelFactory.getHandler(newObject); + if (handler != null) { + handler.setDeserializing(true); + } + + // Register with the specified ID + identityManager.registerObject(newObject, operation.getObjectId()); + + // Mark as known so SET operations work properly + createdObjects.put(operation.getObjectId(), Boolean.TRUE); + + logger.fine("Created remote object: " + operation.getObjectId()); + + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to apply remote CREATE", e); + } + } + + private void applyRemoteDelete(SyncOperation operation) { + Object target = identityManager.getObject(operation.getObjectId()); + if (target == null) { + logger.fine("Object already deleted or not found: " + operation.getObjectId()); + return; + } + + try { + ProxyMethodHandler<?> handler = modelFactory.getHandler(target); + if (handler != null) { + handler.invokeDeleter(target); + } + + identityManager.unregisterById(operation.getObjectId()); + + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to apply remote DELETE", e); + } + + } + + /** + * Ensure a remote object exists, creating it if necessary. + * This handles the case where operations arrive out of order. + * + * @param objectId the object ID + * @param entityType the entity class name + * @return the object, or null if creation failed + */ + private Object ensureRemoteObjectExists(String objectId, String entityType) { + if (entityType == null) { + logger.warning("Cannot create object without entityType for ID: " + objectId); + return null; + } + + try { + Class<?> entityClass = Class.forName(entityType); + + // Create using _newInstance (bypasses initializer requirement) + Object newObject = modelFactory._newInstance(entityClass, false); + + // Mark as deserializing to allow setters without initialization + ProxyMethodHandler<?> handler = modelFactory.getHandler(newObject); + if (handler != null) { + handler.setDeserializing(true); + } + + // Register with the specified ID + identityManager.registerObject(newObject, objectId); + + // Mark as known so SET operations work properly + createdObjects.put(objectId, Boolean.TRUE); + + logger.fine("Auto-created remote object: " + objectId); + return newObject; + + } catch (Exception e) { + logger.log(Level.WARNING, "Failed to auto-create remote object: " + objectId, e); + return null; + } + } +} diff --git a/pamela-core/src/main/java/org/openflexo/pamela/sync/SyncManager.java b/pamela-core/src/main/java/org/openflexo/pamela/sync/SyncManager.java new file mode 100644 index 00000000..745a4e3a --- /dev/null +++ b/pamela-core/src/main/java/org/openflexo/pamela/sync/SyncManager.java @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2013-2015, Openflexo + * + * This file is part of Pamela-core, a component of the software infrastructure + * developed at Openflexo. + * + * Openflexo is dual-licensed under the European Union Public License (EUPL, either + * version 1.1 of the License, or any later version ), which is available at + * https://joinup.ec.europa.eu/software/page/eupl/licence-eupl + * and the GNU General Public License (GPL, either version 3 of the License, or any + * later version), which is available at http://www.gnu.org/licenses/gpl.html . + */ + +package org.openflexo.pamela.sync; + +/** + * Interface for synchronization managers that handle the transport layer + * for PAMELA collaborative synchronization. + * + * Implementations can use different messaging systems like RabbitMQ, Redis, + * WebSockets, or any other pub/sub mechanism. + * + * @author PAMELA Sync + */ +public interface SyncManager { + + /** + * Publishes a synchronization operation to all other replicas. + * + * @param operation the operation to publish + */ + void publishOperation(SyncOperation operation); + + /** + * Adds a listener to receive operations from other replicas. + * + * @param listener the listener to add + */ + void addListener(SyncOperationListener listener); + + /** + * Removes a previously registered listener. + * + * @param listener the listener to remove + */ + void removeListener(SyncOperationListener listener); + + /** + * Returns whether this sync manager is currently connected to the messaging system. + * + * @return true if connected, false otherwise + */ + boolean isConnected(); + + /** + * Returns the unique identifier of this replica. + * + * @return the replica ID + */ + String getReplicaId(); + + /** + * Requests the current state from other replicas. + * Used when a new client joins and needs to synchronize. + */ + default void requestState() { + // Default implementation does nothing + } + + /** + * Sends the current state as a response to a state request. + * + * @param stateSnapshot the serialized state snapshot + * @param targetReplicaId the replica that requested the state (optional, null for broadcast) + */ + default void sendStateResponse(String stateSnapshot, String targetReplicaId) { + // Default implementation does nothing + } + +} diff --git a/pamela-core/src/main/java/org/openflexo/pamela/sync/SyncOperation.java b/pamela-core/src/main/java/org/openflexo/pamela/sync/SyncOperation.java new file mode 100644 index 00000000..accb3e31 --- /dev/null +++ b/pamela-core/src/main/java/org/openflexo/pamela/sync/SyncOperation.java @@ -0,0 +1,230 @@ +/** + * Copyright (c) 2024, Openflexo + * + * This file is part of Pamela-core, a component of the software infrastructure + * developed at Openflexo. + * + * Openflexo is dual-licensed under the European Union Public License (EUPL, either + * version 1.1 of the License, or any later version), which is available at + * https://joinup.ec.europa.eu/software/page/eupl/licence-eupl + * and the GNU General Public License (GPL, either version 3 of the License, or any + * later version), which is available at http://www.gnu.org/licenses/gpl.html. + */ + +package org.openflexo.pamela.sync; + +import java.io.Serializable; +import java.util.UUID; + +/** + * Represents a synchronization operation that can be shared between PAMELA instances + * via RabbitMQ for collaborative real-time editing. + * + * @author PAMELA Team + */ +public class SyncOperation implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * Types of operations that can be synchronized + */ + public enum OperationType { + CREATE, + DELETE, + SET, + ADD, + REMOVE, + REINDEX, + STATE_REQUEST, + STATE_RESPONSE + } + + // Operation identification + private final String operationId; + private final OperationType operationType; + private final long timestamp; + private final String replicaId; + + // Target object information + private final String objectId; + private final String entityType; + + // Property information (for SET, ADD, REMOVE, REINDEX) + private final String propertyIdentifier; + + // Value information + private final String oldValueSerialized; + private final String newValueSerialized; + private final String valueType; + + // For list operations + private final int index; + + // Vector clock for causality tracking + private final VectorClock vectorClock; + + private SyncOperation(Builder builder) { + this.operationId = builder.operationId != null ? builder.operationId : UUID.randomUUID().toString(); + this.operationType = builder.operationType; + this.timestamp = builder.timestamp > 0 ? builder.timestamp : System.currentTimeMillis(); + this.replicaId = builder.replicaId; + this.objectId = builder.objectId; + this.entityType = builder.entityType; + this.propertyIdentifier = builder.propertyIdentifier; + this.oldValueSerialized = builder.oldValueSerialized; + this.newValueSerialized = builder.newValueSerialized; + this.valueType = builder.valueType; + this.index = builder.index; + this.vectorClock = builder.vectorClock; + } + + // Getters + public String getOperationId() { + return operationId; + } + + public OperationType getOperationType() { + return operationType; + } + + public long getTimestamp() { + return timestamp; + } + + public String getReplicaId() { + return replicaId; + } + + public String getObjectId() { + return objectId; + } + + public String getEntityType() { + return entityType; + } + + public String getPropertyIdentifier() { + return propertyIdentifier; + } + + public String getOldValueSerialized() { + return oldValueSerialized; + } + + public String getNewValueSerialized() { + return newValueSerialized; + } + + public String getValueType() { + return valueType; + } + + public int getIndex() { + return index; + } + + public VectorClock getVectorClock() { + return vectorClock; + } + + @Override + public String toString() { + return "SyncOperation{" + + "type=" + operationType + + ", objectId='" + objectId + '\'' + + ", property='" + propertyIdentifier + '\'' + + ", replica='" + replicaId + '\'' + + '}'; + } + + /** + * Builder for creating SyncOperation instances + */ + public static class Builder { + private String operationId; + private OperationType operationType; + private long timestamp; + private String replicaId; + private String objectId; + private String entityType; + private String propertyIdentifier; + private String oldValueSerialized; + private String newValueSerialized; + private String valueType; + private int index = -1; + private VectorClock vectorClock; + + public Builder(OperationType operationType) { + this.operationType = operationType; + } + + public Builder operationId(String operationId) { + this.operationId = operationId; + return this; + } + + public Builder timestamp(long timestamp) { + this.timestamp = timestamp; + return this; + } + + public Builder replicaId(String replicaId) { + this.replicaId = replicaId; + return this; + } + + public Builder objectId(String objectId) { + this.objectId = objectId; + return this; + } + + public Builder entityType(String entityType) { + this.entityType = entityType; + return this; + } + + public Builder propertyIdentifier(String propertyIdentifier) { + this.propertyIdentifier = propertyIdentifier; + return this; + } + + public Builder oldValue(String oldValueSerialized) { + this.oldValueSerialized = oldValueSerialized; + return this; + } + + public Builder newValue(String newValueSerialized) { + this.newValueSerialized = newValueSerialized; + return this; + } + + public Builder valueType(String valueType) { + this.valueType = valueType; + return this; + } + + public Builder index(int index) { + this.index = index; + return this; + } + + public Builder vectorClock(VectorClock vectorClock) { + this.vectorClock = vectorClock; + return this; + } + + public SyncOperation build() { + if (operationType == null) { + throw new IllegalStateException("Operation type is required"); + } + if (replicaId == null) { + throw new IllegalStateException("Replica ID is required"); + } + if (objectId == null) { + throw new IllegalStateException("Object ID is required"); + } + return new SyncOperation(this); + } + } +} diff --git a/pamela-core/src/main/java/org/openflexo/pamela/sync/SyncOperationListener.java b/pamela-core/src/main/java/org/openflexo/pamela/sync/SyncOperationListener.java new file mode 100644 index 00000000..7db324e7 --- /dev/null +++ b/pamela-core/src/main/java/org/openflexo/pamela/sync/SyncOperationListener.java @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2024, Openflexo + * + * This file is part of Pamela-core, a component of the software infrastructure + * developed at Openflexo. + * + * Openflexo is dual-licensed under the European Union Public License (EUPL, either + * version 1.1 of the License, or any later version), which is available at + * https://joinup.ec.europa.eu/software/page/eupl/licence-eupl + * and the GNU General Public License (GPL, either version 3 of the License, or any + * later version), which is available at http://www.gnu.org/licenses/gpl.html. + */ + +package org.openflexo.pamela.sync; + +/** + * Listener interface for receiving synchronization operations from remote replicas. + * Implementations handle the application of remote operations to the local model. + * + * @author PAMELA Team + */ +public interface SyncOperationListener { + + /** + * Called when a synchronization operation is received from a remote replica + * + * @param operation the received operation + */ + void onOperationReceived(SyncOperation operation); + + /** + * Called when the synchronization connection is established + */ + default void onConnected() { + } + + /** + * Called when the synchronization connection is lost + * + * @param reason the reason for disconnection + */ + default void onDisconnected(String reason) { + } + + /** + * Called when a synchronization error occurs + * + * @param error the error that occurred + */ + default void onError(Throwable error) { + } + + /** + * Called when a state request is received from a new replica. + * The listener should respond by sending the current state. + * + * @param requestingReplicaId the ID of the replica requesting state + */ + default void onStateRequested(String requestingReplicaId) { + } + + /** + * Called when a state snapshot is received from another replica. + * The listener should restore the local state from the snapshot. + * + * @param stateSnapshot the serialized state snapshot + * @param fromReplicaId the ID of the replica that sent the state + */ + default void onStateReceived(String stateSnapshot, String fromReplicaId) { + } +} diff --git a/pamela-core/src/main/java/org/openflexo/pamela/sync/SyncOperationSerializer.java b/pamela-core/src/main/java/org/openflexo/pamela/sync/SyncOperationSerializer.java new file mode 100644 index 00000000..c1ac7a84 --- /dev/null +++ b/pamela-core/src/main/java/org/openflexo/pamela/sync/SyncOperationSerializer.java @@ -0,0 +1,176 @@ +/** + * Copyright (c) 2024, Openflexo + * + * This file is part of Pamela-core, a component of the software infrastructure + * developed at Openflexo. + * + * Openflexo is dual-licensed under the European Union Public License (EUPL, either + * version 1.1 of the License, or any later version), which is available at + * https://joinup.ec.europa.eu/software/page/eupl/licence-eupl + * and the GNU General Public License (GPL, either version 3 of the License, or any + * later version), which is available at http://www.gnu.org/licenses/gpl.html. + */ + +package org.openflexo.pamela.sync; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Serializer for SyncOperation objects. + * Handles conversion to/from JSON for transmission over RabbitMQ. + * + * @author PAMELA Team + */ +public class SyncOperationSerializer { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * Serialize a SyncOperation to JSON string + * + * @param operation the operation to serialize + * @return JSON string representation + * @throws SyncSerializationException if serialization fails + */ + public static String serialize(SyncOperation operation) throws SyncSerializationException { + try { + return objectMapper.writeValueAsString(new SyncOperationDTO(operation)); + } catch (JsonProcessingException e) { + throw new SyncSerializationException("Failed to serialize SyncOperation", e); + } + } + + /** + * Deserialize a JSON string to SyncOperation + * + * @param json the JSON string + * @return the deserialized SyncOperation + * @throws SyncSerializationException if deserialization fails + */ + public static SyncOperation deserialize(String json) throws SyncSerializationException { + try { + SyncOperationDTO dto = objectMapper.readValue(json, SyncOperationDTO.class); + return dto.toSyncOperation(); + } catch (JsonProcessingException e) { + throw new SyncSerializationException("Failed to deserialize SyncOperation", e); + } + } + + /** + * Serialize a SyncOperation to byte array + * + * @param operation the operation to serialize + * @return byte array representation + * @throws SyncSerializationException if serialization fails + */ + public static byte[] serializeToBytes(SyncOperation operation) throws SyncSerializationException { + try { + return objectMapper.writeValueAsBytes(new SyncOperationDTO(operation)); + } catch (JsonProcessingException e) { + throw new SyncSerializationException("Failed to serialize SyncOperation to bytes", e); + } + } + + /** + * Deserialize a byte array to SyncOperation + * + * @param bytes the byte array + * @return the deserialized SyncOperation + * @throws SyncSerializationException if deserialization fails + */ + public static SyncOperation deserializeFromBytes(byte[] bytes) throws SyncSerializationException { + try { + SyncOperationDTO dto = objectMapper.readValue(bytes, SyncOperationDTO.class); + return dto.toSyncOperation(); + } catch (Exception e) { + throw new SyncSerializationException("Failed to deserialize SyncOperation from bytes", e); + } + } + + /** + * DTO class for JSON serialization + */ + public static class SyncOperationDTO { + public String operationId; + public String operationType; + public long timestamp; + public String replicaId; + public String objectId; + public String entityType; + public String propertyIdentifier; + public String oldValueSerialized; + public String newValueSerialized; + public String valueType; + public int index; + public VectorClockDTO vectorClock; + + public SyncOperationDTO() { + } + + public SyncOperationDTO(SyncOperation op) { + this.operationId = op.getOperationId(); + this.operationType = op.getOperationType().name(); + this.timestamp = op.getTimestamp(); + this.replicaId = op.getReplicaId(); + this.objectId = op.getObjectId(); + this.entityType = op.getEntityType(); + this.propertyIdentifier = op.getPropertyIdentifier(); + this.oldValueSerialized = op.getOldValueSerialized(); + this.newValueSerialized = op.getNewValueSerialized(); + this.valueType = op.getValueType(); + this.index = op.getIndex(); + if (op.getVectorClock() != null) { + this.vectorClock = new VectorClockDTO(op.getVectorClock()); + } + } + + public SyncOperation toSyncOperation() { + VectorClock vc = null; + if (vectorClock != null) { + vc = vectorClock.toVectorClock(); + } + + return new SyncOperation.Builder(SyncOperation.OperationType.valueOf(operationType)) + .operationId(operationId) + .timestamp(timestamp) + .replicaId(replicaId) + .objectId(objectId) + .entityType(entityType) + .propertyIdentifier(propertyIdentifier) + .oldValue(oldValueSerialized) + .newValue(newValueSerialized) + .valueType(valueType) + .index(index) + .vectorClock(vc) + .build(); + } + } + + /** + * DTO class for VectorClock JSON serialization + */ + public static class VectorClockDTO { + public java.util.Map<String, Long> clock; + + public VectorClockDTO() { + } + + public VectorClockDTO(VectorClock vc) { + this.clock = vc.getClockMap(); + } + + public VectorClock toVectorClock() { + return new VectorClock(clock != null ? clock : new java.util.HashMap<>()); + } + } + + /** + * Exception for serialization errors + */ + public static class SyncSerializationException extends Exception { + public SyncSerializationException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/pamela-core/src/main/java/org/openflexo/pamela/sync/SyncValueSerializer.java b/pamela-core/src/main/java/org/openflexo/pamela/sync/SyncValueSerializer.java new file mode 100644 index 00000000..48c35380 --- /dev/null +++ b/pamela-core/src/main/java/org/openflexo/pamela/sync/SyncValueSerializer.java @@ -0,0 +1,182 @@ +/** + * Copyright (c) 2024, Openflexo + * + * This file is part of Pamela-core, a component of the software infrastructure + * developed at Openflexo. + * + * Openflexo is dual-licensed under the European Union Public License (EUPL, either + * version 1.1 of the License, or any later version), which is available at + * https://joinup.ec.europa.eu/software/page/eupl/licence-eupl + * and the GNU General Public License (GPL, either version 3 of the License, or any + * later version), which is available at http://www.gnu.org/licenses/gpl.html. + */ + +package org.openflexo.pamela.sync; + +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Serializer for property values in sync operations. + * Handles conversion of PAMELA property values to/from string representation + * for transmission over RabbitMQ. + * + * @author PAMELA Team + */ +public class SyncValueSerializer { + + private static final Logger logger = Logger.getLogger(SyncValueSerializer.class.getName()); + + // Special markers for null and object references + private static final String NULL_MARKER = "__NULL__"; + private static final String OBJECT_REF_PREFIX = "__REF__:"; + + /** + * Serialize a value to string representation + * + * @param value the value to serialize + * @return string representation + */ + public String serialize(Object value) { + if (value == null) { + return NULL_MARKER; + } + + // Handle primitive types and common types + if (value instanceof String) { + return (String) value; + } + + if (value instanceof Number || value instanceof Boolean) { + return value.toString(); + } + + if (value instanceof Enum) { + return ((Enum<?>) value).name(); + } + + // Handle PAMELA objects by reference + // This requires the object to be registered in the identity manager + // For now, we'll use toString() and hope for the best + // A more robust solution would check if it's a proxy object + + return value.toString(); + } + + /** + * Serialize a PAMELA object reference + * + * @param object the PAMELA object + * @param identityManager the identity manager to get the object ID + * @return reference string + */ + public String serializeReference(Object object, ObjectIdentityManager identityManager) { + if (object == null) { + return NULL_MARKER; + } + + String objectId = identityManager.getObjectId(object); + if (objectId != null) { + return OBJECT_REF_PREFIX + objectId; + } + + // Not a registered object, serialize as string + return serialize(object); + } + + /** + * Deserialize a string representation to a value + * + * @param serialized the serialized string + * @param targetType the expected type + * @param syncContext the sync context for resolving object references + * @return the deserialized value + */ + public Object deserialize(String serialized, Class<?> targetType, SyncEditingContext syncContext) { + if (serialized == null || NULL_MARKER.equals(serialized)) { + return null; + } + + // Handle object references + if (serialized.startsWith(OBJECT_REF_PREFIX)) { + String objectId = serialized.substring(OBJECT_REF_PREFIX.length()); + return syncContext.getIdentityManager().getObject(objectId); + } + + try { + // Handle primitive types + if (targetType == String.class) { + return serialized; + } + + if (targetType == int.class || targetType == Integer.class) { + return Integer.parseInt(serialized); + } + + if (targetType == long.class || targetType == Long.class) { + return Long.parseLong(serialized); + } + + if (targetType == double.class || targetType == Double.class) { + return Double.parseDouble(serialized); + } + + if (targetType == float.class || targetType == Float.class) { + return Float.parseFloat(serialized); + } + + if (targetType == boolean.class || targetType == Boolean.class) { + return Boolean.parseBoolean(serialized); + } + + if (targetType == byte.class || targetType == Byte.class) { + return Byte.parseByte(serialized); + } + + if (targetType == short.class || targetType == Short.class) { + return Short.parseShort(serialized); + } + + if (targetType == char.class || targetType == Character.class) { + return serialized.isEmpty() ? '\0' : serialized.charAt(0); + } + + // Handle enums + if (targetType.isEnum()) { + @SuppressWarnings({"unchecked", "rawtypes"}) + Object enumValue = Enum.valueOf((Class<Enum>) targetType, serialized); + return enumValue; + } + + // For other types, return the string and let PAMELA's converters handle it + return serialized; + + } catch (Exception e) { + logger.log(Level.WARNING, "Failed to deserialize value: " + serialized + " to type " + targetType, e); + return serialized; + } + } + + /** + * Check if a serialized value is an object reference + * + * @param serialized the serialized string + * @return true if it's an object reference + */ + public boolean isObjectReference(String serialized) { + return serialized != null && serialized.startsWith(OBJECT_REF_PREFIX); + } + + /** + * Extract the object ID from a reference string + * + * @param serialized the serialized reference + * @return the object ID, or null if not a reference + */ + public String extractObjectId(String serialized) { + if (isObjectReference(serialized)) { + return serialized.substring(OBJECT_REF_PREFIX.length()); + } + return null; + } +} diff --git a/pamela-core/src/main/java/org/openflexo/pamela/sync/VectorClock.java b/pamela-core/src/main/java/org/openflexo/pamela/sync/VectorClock.java new file mode 100644 index 00000000..18f5bfac --- /dev/null +++ b/pamela-core/src/main/java/org/openflexo/pamela/sync/VectorClock.java @@ -0,0 +1,178 @@ +/** + * Copyright (c) 2024, Openflexo + * + * This file is part of Pamela-core, a component of the software infrastructure + * developed at Openflexo. + * + * Openflexo is dual-licensed under the European Union Public License (EUPL, either + * version 1.1 of the License, or any later version), which is available at + * https://joinup.ec.europa.eu/software/page/eupl/licence-eupl + * and the GNU General Public License (GPL, either version 3 of the License, or any + * later version), which is available at http://www.gnu.org/licenses/gpl.html. + */ + +package org.openflexo.pamela.sync; + +import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Vector Clock implementation for tracking causality in distributed PAMELA instances. + * Used to establish happened-before relationships between operations from different replicas. + * + * @author PAMELA Team + */ +public class VectorClock implements Serializable, Comparable<VectorClock> { + + private static final long serialVersionUID = 1L; + + private final Map<String, Long> clock; + + public VectorClock() { + this.clock = new ConcurrentHashMap<>(); + } + + public VectorClock(Map<String, Long> initialClock) { + this.clock = new ConcurrentHashMap<>(initialClock); + } + + /** + * Copy constructor + */ + public VectorClock(VectorClock other) { + this.clock = new ConcurrentHashMap<>(other.clock); + } + + /** + * Increment the clock for a specific replica + * + * @param replicaId the replica identifier + * @return the new clock value for this replica + */ + public synchronized long increment(String replicaId) { + long newValue = clock.getOrDefault(replicaId, 0L) + 1; + clock.put(replicaId, newValue); + return newValue; + } + + /** + * Get the clock value for a specific replica + * + * @param replicaId the replica identifier + * @return the clock value, or 0 if not set + */ + public long get(String replicaId) { + return clock.getOrDefault(replicaId, 0L); + } + + /** + * Set the clock value for a specific replica + * + * @param replicaId the replica identifier + * @param value the clock value + */ + public synchronized void set(String replicaId, long value) { + clock.put(replicaId, value); + } + + /** + * Merge this vector clock with another one, taking the maximum of each component + * + * @param other the other vector clock to merge with + */ + public synchronized void merge(VectorClock other) { + for (Map.Entry<String, Long> entry : other.clock.entrySet()) { + clock.merge(entry.getKey(), entry.getValue(), Math::max); + } + } + + /** + * Check if this vector clock happened before another one + * + * @param other the other vector clock + * @return true if this clock happened before the other + */ + public boolean happenedBefore(VectorClock other) { + boolean atLeastOneLess = false; + + for (String replicaId : clock.keySet()) { + long thisValue = this.get(replicaId); + long otherValue = other.get(replicaId); + + if (thisValue > otherValue) { + return false; + } + if (thisValue < otherValue) { + atLeastOneLess = true; + } + } + + // Check for replicas in other but not in this + for (String replicaId : other.clock.keySet()) { + if (!clock.containsKey(replicaId) && other.get(replicaId) > 0) { + atLeastOneLess = true; + } + } + + return atLeastOneLess; + } + + /** + * Check if this vector clock is concurrent with another one + * (neither happened before the other) + * + * @param other the other vector clock + * @return true if the clocks are concurrent + */ + public boolean isConcurrent(VectorClock other) { + return !this.happenedBefore(other) && !other.happenedBefore(this) && !this.equals(other); + } + + /** + * Create a copy of this vector clock + * + * @return a new VectorClock with the same values + */ + public VectorClock copy() { + return new VectorClock(this); + } + + /** + * Get all clock entries + * + * @return a copy of the internal clock map + */ + public Map<String, Long> getClockMap() { + return new HashMap<>(clock); + } + + @Override + public int compareTo(VectorClock other) { + if (this.happenedBefore(other)) { + return -1; + } else if (other.happenedBefore(this)) { + return 1; + } + return 0; // Concurrent or equal + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + VectorClock other = (VectorClock) obj; + return clock.equals(other.clock); + } + + @Override + public int hashCode() { + return clock.hashCode(); + } + + @Override + public String toString() { + return "VectorClock" + clock; + } +} diff --git a/pamela-core/src/main/java/org/openflexo/pamela/sync/package-info.java b/pamela-core/src/main/java/org/openflexo/pamela/sync/package-info.java new file mode 100644 index 00000000..ae16ff2b --- /dev/null +++ b/pamela-core/src/main/java/org/openflexo/pamela/sync/package-info.java @@ -0,0 +1,47 @@ +/** + * PAMELA Synchronization Package + * + * This package provides real-time collaborative editing capabilities for PAMELA models + * through RabbitMQ message broker integration. + * + * <h2>Key Components:</h2> + * <ul> + * <li>{@link org.openflexo.pamela.sync.SyncEditingContext} - Main entry point for synchronized editing</li> + * <li>{@link org.openflexo.pamela.sync.RabbitMQSyncManager} - Handles RabbitMQ communication</li> + * <li>{@link org.openflexo.pamela.sync.SyncOperation} - Represents a synchronizable operation</li> + * <li>{@link org.openflexo.pamela.sync.VectorClock} - Tracks causality between operations</li> + * <li>{@link org.openflexo.pamela.sync.ObjectIdentityManager} - Manages object UUIDs across replicas</li> + * </ul> + * + * <h2>Usage Example:</h2> + * <pre>{@code + * // Create a synchronized editing context + * SyncEditingContext syncContext = new SyncEditingContext(); + * + * // Configure RabbitMQ connection (optional, uses localhost by default) + * // RabbitMQSyncManager customManager = new RabbitMQSyncManager.Builder() + * // .host("rabbitmq.example.com") + * // .credentials("user", "password") + * // .build(); + * // SyncEditingContext syncContext = new SyncEditingContext(customManager); + * + * // Create a model factory with the sync context + * PamelaModelFactory factory = new PamelaModelFactory(MyModel.class); + * factory.setEditingContext(syncContext); + * syncContext.setModelFactory(factory); + * + * // Connect to RabbitMQ + * syncContext.connect(); + * + * // Now all operations on PAMELA objects will be synchronized + * MyModel model = factory.newInstance(MyModel.class); + * model.setName("Hello"); // This is broadcast to all other replicas + * + * // Disconnect when done + * syncContext.disconnect(); + * }</pre> + * + * @author PAMELA Team + * @since 2024 + */ +package org.openflexo.pamela.sync; diff --git a/pamela-core/src/main/java/org/openflexo/pamela/undo/AddCommand.java b/pamela-core/src/main/java/org/openflexo/pamela/undo/AddCommand.java index abca3781..a0e8472d 100644 --- a/pamela-core/src/main/java/org/openflexo/pamela/undo/AddCommand.java +++ b/pamela-core/src/main/java/org/openflexo/pamela/undo/AddCommand.java @@ -62,8 +62,8 @@ public class AddCommand<I> extends AtomicEdit<I> { private final int index; public AddCommand(I updatedObject, ModelEntity<I> modelEntity, ModelProperty<? super I> modelProperty, Object addedValue, - PamelaModelFactory pamelaModelFactory) { - super(modelEntity, pamelaModelFactory); + PamelaModelFactory pamelaModelFactory, String replicaId) { + super(modelEntity, pamelaModelFactory, replicaId); this.updatedObject = updatedObject; this.modelProperty = modelProperty; this.addedValue = addedValue; @@ -71,8 +71,8 @@ public AddCommand(I updatedObject, ModelEntity<I> modelEntity, ModelProperty<? s } public AddCommand(I updatedObject, ModelEntity<I> modelEntity, ModelProperty<? super I> modelProperty, Object addedValue, int index, - PamelaModelFactory pamelaModelFactory) { - super(modelEntity, pamelaModelFactory); + PamelaModelFactory pamelaModelFactory, String replicaId) { + super(modelEntity, pamelaModelFactory, replicaId); this.updatedObject = updatedObject; this.modelProperty = modelProperty; this.addedValue = addedValue; diff --git a/pamela-core/src/main/java/org/openflexo/pamela/undo/AtomicEdit.java b/pamela-core/src/main/java/org/openflexo/pamela/undo/AtomicEdit.java index eff0b53c..0dd9460c 100644 --- a/pamela-core/src/main/java/org/openflexo/pamela/undo/AtomicEdit.java +++ b/pamela-core/src/main/java/org/openflexo/pamela/undo/AtomicEdit.java @@ -56,10 +56,12 @@ public abstract class AtomicEdit<I> implements UndoableEdit { private PamelaModelFactory pamelaModelFactory; private ModelEntity<I> modelEntity; + private String replicaId; - public AtomicEdit(ModelEntity<I> modelEntity, PamelaModelFactory pamelaModelFactory) { + public AtomicEdit(ModelEntity<I> modelEntity, PamelaModelFactory pamelaModelFactory, String replicaId) { this.modelEntity = modelEntity; this.pamelaModelFactory = pamelaModelFactory; + this.replicaId = replicaId; } public PamelaModelFactory getModelFactory() { @@ -70,6 +72,10 @@ public ModelEntity<I> getModelEntity() { return modelEntity; } + public String getReplicaId() { + return replicaId; + } + public abstract I getObject(); @Override @@ -88,6 +94,7 @@ public final boolean replaceEdit(UndoableEdit anEdit) { public void die() { modelEntity = null; pamelaModelFactory = null; + replicaId = null; } @Override diff --git a/pamela-core/src/main/java/org/openflexo/pamela/undo/CreateCommand.java b/pamela-core/src/main/java/org/openflexo/pamela/undo/CreateCommand.java index 6b453ad9..f24152b8 100644 --- a/pamela-core/src/main/java/org/openflexo/pamela/undo/CreateCommand.java +++ b/pamela-core/src/main/java/org/openflexo/pamela/undo/CreateCommand.java @@ -57,8 +57,8 @@ public class CreateCommand<I> extends AtomicEdit<I> { private I createdObject; - public CreateCommand(I createdObject, ModelEntity<I> modelEntity, PamelaModelFactory pamelaModelFactory) { - super(modelEntity, pamelaModelFactory); + public CreateCommand(I createdObject, ModelEntity<I> modelEntity, PamelaModelFactory pamelaModelFactory, String replicaId) { + super(modelEntity, pamelaModelFactory, replicaId); this.createdObject = createdObject; } diff --git a/pamela-core/src/main/java/org/openflexo/pamela/undo/DeleteCommand.java b/pamela-core/src/main/java/org/openflexo/pamela/undo/DeleteCommand.java index 249cdd3e..ea749332 100644 --- a/pamela-core/src/main/java/org/openflexo/pamela/undo/DeleteCommand.java +++ b/pamela-core/src/main/java/org/openflexo/pamela/undo/DeleteCommand.java @@ -57,8 +57,8 @@ public class DeleteCommand<I> extends AtomicEdit<I> { private I deletedObject; - public DeleteCommand(I deletedObject, ModelEntity<I> modelEntity, PamelaModelFactory pamelaModelFactory) { - super(modelEntity, pamelaModelFactory); + public DeleteCommand(I deletedObject, ModelEntity<I> modelEntity, PamelaModelFactory pamelaModelFactory, String replicaId) { + super(modelEntity, pamelaModelFactory, replicaId); this.deletedObject = deletedObject; } diff --git a/pamela-core/src/main/java/org/openflexo/pamela/undo/RemoveCommand.java b/pamela-core/src/main/java/org/openflexo/pamela/undo/RemoveCommand.java index a68419aa..14cbda92 100644 --- a/pamela-core/src/main/java/org/openflexo/pamela/undo/RemoveCommand.java +++ b/pamela-core/src/main/java/org/openflexo/pamela/undo/RemoveCommand.java @@ -61,8 +61,8 @@ public class RemoveCommand<I> extends AtomicEdit<I> { private ModelProperty<? super I> modelProperty; public RemoveCommand(I updatedObject, ModelEntity<I> modelEntity, ModelProperty<? super I> modelProperty, Object removedValue, - PamelaModelFactory pamelaModelFactory) { - super(modelEntity, pamelaModelFactory); + PamelaModelFactory pamelaModelFactory, String replicaId) { + super(modelEntity, pamelaModelFactory, replicaId); this.updatedObject = updatedObject; this.modelProperty = modelProperty; this.removedValue = removedValue; diff --git a/pamela-core/src/main/java/org/openflexo/pamela/undo/SetCommand.java b/pamela-core/src/main/java/org/openflexo/pamela/undo/SetCommand.java index 3484cf22..76e23cc9 100644 --- a/pamela-core/src/main/java/org/openflexo/pamela/undo/SetCommand.java +++ b/pamela-core/src/main/java/org/openflexo/pamela/undo/SetCommand.java @@ -63,8 +63,8 @@ public class SetCommand<I> extends AtomicEdit<I> { private ModelProperty<? super I> modelProperty; public SetCommand(I updatedObject, ModelEntity<I> modelEntity, ModelProperty<? super I> modelProperty, Object oldValue, - Object newValue, PamelaModelFactory pamelaModelFactory) { - super(modelEntity, pamelaModelFactory); + Object newValue, PamelaModelFactory pamelaModelFactory, String replicaId) { + super(modelEntity, pamelaModelFactory, replicaId); this.updatedObject = updatedObject; this.modelProperty = modelProperty; this.oldValue = oldValue; diff --git a/pamela-core/src/main/java/org/openflexo/pamela/undo/UndoManager.java b/pamela-core/src/main/java/org/openflexo/pamela/undo/UndoManager.java index 4146b134..1c1aa6c3 100644 --- a/pamela-core/src/main/java/org/openflexo/pamela/undo/UndoManager.java +++ b/pamela-core/src/main/java/org/openflexo/pamela/undo/UndoManager.java @@ -101,10 +101,22 @@ public class UndoManager extends javax.swing.undo.UndoManager implements HasProp private boolean enabled = true; + private String localReplicaId = null; + public UndoManager() { pcSupport = new PropertyChangeSupport(this); } + /** + * Set the local replica ID for filtering undo/redo operations. + * Only edits from this replica will be added to the undo stack. + * + * @param replicaId the local replica ID + */ + public void setLocalReplicaId(String replicaId) { + this.localReplicaId = replicaId; + } + private static final String ANTICIPATED_RECORDING = "AnticipatedRecording"; private static boolean allowsAnticipatedRecording = false; private static CompoundEdit anticipatedRecording; @@ -336,6 +348,15 @@ public synchronized boolean addEdit(UndoableEdit anEdit) { } if (anEdit instanceof AtomicEdit) { + AtomicEdit<?> atomicEdit = (AtomicEdit<?>) anEdit; + + // Filter out edits from remote replicas + String editReplicaId = atomicEdit.getReplicaId(); + if (editReplicaId != null && localReplicaId != null && !editReplicaId.equals(localReplicaId)) { + logger.fine("Ignoring edit from remote replica: " + editReplicaId + " (local: " + localReplicaId + ")"); + anEdit.die(); + return false; + } // If UNDO is in progress, ignore it if (undoInProgress) { diff --git a/pamela-core/src/test/java/org/openflexo/pamela/test/sync/CollaborativeDocument.java b/pamela-core/src/test/java/org/openflexo/pamela/test/sync/CollaborativeDocument.java new file mode 100644 index 00000000..8055caaf --- /dev/null +++ b/pamela-core/src/test/java/org/openflexo/pamela/test/sync/CollaborativeDocument.java @@ -0,0 +1,112 @@ +/** + * Copyright (c) 2013-2015, Openflexo + * + * This file is part of Pamela-core, a component of the software infrastructure + * developed at Openflexo. + * + * Openflexo is dual-licensed under the European Union Public License (EUPL, either + * version 1.1 of the License, or any later version ), which is available at + * https://joinup.ec.europa.eu/software/page/eupl/licence-eupl + * and the GNU General Public License (GPL, either version 3 of the License, or any + * later version), which is available at http://www.gnu.org/licenses/gpl.html . + */ + +package org.openflexo.pamela.test.sync; + +import java.util.List; + +import org.openflexo.pamela.AccessibleProxyObject; +import org.openflexo.pamela.annotations.Adder; +import org.openflexo.pamela.annotations.Getter; +import org.openflexo.pamela.annotations.Getter.Cardinality; +import org.openflexo.pamela.annotations.ModelEntity; +import org.openflexo.pamela.annotations.Remover; +import org.openflexo.pamela.annotations.Setter; +import org.openflexo.pamela.annotations.XMLAttribute; +import org.openflexo.pamela.annotations.XMLElement; + +/** + * A simple PAMELA model entity representing a collaborative document. + * This entity is designed to be synchronized across multiple replicas + * using the RabbitMQ-based sync infrastructure. + * + * @author PAMELA Sync Test + */ +@ModelEntity +@XMLElement(xmlTag = "CollaborativeDocument") +public interface CollaborativeDocument extends AccessibleProxyObject { + + // Property keys + String TITLE = "title"; + String CONTENT = "content"; + String AUTHOR = "author"; + String VERSION = "version"; + String TAGS = "tags"; + String SECTIONS = "sections"; + + // ========== TITLE ========== + + @Getter(value = TITLE, defaultValue = "Untitled") + @XMLAttribute + String getTitle(); + + @Setter(TITLE) + void setTitle(String title); + + // ========== CONTENT ========== + + @Getter(value = CONTENT, defaultValue = "") + @XMLAttribute + String getContent(); + + @Setter(CONTENT) + void setContent(String content); + + // ========== AUTHOR ========== + + @Getter(value = AUTHOR, defaultValue = "Anonymous") + @XMLAttribute + String getAuthor(); + + @Setter(AUTHOR) + void setAuthor(String author); + + // ========== VERSION ========== + + @Getter(value = VERSION, defaultValue = "1") + @XMLAttribute + int getVersion(); + + @Setter(VERSION) + void setVersion(int version); + + // ========== TAGS (multi-valued) ========== + + @Getter(value = TAGS, cardinality = Cardinality.LIST) + List<String> getTags(); + + @Setter(TAGS) + void setTags(List<String> tags); + + @Adder(TAGS) + void addToTags(String tag); + + @Remover(TAGS) + void removeFromTags(String tag); + + // ========== SECTIONS (embedded entities) ========== + + @Getter(value = SECTIONS, cardinality = Cardinality.LIST) + @XMLElement + List<DocumentSection> getSections(); + + @Setter(SECTIONS) + void setSections(List<DocumentSection> sections); + + @Adder(SECTIONS) + void addToSections(DocumentSection section); + + @Remover(SECTIONS) + void removeFromSections(DocumentSection section); + +} diff --git a/pamela-core/src/test/java/org/openflexo/pamela/test/sync/CollaborativeDocumentSyncTest.java b/pamela-core/src/test/java/org/openflexo/pamela/test/sync/CollaborativeDocumentSyncTest.java new file mode 100644 index 00000000..c98d3094 --- /dev/null +++ b/pamela-core/src/test/java/org/openflexo/pamela/test/sync/CollaborativeDocumentSyncTest.java @@ -0,0 +1,420 @@ +/** + * Copyright (c) 2013-2015, Openflexo + * + * This file is part of Pamela-core, a component of the software infrastructure + * developed at Openflexo. + * + * Openflexo is dual-licensed under the European Union Public License (EUPL, either + * version 1.1 of the License, or any later version ), which is available at + * https://joinup.ec.europa.eu/software/page/eupl/licence-eupl + * and the GNU General Public License (GPL, either version 3 of the License, or any + * later version), which is available at http://www.gnu.org/licenses/gpl.html . + */ + +package org.openflexo.pamela.test.sync; + +import static org.junit.Assert.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.openflexo.pamela.factory.PamelaModelFactory; +import org.openflexo.pamela.sync.ObjectIdentityManager; +import org.openflexo.pamela.sync.RabbitMQSyncManager; +import org.openflexo.pamela.sync.SyncEditingContext; +import org.openflexo.pamela.sync.SyncOperation; +import org.openflexo.pamela.sync.SyncOperationListener; + +/** + * Test demonstrating collaborative synchronization of PAMELA objects + * across multiple replicas using RabbitMQ. + * + * This test simulates two computers (Replica A and Replica B) working on + * the same document. When Replica A modifies a property, Replica B should + * receive the change and update its local instance. + * + * PREREQUISITES: + * - RabbitMQ server must be running on localhost:5672 + * - Default guest/guest credentials (or configure as needed) + * + * To run RabbitMQ locally with Docker: + * docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3-management + * + * @author PAMELA Sync Test + */ +public class CollaborativeDocumentSyncTest { + + // CloudAMQP configuration + private static final String RABBITMQ_HOST = "rat.rmq2.cloudamqp.com"; + private static final int RABBITMQ_PORT = 5671; + private static final String RABBITMQ_USERNAME = "gcyabtej"; + private static final String RABBITMQ_PASSWORD = "C91PisA-dAYuoVTxHRnzU1RCU1fERHeU"; + private static final String RABBITMQ_VHOST = "gcyabtej"; + private static final boolean USE_SSL = true; + private static final String EXCHANGE_NAME = "pamela-sync-test"; + + // Replica A (simulates Computer 1) + private PamelaModelFactory factoryA; + private SyncEditingContext contextA; + private RabbitMQSyncManager syncManagerA; + + // Replica B (simulates Computer 2) + private PamelaModelFactory factoryB; + private SyncEditingContext contextB; + private RabbitMQSyncManager syncManagerB; + + // Test synchronization helpers + private List<SyncOperation> receivedOperationsA = new ArrayList<>(); + private List<SyncOperation> receivedOperationsB = new ArrayList<>(); + private CountDownLatch operationLatchB; + + @Before + public void setUp() throws Exception { + // Initialize Replica A + factoryA = new PamelaModelFactory(CollaborativeDocument.class); + contextA = new SyncEditingContext(factoryA); + factoryA.setEditingContext(contextA); + + syncManagerA = RabbitMQSyncManager.builder() + .host(RABBITMQ_HOST) + .port(RABBITMQ_PORT) + .credentials(RABBITMQ_USERNAME, RABBITMQ_PASSWORD) + .virtualHost(RABBITMQ_VHOST) + .useSsl(USE_SSL) + .exchangeName(EXCHANGE_NAME) + .build(); + + // Initialize Replica B + factoryB = new PamelaModelFactory(CollaborativeDocument.class); + contextB = new SyncEditingContext(factoryB); + factoryB.setEditingContext(contextB); + + syncManagerB = RabbitMQSyncManager.builder() + .host(RABBITMQ_HOST) + .port(RABBITMQ_PORT) + .credentials(RABBITMQ_USERNAME, RABBITMQ_PASSWORD) + .virtualHost(RABBITMQ_VHOST) + .useSsl(USE_SSL) + .exchangeName(EXCHANGE_NAME) + .build(); + } + + @After + public void tearDown() { + if (syncManagerA != null) { + syncManagerA.disconnect(); + } + if (syncManagerB != null) { + syncManagerB.disconnect(); + } + } + + /** + * Test that creating an object on Replica A and modifying it + * propagates the changes to Replica B. + * + * Scenario: + * 1. Replica A creates a CollaborativeDocument + * 2. Replica A sets the title to "Hello from Computer A" + * 3. Replica B receives the SET operation + * 4. Replica B applies the change to its local instance + */ + @Test + public void testSetterPropagation() throws Exception { + // Skip if RabbitMQ is not available + if (!isRabbitMQAvailable()) { + System.out.println("SKIPPING TEST: RabbitMQ not available on " + RABBITMQ_HOST + ":" + RABBITMQ_PORT); + return; + } + + // Connect both replicas + syncManagerA.connect(); + syncManagerB.connect(); + + // Set up listeners + contextA.setSyncManager(syncManagerA); + syncManagerA.addListener(contextA); + syncManagerA.addListener(new TestOperationListener(receivedOperationsA, null)); + + contextB.setSyncManager(syncManagerB); + syncManagerB.addListener(contextB); + syncManagerB.addListener(new TestOperationListener(receivedOperationsB, null)); // Don't use latch yet + + // Give time for queues to be set up and flush any old messages + Thread.sleep(1000); + + // NOW clear any stale operations + receivedOperationsB.clear(); + receivedOperationsA.clear(); + + // ========== REPLICA A: Create document ========== + CollaborativeDocument docA = factoryA.newInstance(CollaborativeDocument.class); + + // Get the object ID assigned to docA + String docId = contextA.getIdentityManager().getOrCreateObjectId(docA); + assertNotNull("Document should have an ID", docId); + System.out.println("[Replica A] Created document with ID: " + docId); + + // ========== REPLICA B: Create corresponding local instance ========== + // In a real scenario, this would be done when receiving the CREATE operation + CollaborativeDocument docB = factoryB.newInstance(CollaborativeDocument.class); + contextB.getIdentityManager().registerObject(docB, docId); + System.out.println("[Replica B] Registered local document with same ID: " + docId); + + // Now set up the countdown latch for the operations we care about + operationLatchB = new CountDownLatch(2); // CREATE + SET + syncManagerB.addListener(new TestOperationListener(null, operationLatchB)); + + // ========== REPLICA A: Modify the document ========== + System.out.println("[Replica A] Setting title to 'Hello from Computer A'"); + docA.setTitle("Hello from Computer A"); + + // Wait for Replica B to receive both CREATE and SET operations + boolean received = operationLatchB.await(5, TimeUnit.SECONDS); + assertTrue("Replica B should have received the operations", received); + + // Small wait to ensure all operations are processed + Thread.sleep(200); + + // Verify the operation was received + assertFalse("Replica B should have received operations", receivedOperationsB.isEmpty()); + + // Debug: print all received operations + System.out.println("[DEBUG] Total operations received: " + receivedOperationsB.size()); + for (SyncOperation op : receivedOperationsB) { + System.out.println(" - " + op.getOperationType() + " on objectId=" + op.getObjectId() + " property=" + op.getPropertyIdentifier()); + } + + // Find the SET operation for this specific document + SyncOperation setOp = null; + for (SyncOperation op : receivedOperationsB) { + if (op.getOperationType() == SyncOperation.OperationType.SET + && "title".equals(op.getPropertyIdentifier()) + && docId.equals(op.getObjectId())) { + setOp = op; + break; + } + } + + assertNotNull("Should have received a SET operation for title", setOp); + System.out.println("[Replica B] Received operation: " + setOp.getOperationType() + + " on property '" + setOp.getPropertyIdentifier() + "'"); + + assertEquals("Operation type should be SET", SyncOperation.OperationType.SET, setOp.getOperationType()); + assertEquals("Property should be 'title'", "title", setOp.getPropertyIdentifier()); + assertEquals("New value should match", "Hello from Computer A", setOp.getNewValueSerialized()); + + // Verify Replica B's document was updated + assertEquals("Replica B's document title should be updated", + "Hello from Computer A", docB.getTitle()); + + System.out.println("[Replica B] Local document title updated to: " + docB.getTitle()); + System.out.println("✓ Test passed: Setter propagation works!"); + } + /** + * Test that adding items to a list on Replica A propagates to Replica B. + */ + @Test + public void testAdderPropagation() throws Exception { + if (!isRabbitMQAvailable()) { + System.out.println("SKIPPING TEST: RabbitMQ not available"); + return; + } + + // Expecting CREATE + 2 ADD operations + operationLatchB = new CountDownLatch(3); + + // Connect replicas + syncManagerA.connect(); + syncManagerB.connect(); + + contextA.setSyncManager(syncManagerA); + syncManagerA.addListener(contextA); + + contextB.setSyncManager(syncManagerB); + syncManagerB.addListener(contextB); + syncManagerB.addListener(new TestOperationListener(receivedOperationsB, operationLatchB)); + + Thread.sleep(1000); // Increased wait time + + // Create document on Replica A ONLY + CollaborativeDocument docA = factoryA.newInstance(CollaborativeDocument.class); + String docId = contextA.getIdentityManager().getOrCreateObjectId(docA); + + System.out.println("[Replica A] Created document with ID: " + docId); + + // Wait for CREATE operation to propagate + Thread.sleep(500); + + // NOW create the corresponding object on Replica B + // This should ideally happen automatically when CREATE is received, + // but for this test we do it manually + CollaborativeDocument docB = factoryB.newInstance(CollaborativeDocument.class); + contextB.getIdentityManager().registerObject(docB, docId); + System.out.println("[Replica B] Registered local document with same ID: " + docId); + + // Reset latch for just the ADD operations + operationLatchB = new CountDownLatch(2); + syncManagerB.addListener(new TestOperationListener(receivedOperationsB, operationLatchB)); + + // Add tags on Replica A + System.out.println("[Replica A] Adding tags 'java' and 'pamela'"); + docA.addToTags("java"); + docA.addToTags("pamela"); + + // Wait for ADD operations + boolean received = operationLatchB.await(10, TimeUnit.SECONDS); // Increased timeout + assertTrue("Replica B should have received ADD operations", received); + + // Debug: Print received operations + System.out.println("[Replica B] Received " + receivedOperationsB.size() + " operations"); + for (SyncOperation op : receivedOperationsB) { + System.out.println(" - " + op.getOperationType() + " on " + op.getPropertyIdentifier()); + } + + // Debug: Print current tags + System.out.println("[Replica B] Current tags: " + docB.getTags()); + System.out.println("[Replica B] Tags size: " + docB.getTags().size()); + + // Verify tags were added on Replica B + assertTrue("Replica B should have 'java' tag", docB.getTags().contains("java")); + assertTrue("Replica B should have 'pamela' tag", docB.getTags().contains("pamela")); + + System.out.println("[Replica B] Tags: " + docB.getTags()); + System.out.println("✓ Test passed: Adder propagation works!"); + } + + /** + * Test bidirectional synchronization - both replicas can make changes. + */ + @Test + public void testBidirectionalSync() throws Exception { + if (!isRabbitMQAvailable()) { + System.out.println("SKIPPING TEST: RabbitMQ not available"); + return; + } + + // Connect replicas + syncManagerA.connect(); + syncManagerB.connect(); + + contextA.setSyncManager(syncManagerA); + syncManagerA.addListener(contextA); + + contextB.setSyncManager(syncManagerB); + syncManagerB.addListener(contextB); + + Thread.sleep(1000); + + // ========== FIRST: Test A -> B (we know this works) ========== + CollaborativeDocument docA = factoryA.newInstance(CollaborativeDocument.class); + String docId = contextA.getIdentityManager().getOrCreateObjectId(docA); + System.out.println("[Test] Created docA with ID: " + docId); + + Thread.sleep(500); + + CollaborativeDocument docB = factoryB.newInstance(CollaborativeDocument.class); + contextB.getIdentityManager().registerObject(docB, docId); + System.out.println("[Test] Registered docB with same ID"); + + System.out.println("\n[TEST 1] A -> B: Setting content on A"); + docA.setContent("Content from A"); + Thread.sleep(1000); + + assertEquals("Content from A", docB.getContent()); + System.out.println("[✓] A -> B works"); + + // ========== SECOND: Test B -> A (this is failing) ========== + // The problem: docB was created BEFORE syncManagerB was attached, + // so changes to docB aren't being tracked! + + // Let's try creating a FRESH document on B AFTER sync is set up + System.out.println("\n[TEST 2] B -> A: Creating NEW document on B"); + + CollaborativeDocument docB2 = factoryB.newInstance(CollaborativeDocument.class); + String docId2 = contextB.getIdentityManager().getOrCreateObjectId(docB2); + System.out.println("[Test] Created docB2 with ID: " + docId2); + + Thread.sleep(500); + + // Register corresponding object on A + CollaborativeDocument docA2 = factoryA.newInstance(CollaborativeDocument.class); + contextA.getIdentityManager().registerObject(docA2, docId2); + System.out.println("[Test] Registered docA2 with same ID"); + + // NOW try to modify docB2 + System.out.println("[Test] Setting author on docB2"); + docB2.setAuthor("User from B"); + Thread.sleep(2000); + + System.out.println("[Debug] docA2.getAuthor() = " + docA2.getAuthor()); + System.out.println("[Debug] docB2.getAuthor() = " + docB2.getAuthor()); + + assertEquals("User from B", docA2.getAuthor()); + System.out.println("[✓] B -> A works"); + + System.out.println("\n✓ Bidirectional sync works!"); + } + /** + * Check if RabbitMQ is available for testing. + */ + private boolean isRabbitMQAvailable() { + try { + RabbitMQSyncManager testManager = RabbitMQSyncManager.builder() + .host(RABBITMQ_HOST) + .port(RABBITMQ_PORT) + .credentials(RABBITMQ_USERNAME, RABBITMQ_PASSWORD) + .virtualHost(RABBITMQ_VHOST) + .useSsl(USE_SSL) + .build(); + testManager.connect(); + testManager.disconnect(); + return true; + } catch (Exception e) { + return false; + } + } + + /** + * Helper listener to capture operations for test verification. + */ + private static class TestOperationListener implements SyncOperationListener { + private final List<SyncOperation> operations; + private final CountDownLatch latch; + + public TestOperationListener(List<SyncOperation> operations, CountDownLatch latch) { + this.operations = operations; + this.latch = latch; + } + + @Override + public void onOperationReceived(SyncOperation operation) { + if (operations != null) { + operations.add(operation); + } + if (latch != null) { + latch.countDown(); + } + } + + @Override + public void onConnected() { + System.out.println(" [Listener] Connected to RabbitMQ"); + } + + @Override + public void onDisconnected(String reason) { + System.out.println(" [Listener] Disconnected from RabbitMQ: " + reason); + } + + @Override + public void onError(Throwable e) { + System.err.println(" [Listener] Error: " + e.getMessage()); + } + } +} diff --git a/pamela-core/src/test/java/org/openflexo/pamela/test/sync/DocumentSection.java b/pamela-core/src/test/java/org/openflexo/pamela/test/sync/DocumentSection.java new file mode 100644 index 00000000..8aa12bb9 --- /dev/null +++ b/pamela-core/src/test/java/org/openflexo/pamela/test/sync/DocumentSection.java @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2013-2015, Openflexo + * + * This file is part of Pamela-core, a component of the software infrastructure + * developed at Openflexo. + * + * Openflexo is dual-licensed under the European Union Public License (EUPL, either + * version 1.1 of the License, or any later version ), which is available at + * https://joinup.ec.europa.eu/software/page/eupl/licence-eupl + * and the GNU General Public License (GPL, either version 3 of the License, or any + * later version), which is available at http://www.gnu.org/licenses/gpl.html . + */ + +package org.openflexo.pamela.test.sync; + +import org.openflexo.pamela.AccessibleProxyObject; +import org.openflexo.pamela.annotations.Getter; +import org.openflexo.pamela.annotations.ModelEntity; +import org.openflexo.pamela.annotations.Setter; +import org.openflexo.pamela.annotations.XMLAttribute; +import org.openflexo.pamela.annotations.XMLElement; + +/** + * A section within a collaborative document. + * Demonstrates nested entity synchronization. + * + * @author PAMELA Sync Test + */ +@ModelEntity +@XMLElement(xmlTag = "DocumentSection") +public interface DocumentSection extends AccessibleProxyObject { + + String HEADING = "heading"; + String BODY = "body"; + String ORDER = "order"; + + @Getter(value = HEADING, defaultValue = "New Section") + @XMLAttribute + String getHeading(); + + @Setter(HEADING) + void setHeading(String heading); + + @Getter(value = BODY, defaultValue = "") + @XMLAttribute + String getBody(); + + @Setter(BODY) + void setBody(String body); + + @Getter(value = ORDER, defaultValue = "0") + @XMLAttribute + int getOrder(); + + @Setter(ORDER) + void setOrder(int order); + +} diff --git a/pamela-core/src/test/java/org/openflexo/pamela/test/sync/SyncInfrastructureTest.java b/pamela-core/src/test/java/org/openflexo/pamela/test/sync/SyncInfrastructureTest.java new file mode 100644 index 00000000..5a26d761 --- /dev/null +++ b/pamela-core/src/test/java/org/openflexo/pamela/test/sync/SyncInfrastructureTest.java @@ -0,0 +1,354 @@ +/** + * Copyright (c) 2013-2015, Openflexo + * + * This file is part of Pamela-core, a component of the software infrastructure + * developed at Openflexo. + * + * Openflexo is dual-licensed under the European Union Public License (EUPL, either + * version 1.1 of the License, or any later version ), which is available at + * https://joinup.ec.europa.eu/software/page/eupl/licence-eupl + * and the GNU General Public License (GPL, either version 3 of the License, or any + * later version), which is available at http://www.gnu.org/licenses/gpl.html . + */ + +package org.openflexo.pamela.test.sync; + +import static org.junit.Assert.*; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.Before; +import org.junit.Test; +import org.openflexo.pamela.factory.PamelaModelFactory; +import org.openflexo.pamela.sync.ObjectIdentityManager; +import org.openflexo.pamela.sync.SyncEditingContext; +import org.openflexo.pamela.sync.SyncOperation; +import org.openflexo.pamela.sync.SyncOperationListener; +import org.openflexo.pamela.sync.SyncOperationSerializer; +import org.openflexo.pamela.sync.VectorClock; + +/** + * Unit tests for the synchronization infrastructure without requiring RabbitMQ. + * These tests validate the core sync components in isolation. + * + * @author PAMELA Sync Test + */ +public class SyncInfrastructureTest { + + private PamelaModelFactory factory; + private SyncEditingContext context; + private List<SyncOperation> capturedOperations; + + @Before + public void setUp() throws Exception { + factory = new PamelaModelFactory(CollaborativeDocument.class); + context = new SyncEditingContext(factory); + factory.setEditingContext(context); + capturedOperations = new ArrayList<>(); + + // Use a mock sync manager that captures operations locally + context.setSyncManager(new LocalCaptureSyncManager(capturedOperations)); + } + + // ========== Vector Clock Tests ========== + + @Test + public void testVectorClockIncrement() { + VectorClock clock = new VectorClock(); + + assertEquals(0, clock.get("replica1")); + + clock.increment("replica1"); + assertEquals(1, clock.get("replica1")); + + clock.increment("replica1"); + assertEquals(2, clock.get("replica1")); + } + + @Test + public void testVectorClockMerge() { + VectorClock clockA = new VectorClock(); + VectorClock clockB = new VectorClock(); + + clockA.increment("A"); // A: {A=1} + clockA.increment("A"); // A: {A=2} + + clockB.increment("B"); // B: {B=1} + + clockA.merge(clockB); // A: {A=2, B=1} + + assertEquals(2, clockA.get("A")); + assertEquals(1, clockA.get("B")); + } + + @Test + public void testVectorClockHappenedBefore() { + VectorClock clock1 = new VectorClock(); + VectorClock clock2 = new VectorClock(); + + clock1.increment("A"); // {A=1} + clock2.increment("A"); // {A=1} + clock2.increment("A"); // {A=2} + + assertTrue("clock1 should happen before clock2", clock1.happenedBefore(clock2)); + assertFalse("clock2 should NOT happen before clock1", clock2.happenedBefore(clock1)); + } + + @Test + public void testVectorClockConcurrent() { + VectorClock clockA = new VectorClock(); + VectorClock clockB = new VectorClock(); + + clockA.increment("A"); // A: {A=1} + clockB.increment("B"); // B: {B=1} + + assertTrue("Concurrent operations should be detected", clockA.isConcurrent(clockB)); + assertTrue("Concurrent is symmetric", clockB.isConcurrent(clockA)); + } + + // ========== Object Identity Manager Tests ========== + + @Test + public void testObjectIdentityRegistration() { + ObjectIdentityManager manager = new ObjectIdentityManager(); + + CollaborativeDocument doc = factory.newInstance(CollaborativeDocument.class); + + String id1 = manager.getOrCreateObjectId(doc); + assertNotNull("Should generate ID", id1); + + String id2 = manager.getOrCreateObjectId(doc); + assertEquals("Same object should return same ID", id1, id2); + } + + @Test + public void testObjectIdentityLookup() { + ObjectIdentityManager manager = new ObjectIdentityManager(); + + CollaborativeDocument doc = factory.newInstance(CollaborativeDocument.class); + String id = manager.getOrCreateObjectId(doc); + + Object retrieved = manager.getObject(id); + assertSame("Should retrieve the same object", doc, retrieved); + } + + @Test + public void testObjectIdentityExplicitRegistration() { + ObjectIdentityManager manager = new ObjectIdentityManager(); + + CollaborativeDocument doc = factory.newInstance(CollaborativeDocument.class); + String customId = "my-custom-id-12345"; + + manager.registerObject(doc, customId); + + assertEquals("Should use custom ID", customId, manager.getOrCreateObjectId(doc)); + assertSame("Should retrieve by custom ID", doc, manager.getObject(customId)); + } + + // ========== SyncOperation Serialization Tests ========== + + @Test + public void testSyncOperationSerialization() throws Exception { + VectorClock clock = new VectorClock(); + clock.increment("replica1"); + + SyncOperation original = new SyncOperation.Builder(SyncOperation.OperationType.SET) + .objectId("obj-123") + .entityType("CollaborativeDocument") + .propertyIdentifier("title") + .oldValue("Old Title") + .newValue("New Title") + .vectorClock(clock) + .replicaId("replica1") + .build(); + + SyncOperationSerializer serializer = new SyncOperationSerializer(); + + // Serialize to JSON + String json = serializer.serialize(original); + assertNotNull("Should serialize to JSON", json); + assertTrue("JSON should contain operation type", json.contains("SET")); + assertTrue("JSON should contain property", json.contains("title")); + + // Deserialize back + SyncOperation deserialized = serializer.deserialize(json); + assertNotNull("Should deserialize", deserialized); + assertEquals("Operation type should match", original.getOperationType(), deserialized.getOperationType()); + assertEquals("Object ID should match", original.getObjectId(), deserialized.getObjectId()); + assertEquals("Property should match", original.getPropertyIdentifier(), deserialized.getPropertyIdentifier()); + assertEquals("New value should match", original.getNewValueSerialized(), deserialized.getNewValueSerialized()); + assertEquals("Replica ID should match", original.getReplicaId(), deserialized.getReplicaId()); + } + + @Test + public void testSyncOperationBytesSerialization() throws Exception { + SyncOperation original = new SyncOperation.Builder(SyncOperation.OperationType.ADD) + .objectId("obj-456") + .entityType("CollaborativeDocument") + .propertyIdentifier("tags") + .newValue("new-tag") + .replicaId("replica2") + .build(); + + SyncOperationSerializer serializer = new SyncOperationSerializer(); + + byte[] bytes = serializer.serializeToBytes(original); + assertNotNull("Should serialize to bytes", bytes); + assertTrue("Bytes should not be empty", bytes.length > 0); + + SyncOperation deserialized = serializer.deserializeFromBytes(bytes); + assertNotNull("Should deserialize from bytes", deserialized); + assertEquals(SyncOperation.OperationType.ADD, deserialized.getOperationType()); + assertEquals("tags", deserialized.getPropertyIdentifier()); + } + + // ========== SyncEditingContext Operation Capture Tests ========== + + @Test + public void testSetOperationCapture() { + capturedOperations.clear(); + + CollaborativeDocument doc = factory.newInstance(CollaborativeDocument.class); + + // Modify the document + doc.setTitle("Test Title"); + + // Check that a SET operation was captured + assertFalse("Should capture SET operation", capturedOperations.isEmpty()); + + SyncOperation op = findOperationByProperty("title"); + assertNotNull("Should have title operation", op); + assertEquals(SyncOperation.OperationType.SET, op.getOperationType()); + assertEquals("Test Title", op.getNewValueSerialized()); + } + + @Test + public void testAddOperationCapture() { + capturedOperations.clear(); + + CollaborativeDocument doc = factory.newInstance(CollaborativeDocument.class); + + // Add a tag + doc.addToTags("java"); + + // Check that an ADD operation was captured + SyncOperation op = findOperationByProperty("tags"); + assertNotNull("Should have tags operation", op); + assertEquals(SyncOperation.OperationType.ADD, op.getOperationType()); + assertEquals("java", op.getNewValueSerialized()); + } + + @Test + public void testRemoveOperationCapture() { + capturedOperations.clear(); + + CollaborativeDocument doc = factory.newInstance(CollaborativeDocument.class); + doc.addToTags("java"); + + capturedOperations.clear(); // Clear the ADD operation + + // Remove the tag + doc.removeFromTags("java"); + + // Check that a REMOVE operation was captured + SyncOperation op = findOperationByProperty("tags"); + assertNotNull("Should have tags operation", op); + assertEquals(SyncOperation.OperationType.REMOVE, op.getOperationType()); + } + + @Test + public void testMultipleOperationsCapture() { + capturedOperations.clear(); + + CollaborativeDocument doc = factory.newInstance(CollaborativeDocument.class); + + doc.setTitle("My Document"); + doc.setAuthor("John Doe"); + doc.setContent("Some content here"); + doc.addToTags("tag1"); + doc.addToTags("tag2"); + + // Should have captured multiple operations + assertTrue("Should have multiple operations", capturedOperations.size() >= 5); + + // Verify we have the expected operations + assertNotNull("Should have title op", findOperationByProperty("title")); + assertNotNull("Should have author op", findOperationByProperty("author")); + assertNotNull("Should have content op", findOperationByProperty("content")); + } + + // ========== Nested Entity Tests ========== + + @Test + public void testNestedEntityOperations() { + capturedOperations.clear(); + + CollaborativeDocument doc = factory.newInstance(CollaborativeDocument.class); + DocumentSection section = factory.newInstance(DocumentSection.class); + + section.setHeading("Introduction"); + section.setBody("This is the introduction section."); + + capturedOperations.clear(); + + doc.addToSections(section); + + // Should capture the ADD operation for sections + SyncOperation op = findOperationByProperty("sections"); + assertNotNull("Should have sections ADD operation", op); + assertEquals(SyncOperation.OperationType.ADD, op.getOperationType()); + } + + // ========== Helper Methods ========== + + private SyncOperation findOperationByProperty(String propertyName) { + for (SyncOperation op : capturedOperations) { + if (propertyName.equals(op.getPropertyIdentifier())) { + return op; + } + } + return null; + } + + /** + * A local mock sync manager that captures operations without network communication. + */ + private static class LocalCaptureSyncManager implements org.openflexo.pamela.sync.SyncManager { + private final List<SyncOperation> capturedOperations; + private final List<SyncOperationListener> listeners = new ArrayList<>(); + + public LocalCaptureSyncManager(List<SyncOperation> capturedOperations) { + this.capturedOperations = capturedOperations; + } + + @Override + public void publishOperation(SyncOperation operation) { + capturedOperations.add(operation); + // Notify listeners (simulating message reception) + for (SyncOperationListener listener : listeners) { + listener.onOperationReceived(operation); + } + } + + @Override + public void addListener(SyncOperationListener listener) { + listeners.add(listener); + } + + @Override + public void removeListener(SyncOperationListener listener) { + listeners.remove(listener); + } + + @Override + public boolean isConnected() { + return true; + } + + @Override + public String getReplicaId() { + return "test-replica"; + } + } +} diff --git a/settings.gradle b/settings.gradle index 0e0b8df5..ace68624 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,4 +1,5 @@ include 'pamela-core' include 'pamela-security-patterns' +include 'book' //include 'pamela-perf-tests' //include 'pamela-spring-security-uc'