diff --git a/memorystore/valkey/caching/demo/README.md b/memorystore/valkey/caching/demo/README.md new file mode 100644 index 00000000000..5f9b752e4f8 --- /dev/null +++ b/memorystore/valkey/caching/demo/README.md @@ -0,0 +1,120 @@ +# Caching Demo Application + +This demo shows how to use Valkey as an in-memory cache to accelerate data retrieval in a Spring Boot application. By storing hot data in Valkey, you can reduce the number of queries to your PostgreSQL database, improving performance and scalability. + +## Running locally + +## Run locally using Docker + +You can use [docker compose](https://docs.docker.com/compose/install/) to run the application locally. Run the following: + +```bash +cd api +docker-compose up --build +``` + +You can also run with sample data. Run the following: + +```bash +cd sample-data +docker-compose up --build +``` + +You can also run with a web application. Run the following: + +```bash +cd web +docker-compose up --build +``` + +Once the application is running, you can access it at [http://localhost:8080](http://localhost:8080) for local development, or the URL provided by Cloud Run for production. + +## How to deploy the application to Google Cloud (Solo API) + +1. You can use [Terraform](https://learn.hashicorp.com/tutorials/terraform/install-cli) to deploy the infrastructure to Google Cloud. Run the following: + + ```bash + cd api + terraform init + terraform apply + ``` + + It should fail to ceate the Google Cloud Run service, but don't worry, we'll fix that in the next series of steps. The message you get might look like this: + + ```bash + Error waiting to create Service: Error waiting for Creating Service: Error code 13, message: Revision 'caching-app-service-00001-9zj' is not ready and cannot serve traffic. Image 'gcr.io/cloudannot serve traffic. Image 'gcr.io/cloud-memorystore-demos/caching-app:latest' not found. + ``` + +2. Once the infrastructure is created, you'll need to run the `init.sql` script in the Cloud SQL instance to create the necessary tables. You can use the Cloud Shell to do this. Run the following command in the Cloud Shell: + + ```bash + gcloud sql connect --database=caching-app-db --user=admin # The admin and database were created in the Terraform script + ``` + + Note: Ensure that the instance name is the same as the one you used in the Terraform script. + + a. When prompted to enable the Cloud SQL Admin API, type `Y` and press `Enter`. + b. When prompted to enter the password, type the password you set in the Terraform script and press `Enter`. + c. Once you're connected to the Cloud SQL instance, run the following command to run the `init.sql` script: + + ```sql + \i init.sql + ``` + +3. Finally, redeploy the Cloud Run service using the local source code. Run the following command: + + ```bash + gcloud run deploy \ + --source=. \ + --region= + ``` + + Note: Ensure that the instance name and region are the same as the ones you used in the Terraform script. + +Now you should have the application running on Google Cloud. + +## How to deploy the application to Google Cloud (Web App + API) + +1. Push your Docker images for the Web App and API to Google Container Registry. Run the following: + + ```bash + cd web + docker build -t gcr.io//caching-app-web . + docker push gcr.io//caching-app-web + + cd ../api + docker build -t gcr.io//caching-app-api . + docker push gcr.io//caching-app-api + ``` + +2. You can use [Terraform](https://learn.hashicorp.com/tutorials/terraform/install-cli) to deploy the infrastructure to Google Cloud. Run the following: + + ```bash + cd web + terraform init + terraform apply + ``` + +3. Once the infrastructure is created, you'll need to run the `init.sql` script in the Cloud SQL instance to create the necessary tables. You can use the Cloud Shell to do this. Run the following command in the Cloud Shell: + + ```bash + gcloud sql connect --database=caching-app-db --user=admin # The admin and database were created in the Terraform script + ``` + + Note: Ensure that the instance name is the same as the one you used in the Terraform script. + + a. When prompted to enable the Cloud SQL Admin API, type `Y` and press `Enter`. + b. When prompted to enter the password, type the password you set in the Terraform script and press `Enter`. + c. Once you're connected to the Cloud SQL instance, run the following command to run the `init.sql` script: + + ```sql + \i init.sql + ``` + +Now you should have the application running on Google Cloud. + +### Endpoints + +- `GET /item/{id}`: Get an item by ID +- `POST /item/create`: Create a new item +- `DELETE /item/delete/{id}`: Delete an item by ID diff --git a/memorystore/valkey/caching/demo/app/.gitignore b/memorystore/valkey/caching/demo/app/.gitignore new file mode 100644 index 00000000000..91618798d77 --- /dev/null +++ b/memorystore/valkey/caching/demo/app/.gitignore @@ -0,0 +1,92 @@ +############################## +## Java +############################## +.mtj.tmp/ +*.class +*.jar +*.war +*.ear +*.nar +hs_err_pid* + +############################## +## Maven +############################## +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +pom.xml.bak +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +############################## +## Gradle +############################## +bin/ +build/ +.gradle +.gradletasknamecache +gradle-app.setting +!gradle-wrapper.jar + +############################## +## IntelliJ +############################## +out/ +.idea/ +.idea_modules/ +*.iml +*.ipr +*.iws + +############################## +## Eclipse +############################## +.settings/ +bin/ +tmp/ +.metadata +.classpath +.project +*.tmp +*.bak +*.swp +*~.nib +local.properties +.loadpath +.factorypath + +############################## +## NetBeans +############################## +nbproject/private/ +build/ +nbbuild/ +dist/ +nbdist/ +nbactions.xml +nb-configuration.xml + +############################## +## Visual Studio Code +############################## +.vscode/ +.code-workspace + +############################## +## OS X +############################## +.DS_Store + +############################## +## Terraform +############################## +.terraform/ +.terraform.lock.hcl +terraform.tfstate +terraform.tfstate.backup \ No newline at end of file diff --git a/memorystore/valkey/caching/demo/app/Dockerfile b/memorystore/valkey/caching/demo/app/Dockerfile new file mode 100644 index 00000000000..131e16d0504 --- /dev/null +++ b/memorystore/valkey/caching/demo/app/Dockerfile @@ -0,0 +1,24 @@ +# Use an OpenJDK base image +FROM openjdk:17-jdk-slim + +# Install Maven for building the project +RUN apt-get update && apt-get install -y maven + +# Set the working directory +WORKDIR /app + +# Copy Maven project files +COPY pom.xml ./ +COPY src ./src + +# Build the project +RUN mvn clean package -DskipTests + +# Copy the built JAR file to the container +RUN cp target/app-1.0-SNAPSHOT.jar app.jar + +# Expose the application port +EXPOSE 8080 + +# Run the application +CMD ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/memorystore/valkey/caching/demo/app/docker-compose.yaml b/memorystore/valkey/caching/demo/app/docker-compose.yaml new file mode 100644 index 00000000000..14c09a7ad7e --- /dev/null +++ b/memorystore/valkey/caching/demo/app/docker-compose.yaml @@ -0,0 +1,51 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: caching-app + +services: + valkey: + image: valkey/valkey:latest + ports: + - "6379:6379" + command: ["valkey-server", "--save", "60", "1", "--loglevel", "warning"] + + postgres: + image: postgres:latest + container_name: postgres + ports: + - "5432:5432" + environment: + - POSTGRES_USER=admin + - POSTGRES_PASSWORD=password + - POSTGRES_DB=postgres + volumes: + - ./init.sql:/docker-entrypoint-initdb.d/init.sql + + app: + build: + context: . + dockerfile: Dockerfile + container_name: app + ports: + - "8080:8080" + depends_on: + - postgres + - valkey + environment: + - VALKEY_HOST=valkey + - VALKEY_PORT=6379 + - DB_URL=jdbc:postgresql://postgres:5432/postgres + - DB_USERNAME=admin + - DB_PASSWORD=password diff --git a/memorystore/valkey/caching/demo/app/init.sql b/memorystore/valkey/caching/demo/app/init.sql new file mode 100644 index 00000000000..e6bb11589a7 --- /dev/null +++ b/memorystore/valkey/caching/demo/app/init.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS items ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL, + price DOUBLE PRECISION NOT NULL +); \ No newline at end of file diff --git a/memorystore/valkey/caching/demo/app/main.tf b/memorystore/valkey/caching/demo/app/main.tf new file mode 100644 index 00000000000..1e951a0c5ee --- /dev/null +++ b/memorystore/valkey/caching/demo/app/main.tf @@ -0,0 +1,155 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +provider "google" { + project = "cloud-memorystore-demos" + region = "us-central1" +} + +data "google_project" "project" { + project_id = "cloud-memorystore-demos" +} + +resource "google_compute_network" "app_network" { + name = "caching-app-network" +} + +resource "google_compute_firewall" "allow_http" { + name = "caching-app-allow-http-8080" + network = google_compute_network.app_network.name + + allow { + protocol = "tcp" + ports = ["8080"] + } + + source_ranges = ["0.0.0.0/0"] + + depends_on = [google_compute_network.app_network] +} + +resource "google_cloud_run_v2_service" "app" { + name = "caching-app-service" + location = "us-central1" + + template { + containers { + image = "replace" # Will be set at a later time + + env { + name = "VALKEY_HOST" + value = module.valkey.discovery_endpoints[0]["address"] + } + + env { + name = "VALKEY_PORT" + value = module.valkey.discovery_endpoints[0]["port"] + } + + env { + name = "DB_URL" + value = "jdbc:postgresql://${google_sql_database_instance.postgres.public_ip_address}/${google_sql_database.postgres_db.name}" + } + + env { + name = "DB_USERNAME" + value = google_sql_user.postgres_user.name + } + + env { + name = "DB_PASSWORD" + value = google_sql_user.postgres_user.password + } + + ports { + container_port = 8080 + } + } + + vpc_access { + network_interfaces { + network = google_compute_network.app_network.name + subnetwork = google_compute_network.app_network.name + tags = [] + } + } + } + + depends_on = [ + google_compute_network.app_network, + module.valkey, + google_sql_database_instance.postgres + ] +} + +module "valkey" { + source = "terraform-google-modules/memorystore/google//modules/valkey" + version = "12.0" + + instance_id = "caching-app-valkey-instance" + project_id = data.google_project.project.project_id + location = "us-central1" + node_type = "SHARED_CORE_NANO" + shard_count = 1 + engine_version = "VALKEY_7_2" + + network = google_compute_network.app_network.name + + service_connection_policies = { + caching_valkey_scp = { + subnet_names = [google_compute_network.app_network.name] + } + } + + depends_on = [google_compute_network.app_network] +} + +resource "google_sql_database_instance" "postgres" { + name = "caching-app-postgres-instance" + database_version = "POSTGRES_16" + region = "us-central1" + + settings { + edition = "ENTERPRISE" + tier = "db-custom-1-3840" + + ip_configuration { + ipv4_enabled = true + + authorized_networks { + name = "caching-app-access" + value = "0.0.0.0/0" + } + } + } + + depends_on = [google_compute_network.app_network] +} + +resource "google_sql_user" "postgres_user" { + name = "admin" + instance = google_sql_database_instance.postgres.name + password = "password123" # Set this to the password you want to use for the user + + depends_on = [google_sql_database_instance.postgres] +} + +resource "google_sql_database" "postgres_db" { + name = "caching-app-db" + instance = google_sql_database_instance.postgres.name + + depends_on = [google_sql_database_instance.postgres] +} \ No newline at end of file diff --git a/memorystore/valkey/caching/demo/app/pom.xml b/memorystore/valkey/caching/demo/app/pom.xml new file mode 100644 index 00000000000..67d8b6a6768 --- /dev/null +++ b/memorystore/valkey/caching/demo/app/pom.xml @@ -0,0 +1,168 @@ + + + + + + 4.0.0 + + org.example + app + 1.0-SNAPSHOT + + + + com.google.cloud.samples + shared-configuration + 1.2.0 + + + + 17 + 17 + UTF-8 + + + + + + org.springframework.boot + spring-boot-starter-web + 3.3.6 + + + + + org.slf4j + slf4j-api + 2.0.16 + + + org.slf4j + slf4j-simple + 2.0.16 + + + + + redis.clients + jedis + 4.3.0 + + + + + org.springframework.boot + spring-boot-starter-jdbc + 3.3.6 + + + + + org.springframework.boot + spring-boot-starter-thymeleaf + 3.3.6 + + + + + org.postgresql + postgresql + 42.6.0 + + + + + + org.springframework.boot + spring-boot-starter-test + 3.3.6 + test + + + + + jakarta.validation + jakarta.validation-api + 3.0.2 + + + org.hibernate.validator + hibernate-validator + 8.0.1.Final + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 17 + 17 + + -parameters + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.2.4 + + + package + + shade + + + true + + + app.Main + + + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M8 + + + + \ No newline at end of file diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/DataController.java b/memorystore/valkey/caching/demo/app/src/main/java/app/DataController.java new file mode 100644 index 00000000000..8e88f1f62ed --- /dev/null +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/DataController.java @@ -0,0 +1,147 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Responsible for handling the data operations. + * Handles checking cache first, then database, and updating the cache. + */ + +package app; + +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Controller; +import redis.clients.jedis.Jedis; + +@Controller +public class DataController { + + // Default TTL for cached data is 60 seconds (1 minute) + public static final Long DEFAULT_TTL = 60L; + + private final ItemsRepository itemsRepository; + private final Jedis jedis; + + public DataController(ItemsRepository cacheRepository, Jedis jedis) { + this.itemsRepository = cacheRepository; + this.jedis = jedis; + } + + public Item get(long id) { + String idString = Long.toString(id); + + // Use try-catch to avoid missing the database if there's an error with the + // cache + try { + // Check if the data exists in the cache first + String cachedValue = jedis.get(idString); + if (cachedValue != null) { + // Return the cached data + Item cachedItem = Item.fromJsonString(cachedValue); + cachedItem.setFromCache(true); + return cachedItem; + } + } catch (Exception e) { + // If there's an error with the cache, log the error and continue + System.err.println("Error with cache: " + e.getMessage()); + } + + Optional item = itemsRepository.get(id); + + if (item.isEmpty()) { + // If the data doesn't exist in the database, return null + return null; + } + + // Use try-catch to avoid missing returning the data if there's an error with + // the cache + try { + // Cache result from the database with the default TTL + jedis.setex(idString, DEFAULT_TTL, item.get().toJsonObject().toString()); + } catch (Exception e) { + // If there's an error with the cache, log the error and continue + System.err.println("Error with cache: " + e.getMessage()); + } + + return item.get(); + } + + public List getMultiple(int amount) { + // Get multiple items from the database + return itemsRepository.getMultiple(amount); + } + + public long create(Item item) { + // Create the data in the database + long itemId = itemsRepository.create(item); + + // Clone the item with the generated ID + Item createdItem = new Item( + itemId, + item.getName(), + item.getDescription(), + item.getPrice()); + + // Use try-catch to avoid returning the data if there's an error with the cache + try { + // Cache the data with the default TTL + String idString = Long.toString(itemId); + jedis.setex(idString, DEFAULT_TTL, createdItem.toJsonObject().toString()); + } catch (Exception e) { + // If there's an error with the cache, log the error and continue + System.err.println("Error with cache: " + e.getMessage()); + } + + return itemId; + } + + public void delete(long id) { + // Delete the data from database + itemsRepository.delete(id); + + // Use try-catch to avoid missing the cache if there's an error with the cache + try { + // Also, delete the data from the cache if it exists + String idString = Long.toString(id); + long totalDeleted = jedis.del(idString); + + if (totalDeleted == 0) { + throw new Exception("Item not found in cache"); + } + } catch (Exception e) { + // If there's an error with the cache, log the error and continue + System.err.println("Error with cache: " + e.getMessage()); + } + } + + public boolean exists(long id) { + String idString = Long.toString(id); + + // Use try-catch to avoid missing the database if there's an error with the + // cache + boolean cacheExists = false; + try { + // Check if the data exists in the cache + cacheExists = jedis.exists(idString); + } catch (Exception e) { + // If there's an error with the cache, log the error and continue + System.err.println("Error with cache: " + e.getMessage()); + } + + // Check if the data exists in the cache or the database (check the cache first) + return cacheExists || itemsRepository.exists(id); + } +} diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/HomeController.java b/memorystore/valkey/caching/demo/app/src/main/java/app/HomeController.java new file mode 100644 index 00000000000..cf83ec9a0af --- /dev/null +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/HomeController.java @@ -0,0 +1,35 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class HomeController { + + @Value("${API_URL:localhost:8080}") + private String apiUrl; + + @GetMapping("/") + public String home(Model model) { + model.addAttribute("apiUrl", apiUrl); + return "index"; // Refers to templates/index.html + } +} diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/Item.java b/memorystore/valkey/caching/demo/app/src/main/java/app/Item.java new file mode 100644 index 00000000000..a97dde5f4e5 --- /dev/null +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/Item.java @@ -0,0 +1,102 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Data class representing an item in the application. + */ + +package app; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import org.json.JSONObject; + +public class Item { + + private final Long id; + + @NotNull + private final String name; + + @NotNull + private final String description; + + @NotNull + @Positive + private final Double price; + + private boolean fromCache; + + public Item() { + this(null, "", "", null); + } + + public Item(Long id, String name, String description, Double price) { + this.id = id; + this.name = name; + this.description = description; + this.price = price; + this.fromCache = false; + } + + public Item(String name, String description, Double price) { + this(null, name, description, price); + } + + public Long getId() { + return this.id; + } + + public String getName() { + return this.name; + } + + public String getDescription() { + return this.description; + } + + public Double getPrice() { + return this.price; + } + + public boolean isFromCache() { + return this.fromCache; + } + + public void setFromCache(boolean fromCache) { + this.fromCache = fromCache; + } + + public JSONObject toJsonObject() { + JSONObject obj = new JSONObject(); + obj.put("id", this.id); + obj.put("name", this.name); + obj.put("description", this.description); + obj.put("price", this.price); + obj.put("fromCache", this.fromCache); + + return obj; + } + + public static Item fromJsonString(String obj) { + JSONObject jsonObject = new JSONObject(obj); + return new Item( + jsonObject.getLong("id"), + jsonObject.getString("name"), + jsonObject.getString("description"), + jsonObject.getDouble("price")); + } +} diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/ItemController.java b/memorystore/valkey/caching/demo/app/src/main/java/app/ItemController.java new file mode 100644 index 00000000000..f206444d92d --- /dev/null +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/ItemController.java @@ -0,0 +1,92 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Provides a RESTful API for interacting with the application's data. + * + * The controller contains three routes: + * - GET /item/{id} - Get an item by ID + * - POST /item/create - Create a new item + * - DELETE /item/delete/{id} - Delete an item by ID + */ + +package app; + +import jakarta.validation.Valid; +import org.json.JSONObject; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/item") +public class ItemController { + + public static final int TOTAL_RANDOM_ITEMS = 10; + + private final DataController dataController; + + public ItemController(DataController dataController) { + this.dataController = dataController; + } + + @GetMapping("/{id}") + public ResponseEntity read(@PathVariable Long id) { + Item item = dataController.get(id); + + if (item == null) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.ok(item.toJsonObject().toString()); + } + + @GetMapping("/random") + public ResponseEntity read() { + return ResponseEntity.ok( + new JSONObject() + .put("items", dataController.getMultiple(TOTAL_RANDOM_ITEMS)) + .toString()); + } + + @PostMapping("/create") + public ResponseEntity create(@Valid @RequestBody Item item) { + /** Create a new item */ + Item createdItem = new Item( + item.getName(), + item.getDescription(), + item.getPrice()); + + /** Save the item */ + long itemId = dataController.create(createdItem); + + /** Return a successful response */ + return ResponseEntity.ok( + JSONObject.valueToString(new JSONObject().put("id", itemId))); + } + + @DeleteMapping("/delete/{id}") + public ResponseEntity delete(@PathVariable long id) { + dataController.delete(id); + return ResponseEntity.ok( + JSONObject.valueToString(new JSONObject().put("id", id))); + } +} diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/ItemsRepository.java b/memorystore/valkey/caching/demo/app/src/main/java/app/ItemsRepository.java new file mode 100644 index 00000000000..2d6f2e6c6fd --- /dev/null +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/ItemsRepository.java @@ -0,0 +1,116 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Handles CRUD operations for the items table. + */ + +package app; + +import java.sql.PreparedStatement; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Repository; + +@Repository +public class ItemsRepository { + + private final JdbcTemplate jdbcTemplate; + + public ItemsRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public Optional get(long id) { + try { + return Optional.ofNullable( + jdbcTemplate.queryForObject( + "SELECT * FROM items WHERE id = ?", + (rs, rowNum) -> new Item( + rs.getLong("id"), + rs.getString("name"), + rs.getString("description"), + rs.getDouble("price")), + id)); + } catch (EmptyResultDataAccessException e) { + return Optional.empty(); + } + } + + public List getMultiple(int amount) { + return jdbcTemplate.query( + "SELECT * FROM items ORDER BY random() LIMIT ?", + (rs, rowNum) -> new Item( + rs.getLong("id"), + rs.getString("name"), + rs.getString("description"), + rs.getDouble("price")), + amount); + } + + public long create(Item item) { + String name = item.getName(); + String description = item.getDescription(); + double price = item.getPrice(); + + KeyHolder keyHolder = new GeneratedKeyHolder(); + + jdbcTemplate.update( + connection -> { + PreparedStatement ps = connection.prepareStatement( + "INSERT INTO items (name, description, price) VALUES (?, ?, ?)", + new String[] { "id" } // Explicitly specify the generated key column + ); + ps.setString(1, name); + ps.setString(2, description); + ps.setDouble(3, price); + return ps; + }, + keyHolder); + + // Ensure the keyHolder contains the generated ID only + Map keys = keyHolder.getKeys(); + if (keys != null && keys.size() > 1) { + throw new IllegalStateException( + "Expected a single key, but multiple keys were returned: " + keys); + } + + Number key = keyHolder.getKey(); + if (key == null) { + throw new IllegalStateException("No key generated during insert"); + } + + return key.longValue(); + } + + public void delete(long id) { + jdbcTemplate.update("DELETE FROM items WHERE id = ?", id); + } + + public boolean exists(long id) { + /** Set the query to execute */ + String query = "SELECT EXISTS(SELECT 1 FROM items WHERE id = ?)"; + + /** Return query result */ + return Boolean.TRUE.equals( + jdbcTemplate.queryForObject(query, Boolean.class, id)); + } +} diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/JdbcConfig.java b/memorystore/valkey/caching/demo/app/src/main/java/app/JdbcConfig.java new file mode 100644 index 00000000000..17891e6bd5d --- /dev/null +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/JdbcConfig.java @@ -0,0 +1,65 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Configuration for the JDBC DataSource to connect to the PostgreSQL server. + */ + +package app; + +import javax.sql.DataSource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.datasource.DriverManagerDataSource; + +@Configuration +public class JdbcConfig { + + // Database configuration properties with environment variable fallback + @Value("${DB_URL:jdbc:postgresql://localhost:5432/postgres}") + private String url; + + @Value("${DB_USERNAME:postgres}") + private String username; + + @Value("${DB_PASSWORD:}") + private String password; + + @Bean + public DataSource dataSource() { + // Validate mandatory properties + if (url == null || url.isEmpty()) { + throw new IllegalArgumentException( + "Database URL (DB_URL) is not configured" + ); + } + if (username == null || username.isEmpty()) { + throw new IllegalArgumentException( + "Database username (DB_USERNAME) is not configured" + ); + } + + // Set up the DataSource + DriverManagerDataSource dataSource = new DriverManagerDataSource(); + dataSource.setDriverClassName("org.postgresql.Driver"); + dataSource.setUrl(url); + dataSource.setUsername(username); + dataSource.setPassword(password); + + return dataSource; + } +} diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/JedisConfig.java b/memorystore/valkey/caching/demo/app/src/main/java/app/JedisConfig.java new file mode 100644 index 00000000000..6b76fc93089 --- /dev/null +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/JedisConfig.java @@ -0,0 +1,70 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Configuration for the Jedis client to connect to the Valkey server. + */ + +package app; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import redis.clients.jedis.Jedis; + +@Configuration +public class JedisConfig { + + // Redis server configuration properties + @Value("${VALKEY_HOST:localhost}") // Default to localhost if not set + private String redisHost; + + @Value("${VALKEY_PORT:6379}") // Default to 6379 if not set + private int redisPort; + + @Value("${VALKEY_PASSWORD:}") // Empty by default if not set + private String redisPassword; + + @Bean + public Jedis jedis() { + // Validate mandatory properties + if (redisHost == null || redisHost.isEmpty()) { + throw new IllegalArgumentException( + "Redis host (VALKEY_HOST) is not configured"); + } + if (redisPort <= 0 || redisPort > 65535) { + throw new IllegalArgumentException("Redis port (VALKEY_PORT) is invalid"); + } + + Jedis jedis = new Jedis(redisHost, redisPort); + + // Authenticate if a password is set + if (!redisPassword.isEmpty()) { + jedis.auth(redisPassword); + } + + // Verify the connection to the Redis server + try { + jedis.ping(); + } catch (Exception e) { + throw new RuntimeException( + "Failed to connect to Redis server at " + redisHost + ":" + redisPort, + e); + } + + return jedis; + } +} diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/Main.java b/memorystore/valkey/caching/demo/app/src/main/java/app/Main.java new file mode 100644 index 00000000000..113c5532129 --- /dev/null +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/Main.java @@ -0,0 +1,33 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Main class to start the Spring Boot application. + */ + +package app; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Main { + + public static void main(String[] args) { + SpringApplication.run(Main.class, args); + } +} + \ No newline at end of file diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/WebConfig.java b/memorystore/valkey/caching/demo/app/src/main/java/app/WebConfig.java new file mode 100644 index 00000000000..1c8c3014ec9 --- /dev/null +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/WebConfig.java @@ -0,0 +1,57 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Use this configuration to allow CORS requests from the frontend. + */ + +package app; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig { + + // Default to localhost:3000 if not set + @Value("${ALLOWED_ORIGINS:localhost:3000}") + private String allowedOrigins; + // Default to GET,POST,PUT,DELETE methods if not set + @Value("${ALLOWED_METHODS:GET,POST,PUT,DELETE}") + private String allowedMethods; + + // Default to all headers if not set + @Value("${ALLOWED_HEADERS:*}") + private String allowedHeaders; + + @Bean + public WebMvcConfigurer corsConfigurer() { + return new WebMvcConfigurer() { + @Override + public void addCorsMappings(CorsRegistry registry) { + registry + .addMapping("/**") // Allow all endpoints + .allowedOrigins(allowedOrigins.split(",")) // Allow requests from the frontend + .allowedMethods(allowedMethods.split(",")) // Restrict HTTP methods + .allowedHeaders(allowedHeaders.split(",")) // Specify allowed headers + .allowCredentials(true); // Allow cookies and credentials + } + }; + } +} diff --git a/memorystore/valkey/caching/demo/app/src/main/resources/static/script.js b/memorystore/valkey/caching/demo/app/src/main/resources/static/script.js new file mode 100644 index 00000000000..b713525685e --- /dev/null +++ b/memorystore/valkey/caching/demo/app/src/main/resources/static/script.js @@ -0,0 +1,258 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** Set API URL */ +const API_URL = window.location.origin + "/api"; + +/** API Helper Function */ +async function apiRequest(url, options = {}) { + let msTaken = new Date().getTime(); + try { + const res = await fetch(API_URL + url, options); + + if (!res.ok && res.status === 500) { + const errorText = "An error occurred"; + throw new Error(errorText); + } + + if (!res.ok && res.status === 404) { + const errorText = "Item not found"; + throw new Error(errorText); + } + + const data = await res.json(); + + /** Calculate response time in ms */ + msTaken = new Date().getTime() - msTaken; + + /** Update the UI for responses */ + setTiming(data.fromCache, msTaken); + setResponse({ type: "", data: null }); + + return { data }; + } catch (error) { + setResponse({ type: "error", data: error.message }); + setLoading(false); + return null; + } +} + +/** Set Global State */ +let loading = false; + +/** Add Element References */ +const queryRef = document.getElementById("search-input"); +const nameRef = document.getElementById("name"); +const descriptionRef = document.getElementById("description"); +const priceRef = document.getElementById("price"); + +const searchBtn = document.getElementById("search-btn"); +const randomBtn = document.getElementById("retrieve-random-btn"); +const createBtn = document.getElementById("create-btn"); + +const errorMsg = document.getElementById("error-msg"); +const successMsg = document.getElementById("success-msg"); + +const fromCache = document.getElementById("from-cache"); +const timeToFetch = document.getElementById("time-to-fetch"); +const itemsContainer = document.getElementById("items"); + +/** Loading Utility Function */ +function setLoading(value) { + loading = value; + + /** Disable inputs on loading */ + searchBtn.disabled = loading; + createBtn.disabled = loading; + + nameRef.disabled = loading; + descriptionRef.disabled = loading; + priceRef.disabled = loading; +} + +/** Update UI Timing */ +function setTiming(inFromCache, inTimeToFetch) { + fromCache.textContent = inFromCache ? "yes" : "no"; + timeToFetch.textContent = isNaN(inTimeToFetch) + ? "N/A" + : `${inTimeToFetch} ms`; +} + +/** Clear Input Fields */ +function clearInputFields() { + nameRef.value = ""; + descriptionRef.value = ""; + priceRef.value = ""; +} + +/** Set Response */ +function setResponse({ type, data }) { + console.log("Response:", type, data); + /** Set success message */ + if (type === "success") { + successMsg.style.display = "block"; + errorMsg.style.display = "none"; + successMsg.textContent = data; + return; + } + + /** Set error message */ + if (type === "error") { + successMsg.style.display = "none"; + errorMsg.style.display = "block"; + errorMsg.textContent = data; + return; + } + + /** Clear response message */ + successMsg.style.display = "none"; + successMsg.textContent = ""; + errorMsg.style.display = "none"; + errorMsg.textContent = ""; +} + +/** Search for an Item */ +async function search() { + setLoading(true); + itemsContainer.innerHTML = ""; + + const resp = await apiRequest(`/item/${queryRef.value.trim()}`); + if (resp && resp.data) { + const itemElement = document.createElement("div"); + itemElement.className = + "item flex flex-row gap-2.5 p-4 bg-gray-200 rounded"; + itemElement.innerHTML = addItemStyling(resp.data); + + itemElement.dataset.id = resp.data.id; + + const deleteBtn = itemElement.querySelector(".delete-btn"); + deleteBtn.addEventListener("click", () => deleteItem(resp.data.id)); + + itemsContainer.appendChild(itemElement); + } + + setLoading(false); +} + +/** Retrieve Random Items */ +async function retrieveRandom() { + setLoading(true); + itemsContainer.innerHTML = ""; + + const resp = await apiRequest("/item/random"); + if (resp && resp.data && Array.isArray(resp.data.items)) { + resp.data.items.forEach((item) => { + const itemElement = document.createElement("div"); + itemElement.className = + "item flex flex-row gap-2.5 p-4 bg-gray-200 rounded"; + itemElement.innerHTML = addItemStyling(item); + + itemElement.dataset.id = item.id; + + const deleteBtn = itemElement.querySelector(".delete-btn"); + deleteBtn.addEventListener("click", () => deleteItem(item.id)); + + itemsContainer.appendChild(itemElement); + }); + } else { + setResponse({ type: "error", data: "No items found." }); + } + + setLoading(false); +} + +/** Create a New Item */ +async function createItem() { + const name = nameRef.value.trim(); + const description = descriptionRef.value.trim(); + const price = parseFloat(priceRef.value.trim()); + + if (!name || !description || isNaN(price)) { + setResponse({ + type: "error", + data: "Please fill in all fields with valid data", + }); + return; + } + + setLoading(true); + + const resp = await apiRequest("/item/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, description, price }), + }); + + if (resp && resp.data) { + setResponse({ + type: "success", + data: `Item created successfully with ID: ${resp.data.id}`, + }); + clearInputFields(); + } + + setLoading(false); +} + +/** Delete an Item */ +async function deleteItem(id) { + setLoading(true); + + const resp = await apiRequest(`/item/delete/${id}`, { method: "DELETE" }); + + if (!resp) { + setResponse({ type: "error", data: "Failed to delete item" }); + setLoading(false); + return; + } + setResponse({ type: "success", data: "Item deleted successfully" }); + + /** Remove the item from the UI */ + const itemElement = document.querySelector(`.item[data-id="${id}"]`); + if (itemElement) { + itemElement.remove(); + } + + /** Reset loading status */ + setLoading(false); +} + +/** Add Item Styling */ +function addItemStyling({ id, name, price, description }) { + return ` +

ID: ${id}

+
+
+

Name: ${name}

+

Price: $${price}

+
+

${description}

+
+ +
+
`; +} + +/** Initialize */ +function init() { + errorMsg.style.display = "none"; + successMsg.style.display = "none"; +} + +window.addEventListener("load", init); +searchBtn.addEventListener("click", search); +randomBtn.addEventListener("click", retrieveRandom); +createBtn.addEventListener("click", createItem); diff --git a/memorystore/valkey/caching/demo/app/src/main/resources/templates/index.html b/memorystore/valkey/caching/demo/app/src/main/resources/templates/index.html new file mode 100644 index 00000000000..45c9060105e --- /dev/null +++ b/memorystore/valkey/caching/demo/app/src/main/resources/templates/index.html @@ -0,0 +1,142 @@ + + + + + + + + Valkey For MemoryStore Caching Demo + + + + + + + +
+
+

+ Valkey For MemoryStore Caching Demo +

+
+
+ +
+ +
+ + +
+ + + + + +
+
+

Nothing to show yet.

+
+
+ + +
+

Add a New Item

+
+ +
+
+ +
+
+ +
+
+ +
+
+ + +
+

+ Item is from cache: null +

+

|

+

+ Time to fetch item: + null +

+
+
+ + diff --git a/memorystore/valkey/caching/demo/app/src/test/java/app/DataControllerTest.java b/memorystore/valkey/caching/demo/app/src/test/java/app/DataControllerTest.java new file mode 100644 index 00000000000..60a494ef003 --- /dev/null +++ b/memorystore/valkey/caching/demo/app/src/test/java/app/DataControllerTest.java @@ -0,0 +1,268 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import redis.clients.jedis.Jedis; + +@ExtendWith(MockitoExtension.class) +class DataControllerTest { + + @Mock + private ItemsRepository itemsRepository; // Mocked repository for database interactions + + @Mock + private Jedis jedis; // Mocked Redis client for cache interactions + + private DataController dataController; // System under test + + @BeforeEach + void setUp() { + // Initialize the DataController with mocked dependencies before each test + dataController = new DataController(itemsRepository, jedis); + } + + // ---------------------------------------------------- + // get() tests + // ---------------------------------------------------- + @Nested + @DisplayName("Testing get() method") + class GetTests { + + @Test + @DisplayName("Should return item from cache if it exists in cache") + void testGet_ItemInCache() { + // Arrange: Item exists in cache + long itemId = 1; + String itemIdStr = Long.toString(itemId); + String cachedData = "{\"id\":1,\"name\":\"Cached Item\"" + + ",\"description\":\"Cached description\",\"price\":10.5}"; + Item cachedItem = Item.fromJsonString(cachedData); + + given(jedis.get(itemIdStr)).willReturn(cachedData); + + // Act: Call the get() method + Item result = dataController.get(itemId); + + // Assert: Verify cache is used, database is not queried, and correct item is + // returned + verify(itemsRepository, never()).get(anyLong()); // Database should not be called + assertEquals(cachedItem.getId(), result.getId()); + assertEquals(cachedItem.getName(), result.getName()); + assertEquals(true, result.isFromCache()); + } + + @Test + @DisplayName("Should return item from database and cache it if not in cache") + void testGet_ItemNotInCache() { + // Arrange: Item is not in cache but exists in the database + long itemId = 2; + String itemIdStr = Long.toString(itemId); + Item dbItem = new Item(2L, "Database Item", "From DB", 15.99); + + given(jedis.get(itemIdStr)).willReturn(null); + given( + itemsRepository + .get(itemId)) + .willReturn(Optional.of(dbItem)); // Database contains the item + + // Act: Call the get() method + Item result = dataController.get(itemId); + + // Assert: Verify database usage, cache update, and correct item return + verify(jedis).setex( + itemIdStr, + DataController.DEFAULT_TTL, + dbItem.toJsonObject().toString()); // Add item to cache + assertEquals(dbItem.getId(), result.getId()); + assertEquals(dbItem.getName(), result.getName()); + assertEquals(false, result.isFromCache()); + } + + @Test + @DisplayName("Should return null if item does not exist in cache or database") + void testGet_ItemNotFound() { + // Arrange: Item does not exist in cache or database + long itemId = 3; + String itemIdStr = Long.toString(itemId); + + given(jedis.get(itemIdStr)).willReturn(null); // Cache miss + given(itemsRepository.get(itemId)).willReturn(Optional.empty()); // Database miss + + // Act: Call the get() method + Item result = dataController.get(itemId); + + // Assert: Verify no cache update and null return + verify(jedis, never()).setex( + anyString(), + eq(DataController.DEFAULT_TTL), + anyString()); // Cache should not be updated + assertNull(result); + } + } + + // ---------------------------------------------------- + // create() tests + // ---------------------------------------------------- + @Nested + @DisplayName("Testing create() method") + class CreateTests { + + @Test + @DisplayName("Should create item in cache and database") + void testCreate() { + // Arrange: Item to be created + Item item = new Item("New Item", "New Description", 20.0); + + given(itemsRepository.create(item)).willReturn(0L); // Simulate database creation with ID 0 + + // Act: Call the create() method + long result = dataController.create(item); + + // Assert: Verify cache and database interactions + Item expectedItem = new Item( + 0L, + item.getName(), + item.getDescription(), + item.getPrice()); + verify(jedis).setex( + Long.toString(result), + DataController.DEFAULT_TTL, + expectedItem.toJsonObject().toString()); // Add item to cache with TTL + assertEquals(0L, result); // Validate returned ID + } + } + + // ---------------------------------------------------- + // delete() tests + // ---------------------------------------------------- + @Nested + @DisplayName("Testing delete() method") + class DeleteTests { + + @Test + @DisplayName("Should delete item from both cache and database") + void testDelete_ItemExists() { + // Arrange: Item exists in cache + long itemId = 6; + String itemIdStr = Long.toString(itemId); + + given(jedis.exists(itemIdStr)).willReturn(true); // Cache contains the item + + // Act: Call the delete() method + dataController.delete(itemId); + + // Assert: Verify deletion from both cache and database + verify(itemsRepository).delete(itemId); // Delete from database + verify(jedis).del(itemIdStr); // Delete from cache + } + + @Test + @DisplayName("Should only delete item from database if not in cache") + void testDelete_ItemNotInCache() { + // Arrange: Item does not exist in cache + long itemId = 7; + String itemIdStr = Long.toString(itemId); + + given(jedis.exists(itemIdStr)).willReturn(false); // Cache miss + + // Act: Call the delete() method + dataController.delete(itemId); + + // Assert: Verify database deletion and no cache interaction + verify(itemsRepository).delete(itemId); // Delete from database + verify(jedis, never()).del(anyString()); // Cache should not be updated + } + } + + // ---------------------------------------------------- + // exists() tests + // ---------------------------------------------------- + @Nested + @DisplayName("Testing exists() method") + class ExistsTests { + + @Test + @DisplayName("Should return true if item exists in cache") + void testExists_ItemInCache() { + // Arrange: Item exists in cache + long itemId = 8; + String itemIdStr = Long.toString(itemId); + + given(jedis.exists(itemIdStr)).willReturn(true); // Cache contains the item + + // Act: Call the exists() method + boolean result = dataController.exists(itemId); + + // Assert: Verify true result and no database call + assertTrue(result); + verify(itemsRepository, never()).exists(anyLong()); // Database should not be queried + } + + @Test + @DisplayName("Should return true if item exists in database") + void testExists_ItemInDatabase() { + // Arrange: Item is not in cache but exists in the database + long itemId = 9; + String itemIdStr = Long.toString(itemId); + + given(jedis.exists(itemIdStr)).willReturn(false); // Cache miss + given(itemsRepository.exists(itemId)).willReturn(true); // Database contains the item + + // Act: Call the exists() method + boolean result = dataController.exists(itemId); + + // Assert: Verify true result + assertTrue(result); + } + + @Test + @DisplayName("Should return false if item does not exist in cache or database") + void testExists_ItemNotFound() { + // Arrange: Item does not exist in cache or database + long itemId = 10; + String itemIdStr = Long.toString(itemId); + + given(jedis.exists(itemIdStr)).willReturn(false); // Cache miss + given(itemsRepository.exists(itemId)).willReturn(false); // Database miss + + // Act: Call the exists() method + boolean result = dataController.exists(itemId); + + // Assert: Verify false result + assertFalse(result); + } + } +} diff --git a/memorystore/valkey/caching/demo/app/src/test/java/app/ItemControllerTest.java b/memorystore/valkey/caching/demo/app/src/test/java/app/ItemControllerTest.java new file mode 100644 index 00000000000..e84ba92d04e --- /dev/null +++ b/memorystore/valkey/caching/demo/app/src/test/java/app/ItemControllerTest.java @@ -0,0 +1,220 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +import org.hamcrest.Matchers; +import org.json.JSONObject; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(ItemController.class) +class ItemControllerTest { + + @Autowired + private MockMvc mockMvc; // MockMvc is used to perform HTTP requests in tests + + @MockBean + private DataController dataController; // Mocked dependency of ItemController + + @Test + @DisplayName("Test reading an item by ID") + void testReadItem() throws Exception { + // Arrange: DataController returns an Item for the provided ID + long itemId = 1; + Item item = new Item(1L, "ItemName", "ItemDescription", 100.0); + + given(dataController.get(itemId)).willReturn(item); // Simulate DataController behavior + + // Act: Perform GET /item/1 + mockMvc + .perform(get("/api/item/{id}", itemId)) + .andExpect(status().isOk()) // Assert HTTP status is 200 OK + .andExpect( + content() + .string( + Matchers.containsString("\"id\":1"))) // Assert JSON contains "id":1 + .andExpect( + content() + .string( + Matchers + .containsString("\"name\":\"ItemName\""))) // Assert JSON contains name + .andExpect( + content() + .string( + Matchers + .containsString( + "\"description\":\"ItemDescription\""))) // Assert description + .andExpect(content().string(Matchers.containsString("\"price\":100"))); // Assert price + + // Assert: Verify DataController's get method was called with the correct ID + verify(dataController).get(itemId); + } + + @Test + @DisplayName("Test reading an item that does not exist") + void testReadItem_NotFound() throws Exception { + // Arrange: DataController returns null for the provided ID + long itemId = 2; + given(dataController.get(itemId)).willReturn(null); // Simulate item not found + + // Act: Perform GET /item/2 + mockMvc + .perform(get("/api/item/{id}", itemId)) + .andExpect(status().isNotFound()); // Assert HTTP status is 404 Not Found + + // Assert: Verify DataController's get method was called + verify(dataController).get(itemId); + } + + @Test + @DisplayName("Test reading multiple random items") + void testReadMultipleItems() throws Exception { + // Arrange: DataController returns a list of random items + Item item1 = new Item(1L, "Item1", "Description1", 100.0); + Item item2 = new Item(2L, "Item2", "Description2", 200.0); + Item item3 = new Item(3L, "Item3", "Description3", 300.0); + Item item4 = new Item(4L, "Item4", "Description4", 400.0); + Item item5 = new Item(5L, "Item5", "Description5", 500.0); + Item item6 = new Item(6L, "Item6", "Description6", 600.0); + Item item7 = new Item(7L, "Item7", "Description7", 700.0); + Item item8 = new Item(8L, "Item8", "Description8", 800.0); + Item item9 = new Item(9L, "Item9", "Description9", 900.0); + Item item10 = new Item(10L, "Item10", "Description10", 1000.0); + + List items = List.of( + item1, + item2, + item3, + item4, + item5, + item6, + item7, + item8, + item9, + item10); + given(dataController.getMultiple(10)).willReturn(items); + + // Act: Perform GET /random + mockMvc + .perform(get("/api/item/random")) + .andExpect( + status() + .isOk()) // Assert HTTP status is 200 OK + .andExpect( + jsonPath("$.items.length()") + .value(10)) // Assert the `items` array has 10 elements + .andExpect( + jsonPath("$.items[0].id") + .value(1)) // Check first item's ID + .andExpect( + jsonPath("$.items[0].name") + .value("Item1")) // Check first item's name + .andExpect( + jsonPath("$.items[9].id") + .value(10)) // Check last item's ID + .andExpect( + jsonPath("$.items[9].name") + .value("Item10")); // Check last item's name + + // Assert: Verify DataController's getMultiple method was called with the + // correct parameter + verify(dataController).getMultiple(10); + } + + @Test + @DisplayName("Test creating a new item") + void testCreateItem() throws Exception { + // Arrange: DataController successfully creates the item + Item item = new Item("NewItem", "NewDescription", 200.0); + + given(dataController.create(any(Item.class))).willReturn(0L); // Simulate creation with ID 0 + + // Act: Perform POST /item/create + JSONObject itemJson = item.toJsonObject(); + itemJson.remove("id"); // Remove ID from JSON for creation + mockMvc + .perform( + post("/api/item/create") + .contentType("application/json") // Specify JSON content type + .content(itemJson.toString()) // Convert JSON to string + ) + .andExpect(status().isOk()) // Assert HTTP status is 200 OK + .andExpect(content().string("{\"id\":0}")); // Assert response message + + // Assert: Verify DataController's create method was called with an argument + // matching the expected properties + verify(dataController).create( + argThat( + argument -> argument + .getName() + .equals("NewItem") + && + argument + .getDescription() + .equals("NewDescription") + && + argument.getPrice() == 200.0)); + } + + @Test + @DisplayName("Test creating an item with invalid request body") + void testCreateItem_Validations() throws Exception { + // Act: Perform POST /item/create with an invalid request body (missing required + // fields) + mockMvc + .perform( + post("/api/item/create") + .contentType("application/json") // Specify JSON content type + .content("{\"name\":\"NewItem\"}") // Incomplete JSON (missing fields) + ) + .andExpect(status().isBadRequest()); // Assert HTTP status is 400 Bad Request + + // Assert: Verify DataController's create method was not called + verify(dataController, never()).create(any()); + } + + @Test + @DisplayName("Test deleting an item") + void testDeleteItem() throws Exception { + // Act: Perform DELETE /item/delete/5 + long itemId = 5; + mockMvc + .perform(delete("/api/item/delete/{id}", itemId)) + .andExpect(status().isOk()) // Assert HTTP status is 200 OK + .andExpect(content().string("{\"id\":5}")); // Assert response message + + // Assert: Verify DataController's delete method was called with the correct ID + verify(dataController).delete(itemId); + } +} diff --git a/memorystore/valkey/caching/demo/sample-data/.gitignore b/memorystore/valkey/caching/demo/sample-data/.gitignore new file mode 100644 index 00000000000..e9e224cae7f --- /dev/null +++ b/memorystore/valkey/caching/demo/sample-data/.gitignore @@ -0,0 +1,84 @@ +############################## +## Java +############################## +.mtj.tmp/ +*.class +*.jar +*.war +*.ear +*.nar +hs_err_pid* + +############################## +## Maven +############################## +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +pom.xml.bak +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +############################## +## Gradle +############################## +bin/ +build/ +.gradle +.gradletasknamecache +gradle-app.setting +!gradle-wrapper.jar + +############################## +## IntelliJ +############################## +out/ +.idea/ +.idea_modules/ +*.iml +*.ipr +*.iws + +############################## +## Eclipse +############################## +.settings/ +bin/ +tmp/ +.metadata +.classpath +.project +*.tmp +*.bak +*.swp +*~.nib +local.properties +.loadpath +.factorypath + +############################## +## NetBeans +############################## +nbproject/private/ +build/ +nbbuild/ +dist/ +nbdist/ +nbactions.xml +nb-configuration.xml + +############################## +## Visual Studio Code +############################## +.vscode/ +.code-workspace + +############################## +## OS X +############################## +.DS_Store \ No newline at end of file diff --git a/memorystore/valkey/caching/demo/sample-data/Dockerfile b/memorystore/valkey/caching/demo/sample-data/Dockerfile new file mode 100644 index 00000000000..131e16d0504 --- /dev/null +++ b/memorystore/valkey/caching/demo/sample-data/Dockerfile @@ -0,0 +1,24 @@ +# Use an OpenJDK base image +FROM openjdk:17-jdk-slim + +# Install Maven for building the project +RUN apt-get update && apt-get install -y maven + +# Set the working directory +WORKDIR /app + +# Copy Maven project files +COPY pom.xml ./ +COPY src ./src + +# Build the project +RUN mvn clean package -DskipTests + +# Copy the built JAR file to the container +RUN cp target/app-1.0-SNAPSHOT.jar app.jar + +# Expose the application port +EXPOSE 8080 + +# Run the application +CMD ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/memorystore/valkey/caching/demo/sample-data/docker-compose.yaml b/memorystore/valkey/caching/demo/sample-data/docker-compose.yaml new file mode 100644 index 00000000000..985ac91cff6 --- /dev/null +++ b/memorystore/valkey/caching/demo/sample-data/docker-compose.yaml @@ -0,0 +1,66 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: caching-app + +services: + valkey: + image: valkey/valkey:latest + ports: + - "6379:6379" + command: ["valkey-server", "--save", "60", "1", "--loglevel", "warning"] + + postgres: + image: postgres:latest + container_name: postgres + ports: + - "5432:5432" + environment: + - POSTGRES_USER=admin + - POSTGRES_PASSWORD=password + - POSTGRES_DB=postgres + volumes: + - ../app/init.sql:/docker-entrypoint-initdb.d/init.sql + + data: + build: + context: . + dockerfile: Dockerfile + container_name: sample-data + ports: + - "8082:8082" + depends_on: + - postgres + environment: + - DB_URL=jdbc:postgresql://postgres:5432/postgres + - DB_USERNAME=admin + - DB_PASSWORD=password + + app: + build: + context: ../app + dockerfile: Dockerfile + container_name: app + ports: + - "8080:8080" + depends_on: + - valkey + - postgres + - data + environment: + - VALKEY_HOST=valkey + - VALKEY_PORT=6379 + - DB_URL=jdbc:postgresql://postgres:5432/postgres + - DB_USERNAME=admin + - DB_PASSWORD=password diff --git a/memorystore/valkey/caching/demo/sample-data/pom.xml b/memorystore/valkey/caching/demo/sample-data/pom.xml new file mode 100644 index 00000000000..df1991ea617 --- /dev/null +++ b/memorystore/valkey/caching/demo/sample-data/pom.xml @@ -0,0 +1,96 @@ + + + + + + 4.0.0 + + org.example + app + 1.0-SNAPSHOT + + + 17 + 17 + UTF-8 + + + + + + org.springframework.boot + spring-boot-starter-jdbc + 3.3.6 + + + + + org.postgresql + postgresql + 42.6.0 + + + + + com.github.javafaker + javafaker + 1.0.2 + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 17 + 17 + + -parameters + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.2.4 + + + package + + shade + + + true + + + app.Main + + + + + + + + + \ No newline at end of file diff --git a/memorystore/valkey/caching/demo/sample-data/src/main/java/app/Main.java b/memorystore/valkey/caching/demo/sample-data/src/main/java/app/Main.java new file mode 100644 index 00000000000..b05a0382949 --- /dev/null +++ b/memorystore/valkey/caching/demo/sample-data/src/main/java/app/Main.java @@ -0,0 +1,123 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app; + +import com.github.javafaker.Faker; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import org.springframework.boot.jdbc.DataSourceBuilder; +import org.springframework.jdbc.CannotGetJdbcConnectionException; +import org.springframework.jdbc.core.JdbcTemplate; + +public final class Main { + + private Main() { + throw new UnsupportedOperationException( + "This is a utility class and cannot be instantiated"); + } + + /** Maximum number of entries to generate. */ + private static final int MAX_GENERATED_ENTRIES = 15000; + /** Faker instance for generating random data. */ + private static final Faker FAKER = new Faker(); + /** Random number generator for generating random data. */ + private static final Random RANDOM = new Random(); + /** Sleep time in milliseconds before retrying to connect. */ + private static final int SLEEP_TIME = 5000; + /** Bound on price. */ + private static final int PRICE_BOUND = 10000; + /** Scaling factor for price. */ + private static final int PRICE_SCALE = 100; + + /** + * Main method to start sample-data application. + * + * @param args Command-line arguments + */ + public static void main(final String[] args) { + // Connect to PostgreSQL + System.out.println("Connecting to PostgreSQL..."); + JdbcTemplate jdbcTemplate = configureJdbcTemplate(); + + // Populate leaderboard with test data + try { + System.out.println("Populating items table with sample data..."); + populateItems(jdbcTemplate); + } catch (CannotGetJdbcConnectionException e) { + System.out + .println("Failed to connect to the" + + " database. Retrying in 5 seconds..."); + // Sleep for 5 seconds and retry + try { + Thread.sleep(SLEEP_TIME); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + main(args); + } + } + + private static void populateItems(final JdbcTemplate jdbcTemplate) { + String sql = "INSERT INTO items" + + " (name, description, price) VALUES (?, ?, ?)"; + + // Prepare batch arguments + List batchArgs = new ArrayList<>(); + for (int i = 0; i < MAX_GENERATED_ENTRIES; i++) { + String name = generateProductName(); + String description = generateDescription(); + double price = RANDOM.nextInt(PRICE_BOUND) / PRICE_SCALE; + + batchArgs.add(new Object[] { + name, description, price }); + } + + // Execute batch update + jdbcTemplate.batchUpdate(sql, batchArgs); + } + + private static String generateProductName() { + return FAKER.commerce().productName(); + } + + private static String generateDescription() { + return FAKER.lorem().paragraph(); + } + + private static JdbcTemplate configureJdbcTemplate() { + String jdbcUrl = System + .getenv() + .getOrDefault("DB_URL", "jdbc:postgresql://localhost:5432/items"); + String jdbcUsername = System + .getenv() + .getOrDefault("DB_USERNAME", "root"); + String jdbcPassword = System + .getenv() + .getOrDefault("DB_PASSWORD", "password"); + + JdbcTemplate jdbcTemplate = new JdbcTemplate(); + jdbcTemplate.setDataSource( + DataSourceBuilder.create() + .url(jdbcUrl) + .username(jdbcUsername) + .password(jdbcPassword) + .build()); + return jdbcTemplate; + } + +} diff --git a/memorystore/valkey/caching/demo/sample-data/src/main/java/app/package-info.java b/memorystore/valkey/caching/demo/sample-data/src/main/java/app/package-info.java new file mode 100644 index 00000000000..30c58bf7980 --- /dev/null +++ b/memorystore/valkey/caching/demo/sample-data/src/main/java/app/package-info.java @@ -0,0 +1,18 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** Contains classes for the caching application. */ +package app;