From 6b13153df019a57077c2a87019a7f01ac12a71d4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:09:54 +0200 Subject: [PATCH 01/11] Backend: shared key/value store, @Cacheable family, distributed sessions, rate limits and scheduler locks A Redis-protocol client (RESP2, no client jar) behind a KeyValueStore interface, with an in-process store as the default. cn1.kv.url or Spring's spring.data.redis.* selects Redis/Valkey/KeyDB; the store is a server resource opened on first use and closed at shutdown. On top of it: @Cacheable/@CachePut/@CacheEvict woven at build time with a compiled key expression and generated JSON codecs, cn1.session.store=kv, KeyValueRateLimiter, scheduler locks without a database, an injectable KeyValueStore and a health entry. Tested with demo/kvcheck on the JVM and ParparVM against podman Redis 7, Valkey 8 and an ACL-protected Redis, and BackendBeansTest with CN1_TEST_REDIS. Co-Authored-By: Claude Opus 5.5 --- .../maven/processors/BackendBeans.java | 175 ++++- .../maven/processors/BackendSources.java | 88 +++ .../maven/processors/BackendWiringWriter.java | 5 + .../maven/processors/CacheKeyCompiler.java | 255 +++++++ .../RestControllerAnnotationProcessor.java | 13 +- .../maven/processors/BackendBeansTest.java | 215 ++++++ vm/backend/demo/kvcheck/com/demo/KvCheck.java | 316 +++++++++ .../src/com/codename1/backend/Access.java | 11 + .../src/com/codename1/backend/Sessions.java | 212 +++++- .../src/com/codename1/backend/Tasks.java | 64 ++ .../backend/annotations/CacheEvict.java | 48 ++ .../backend/annotations/CachePut.java | 46 ++ .../backend/annotations/Cacheable.java | 64 ++ .../backend/annotations/EnableCaching.java | 35 + .../codename1/backend/kv/KeyValueStore.java | 116 ++++ .../codename1/backend/kv/KeyValueStores.java | 132 ++++ .../backend/kv/MemoryKeyValueStore.java | 259 +++++++ .../codename1/backend/kv/RedisConnection.java | 202 ++++++ .../codename1/backend/kv/RedisException.java | 47 ++ .../backend/kv/RedisKeyValueStore.java | 643 ++++++++++++++++++ .../codename1/backend/kv/package-info.java | 33 + .../ratelimit/KeyValueRateLimiter.java | 128 ++++ .../codename1/impl/backend/BackendAccess.java | 19 + .../codename1/impl/backend/CacheRuntime.java | 231 +++++++ .../codename1/impl/backend/Management.java | 20 + .../com/codename1/impl/backend/Scheduler.java | 65 +- .../codename1/impl/backend/WireBuffer.java | 349 ++++++++++ 27 files changed, 3777 insertions(+), 14 deletions(-) create mode 100644 maven/build-engine/src/main/java/com/codename1/maven/processors/CacheKeyCompiler.java create mode 100644 vm/backend/demo/kvcheck/com/demo/KvCheck.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/CacheEvict.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/CachePut.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/Cacheable.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/EnableCaching.java create mode 100644 vm/backend/src/com/codename1/backend/kv/KeyValueStore.java create mode 100644 vm/backend/src/com/codename1/backend/kv/KeyValueStores.java create mode 100644 vm/backend/src/com/codename1/backend/kv/MemoryKeyValueStore.java create mode 100644 vm/backend/src/com/codename1/backend/kv/RedisConnection.java create mode 100644 vm/backend/src/com/codename1/backend/kv/RedisException.java create mode 100644 vm/backend/src/com/codename1/backend/kv/RedisKeyValueStore.java create mode 100644 vm/backend/src/com/codename1/backend/kv/package-info.java create mode 100644 vm/backend/src/com/codename1/backend/security/ratelimit/KeyValueRateLimiter.java create mode 100644 vm/backend/src/com/codename1/impl/backend/CacheRuntime.java create mode 100644 vm/backend/src/com/codename1/impl/backend/WireBuffer.java diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java index 5417a1673bb..d83e4e57aaa 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java @@ -97,6 +97,13 @@ final class BackendBeans { static final String MANAGED_OPERATION = PKG + "ManagedOperation;"; static final String TIMED = PKG + "Timed;"; static final String COUNTED = PKG + "Counted;"; + static final String CACHEABLE = PKG + "Cacheable;"; + static final String CACHE_PUT = PKG + "CachePut;"; + static final String CACHE_EVICT = PKG + "CacheEvict;"; + static final String ENABLE_CACHING = PKG + "EnableCaching;"; + /// The caching annotations, and how each is written. + static final String[] CACHING = {CACHEABLE, CACHE_PUT, CACHE_EVICT}; + static final String[] CACHING_SHOWN = {"@Cacheable", "@CachePut", "@CacheEvict"}; static final String MCP_TOOL = PKG + "McpTool;"; static final String MCP_PARAM = PKG + "McpParam;"; static final String MVC_CONTROLLER = PKG + "Controller;"; @@ -127,7 +134,7 @@ final class BackendBeans { PROFILE, ON_PROPERTY, ON_EXPRESSION, ON_MISSING, POST_CONSTRUCT, PRE_DESTROY, TRANSACTIONAL, ASYNC, SCHEDULED, MANAGED_RESOURCE, MANAGED_ATTRIBUTE, MANAGED_OPERATION, TIMED, COUNTED, - MCP_TOOL, MVC_CONTROLLER, REST_CONTROLLER, WEBSOCKET_MAPPING, PRE_AUTHORIZE, SECURED, + CACHEABLE, CACHE_PUT, CACHE_EVICT, ENABLE_CACHING, MCP_TOOL, MVC_CONTROLLER, REST_CONTROLLER, WEBSOCKET_MAPPING, PRE_AUTHORIZE, SECURED, ROLES_ALLOWED, PERMIT_ALL, DENY_ALL, BackendExceptionHandlers.CONTROLLER_ADVICE, BackendExceptionHandlers.REST_CONTROLLER_ADVICE))); @@ -138,6 +145,7 @@ final class BackendBeans { BackendExceptionHandlers.REST_CONTROLLER_ADVICE}; static final String CONFIG_TYPE = "com/codename1/backend/Config"; + static final String KEY_VALUE_TYPE = "com/codename1/backend/kv/KeyValueStore"; static final String DATASOURCE_TYPE = "com/codename1/backend/DataSource"; static final String ENTITIES_TYPE = "com/codename1/backend/orm/EntityManager"; static final String SESSION_TYPE = "com/codename1/orm/session/Session"; @@ -394,6 +402,17 @@ static final class Aspect { AnnotationValues async; AnnotationValues timed; AnnotationValues counted; + /// The method's caching annotation, one of [BackendBeans#CACHING], or null. + AnnotationValues cache; + /// The cache's name, and the Java expression of the entry's key. + String cacheName; + String cacheKey; + /// The generic return type, and the codec statements reading it from + /// the parsed `j` into `target` and writing `result` into `out`; null for + /// an eviction. + String cacheType; + String cacheRead; + String cacheWrite; String asyncTaskBinary; /// The method's authorization check as a Java boolean expression, or /// null when it has none; see [MethodSecurityCompiler]. @@ -618,6 +637,7 @@ private void checkTestBeans() { for (MethodInfo m : cls.getMethods()) { if (m.getAnnotation(TRANSACTIONAL) != null || m.getAnnotation(ASYNC) != null || m.getAnnotation(TIMED) != null || m.getAnnotation(COUNTED) != null + || caching(m) != null || m.getAnnotation(SCHEDULED) != null || methodSecurity(m.getAnnotations(), null, null) != null) { ctx.error(cls, "@TestConfiguration " + cls.getSourceName() + "." @@ -1785,13 +1805,29 @@ private void collectJobs(Bean bean) { + "\", which is not a zone ID, UTC, or an offset such as +02:00."); continue; } - if (job.lock.length() > 0) { + if (job.lock.length() > 0 && !declaresSharedStore(ctx)) { + // The lock is claimed in the database -- unless the module names + // a shared key/value store, which the scheduler then uses, and a + // server that needs no database is not given one for a lock. needsDatabase = true; } bean.jobs.add(job); } } + /// Whether the module's `application.properties` names a shared key/value + /// store -- `cn1.kv.url`, or Spring Boot's `spring.data.redis.url` or `.host`. + static boolean declaresSharedStore(ProcessorContext ctx) { + String url = RestControllerAnnotationProcessor.applicationProperty(ctx, "cn1.kv.url"); + if (url != null) { + return !url.trim().startsWith("memory:"); + } + return RestControllerAnnotationProcessor.applicationPropertyKnown(ctx, + "spring.data.redis.url") + || RestControllerAnnotationProcessor.applicationPropertyKnown(ctx, + "spring.data.redis.host"); + } + private void collectTools(Bean bean) { AnnotatedClass cls = bean.cls; for (MethodInfo m : inheritedMembers(cls)) { @@ -2332,6 +2368,10 @@ private void resolvePoint(Bean owner, Point p, AnnotatedClass where) { p.builtin = "config"; return; } + if (KEY_VALUE_TYPE.equals(type)) { + p.builtin = "keyValue"; + return; + } if (DATASOURCE_TYPE.equals(type)) { p.builtin = "dataSource"; needsDatabase = true; @@ -2767,9 +2807,10 @@ private void visit(Bean b, Map state, List path) { /// one answer that cannot be right. private void refuseInterfaceAspects(AnnotatedClass cls) { String[] names = {TRANSACTIONAL, ASYNC, TIMED, COUNTED, PRE_AUTHORIZE, SECURED, - ROLES_ALLOWED, PERMIT_ALL, DENY_ALL}; + ROLES_ALLOWED, PERMIT_ALL, DENY_ALL, CACHEABLE, CACHE_PUT, CACHE_EVICT}; String[] shown = {"@Transactional", "@Async", "@Timed", "@Counted", "@PreAuthorize", - "@Secured", "@RolesAllowed", "@PermitAll", "@DenyAll"}; + "@Secured", "@RolesAllowed", "@PermitAll", "@DenyAll", "@Cacheable", "@CachePut", + "@CacheEvict"}; for (int i = 0; i < names.length; i++) { if (cls.getClassAnnotation(names[i]) != null) { ctx.error(cls, shown[i] + " on interface " + cls.getSourceName() + " is not " @@ -2993,6 +3034,7 @@ private void collectAspects() { } AnnotationValues timed = m.getAnnotation(TIMED); AnnotationValues counted = m.getAnnotation(COUNTED); + AnnotationValues cache = caching(m); String where = cls.getSourceName() + "." + m.getName(); AnnotationValues security = methodSecurity(m.getAnnotations(), cls, null); boolean lifecycle = m.getAnnotation(POST_CONSTRUCT) != null @@ -3017,7 +3059,7 @@ private void collectAspects() { security = null; } if (tx == null && async == null && timed == null && counted == null - && security == null) { + && security == null && cache == null) { continue; } if (m.isAbstract() || (m.getAccess() & Opcodes.ACC_NATIVE) != 0) { @@ -3052,6 +3094,9 @@ private void collectAspects() { if (security != null) { compileSecurity(cls, m, security, a, where); } + if (cache != null && !compileCache(cls, m, cache, a, where)) { + continue; + } a.transactional = tx; a.async = async; a.timed = timed; @@ -3284,6 +3329,120 @@ private AnnotationValues methodSecurity(Map annotation } /// Turns `security` into the Java expression `a`'s woven check evaluates. + /// The one caching annotation on `m`, or null; two on one method is an error + /// reported by [#compileCache], so the first is answered here. + static AnnotationValues caching(MethodInfo m) { + for (String d : CACHING) { + AnnotationValues v = m.getAnnotation(d); + if (v != null) { + return v; + } + } + return null; + } + + /// Compiles `cache` onto `a`: its name, key, and the codecs of the return + /// type. Answers false, having reported why, when it cannot. + private boolean compileCache(AnnotatedClass cls, MethodInfo m, AnnotationValues cache, + Aspect a, String where) { + String d = cache.getDescriptor(); + String shown = CACHING_SHOWN[java.util.Arrays.asList(CACHING).indexOf(d)]; + int count = 0; + for (String other : CACHING) { + if (m.getAnnotation(other) != null) { + count++; + } + } + if (count > 1) { + ctx.error(cls, where + " carries more than one of @Cacheable, @CachePut and " + + "@CacheEvict. Spring's @Caching is not supported; split the method."); + return false; + } + List cacheNames = strings(cache.get("value")); + if (cacheNames.isEmpty()) { + cacheNames = strings(cache.get("cacheNames")); + } + if (cacheNames.isEmpty() || cacheNames.get(0).trim().length() == 0) { + ctx.error(cls, shown + " on " + where + " names no cache. Name one: " + + shown + "(\"products\")."); + return false; + } + if (cacheNames.size() > 1) { + ctx.error(cls, shown + " on " + where + " names " + cacheNames.size() + " caches; " + + "one entry is kept per call here, so name one."); + return false; + } + String name = cacheNames.get(0).trim(); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (c <= ' ' || c == ':' || c == '*' || c == '?' || c == '[' || c == ']' + || c == '\\' || c > '~') { + ctx.error(cls, shown + " on " + where + " names the cache \"" + name + + "\"; a cache name is printable ASCII without spaces, ':', or the " + + "glob characters *?[]\\."); + return false; + } + } + String unless = cache.getStringOrDefault("unless", "").trim(); + if (unless.length() > 0 && !unless.replace(" ", "").equals("#result==null")) { + ctx.error(cls, shown + " on " + where + " has unless = \"" + unless + "\"; only " + + "\"#result == null\" is supported, and a null result is never kept " + + "anyway."); + return false; + } + Type[] args = Type.getArgumentTypes(m.getDescriptor()); + String key; + if (CACHE_EVICT.equals(d) && cache.getBoolOrDefault("allEntries", false)) { + key = null; + } else { + Map names; + try { + names = MethodSecurityCompiler.parameterNames(cls, m); + } catch (MethodSecurityCompiler.Refused refused) { + names = Collections.emptyMap(); + } + try { + key = new CacheKeyCompiler(ctx, m, args, names) + .compile(cache.getStringOrDefault("key", "")); + } catch (CacheKeyCompiler.Refused refused) { + ctx.error(cls, "The key of " + shown + " on " + where + " cannot be compiled: " + + refused.getMessage() + "."); + return false; + } + } + a.cache = cache; + a.cacheName = name; + a.cacheKey = key; + if (CACHE_EVICT.equals(d)) { + return true; + } + Type ret = Type.getReturnType(m.getDescriptor()); + if (ret.getSort() == Type.VOID) { + ctx.error(cls, shown + " on " + where + " returns nothing, so there is nothing to " + + "keep. Return the value, or use @CacheEvict."); + return false; + } + String sig = m.getSignature(); + String type = RestClientAnnotationProcessor.javaTypeFor(ret, + sig == null ? null : sig.substring(sig.lastIndexOf(')') + 1)); + BackendJsonCodecs codecs = BackendJsonCodecs.of(ctx); + String why = codecs.checkWrite(type); + if (why == null) { + why = codecs.checkRead(type); + } + if (why != null) { + ctx.error(cls, shown + " on " + where + " returns " + type + ", which cannot be " + + "kept as JSON: it holds " + why + "."); + return false; + } + a.cacheType = type; + a.cacheWrite = codecs.writeStatements(type, "result", "0", ""); + a.cacheRead = codecs.readStatements(type, "j", + "com.codename1.impl.backend.JsonCodec.Path.ROOT", "null", "-1", "0", "target", + ""); + return true; + } + private void compileSecurity(AnnotatedClass cls, MethodInfo m, AnnotationValues security, Aspect a, String where) { String d = security.getDescriptor(); @@ -3472,6 +3631,12 @@ private void emit() throws ProcessingException { > Collections.frequency(b.managed.operationWrites, null); } } + for (Aspects a : aspects.values()) { + for (Aspect m : a.methods) { + // A cached value is written and read by the same codecs. + toolCodecs |= m.cacheType != null; + } + } if (toolCodecs) { sources.putAll(BackendJsonCodecs.of(ctx).sources()); if (ctx.hasErrors()) { diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendSources.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendSources.java index 96f70773498..8e5b5a89294 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendSources.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendSources.java @@ -86,6 +86,11 @@ void aspects(BackendBeans.Aspects a, Map out) { if (aspect.transactional != null) { layers.add("tx"); } + if (aspect.cache != null) { + // Outside the transaction, so a hit opens none; inside the + // timing, so a timed method's histogram counts its hits. + layers.add("cache"); + } if (aspect.timed != null || aspect.counted != null) { layers.add("metrics"); } @@ -97,6 +102,8 @@ void aspects(BackendBeans.Aspects a, Map out) { .append(params).append(") throws Throwable {\n"); if ("tx".equals(layers.get(i))) { transaction(sb, aspect, inner, isVoid, retType); + } else if ("cache".equals(layers.get(i))) { + cache(sb, aspect, inner, isVoid, retType); } else { metrics(sb, cls, aspect, index, inner, isVoid, retType); } @@ -160,6 +167,87 @@ private void transaction(StringBuilder sb, BackendBeans.Aspect aspect, String in } } + private void cache(StringBuilder sb, BackendBeans.Aspect aspect, String inner, + boolean isVoid, String retType) { + String rt = "com.codename1.impl.backend.CacheRuntime."; + String name = quote(aspect.cacheName); + String d = aspect.cache.getDescriptor(); + if (aspect.cacheKey != null) { + sb.append(" String cn1Key = ").append(aspect.cacheKey).append(";\n"); + } + if (BackendBeans.CACHE_EVICT.equals(d)) { + String evict = aspect.cacheKey == null ? rt + "evictAll(" + name + ");\n" + : rt + "evict(" + name + ", cn1Key);\n"; + boolean before = aspect.cache.getBoolOrDefault("beforeInvocation", false); + if (before) { + sb.append(" ").append(evict); + } + if (!isVoid) { + sb.append(" ").append(retType).append(" cn1Result = ").append(inner) + .append(";\n"); + } else { + sb.append(" ").append(inner).append(";\n"); + } + if (!before) { + sb.append(" ").append(evict); + } + if (!isVoid) { + sb.append(" return cn1Result;\n"); + } + return; + } + String type = BackendJsonCodecs.source(aspect.cacheType); + if (BackendBeans.CACHEABLE.equals(d)) { + sb.append(" byte[] cn1Hit = ").append(rt).append("get(").append(name) + .append(", cn1Key);\n"); + sb.append(" if (cn1Hit != null) {\n"); + sb.append(" Object j = ").append(rt).append("parse(cn1Hit);\n"); + sb.append(" ").append(type).append(" target = ") + .append(defaultValue(retType)).append(";\n"); + for (String line : aspect.cacheRead.split("\n")) { + sb.append(" ").append(line).append('\n'); + } + sb.append(" return target;\n"); + sb.append(" }\n"); + } + sb.append(" final ").append(type).append(" result = ").append(inner).append(";\n"); + boolean primitive = retType.indexOf('.') < 0 && !retType.endsWith("]") + && Character.isLowerCase(retType.charAt(0)); + String writer = "new com.codename1.backend.Json.Writable() {\n" + + " public void writeTo(com.codename1.backend.ByteSink out) {\n"; + StringBuilder w = new StringBuilder(writer); + for (String line : aspect.cacheWrite.split("\n")) { + w.append(" ").append(line).append('\n'); + } + w.append(" }\n }"); + if (primitive) { + sb.append(" ").append(rt).append("put(").append(name).append(", cn1Key, ") + .append(w).append(");\n"); + } else if (BackendBeans.CACHEABLE.equals(d)) { + // A null result is a miss next time too; there is nothing to remove. + sb.append(" if (result != null) {\n ").append(rt).append("put(") + .append(name).append(", cn1Key, ").append(w).append(");\n }\n"); + } else { + // @CachePut of null removes the entry, so a later read cannot be + // answered with a value the method no longer gives. + sb.append(" ").append(rt).append("put(").append(name).append(", cn1Key, ") + .append("result == null ? null : ").append(w).append(");\n"); + } + sb.append(" return result;\n"); + } + + private static String defaultValue(String type) { + if ("boolean".equals(type)) { + return "false"; + } + if ("byte".equals(type) || "short".equals(type) || "int".equals(type) + || "long".equals(type) || "char".equals(type) || "float".equals(type) + || "double".equals(type)) { + return "(" + type + ") 0"; + } + return "null"; + } + /// Spring's rollback rule as one expression over `cn1Error`: the listed types, /// most specific first, then unchecked-rolls-back. String rollbackDecision(AnnotationValues tx) { diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendWiringWriter.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendWiringWriter.java index 55879e6e351..4a579be7d19 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendWiringWriter.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendWiringWriter.java @@ -580,6 +580,11 @@ String point(BackendBeans.Point p) { if ("session".equals(p.builtin)) { return "transactionSession"; } + if ("keyValue".equals(p.builtin)) { + // The server's own store, opened on first use and closed when + // the server stops. + return "com.codename1.backend.kv.KeyValueStores.current()"; + } if ("repository".equals(p.builtin)) { return model.repositories.get(p.type.getInternalName()); } diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/CacheKeyCompiler.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/CacheKeyCompiler.java new file mode 100644 index 00000000000..f7524dc0c6e --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/CacheKeyCompiler.java @@ -0,0 +1,255 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven.processors; + +import com.codename1.maven.annotations.AnnotatedClass; +import com.codename1.maven.annotations.FieldInfo; +import com.codename1.maven.annotations.MethodInfo; +import com.codename1.maven.annotations.ProcessorContext; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.objectweb.asm.Type; + +/// Compiles the key expression of `@Cacheable`, `@CachePut` and `@CacheEvict` +/// into a Java expression over the helper's arguments `a0..aN`. +/// +/// The SpEL subset Spring code keys caches with, and nothing more: a parameter +/// by name (`#id`) or position (`#p0`, `#a0`), a chain of properties of one +/// (`#order.customer.id`, each a public getter, record accessor or public +/// field), a quoted literal (`'all'`), a number, `#root.methodName`, joined with +/// `+`. Anything else is refused with the reason, which the build reports: a +/// key that silently meant something else would be a cache answering one +/// caller with another's data. +final class CacheKeyCompiler { + private final ProcessorContext ctx; + private final MethodInfo method; + private final Type[] args; + private final Map names; + + CacheKeyCompiler(ProcessorContext ctx, MethodInfo method, Type[] args, + Map names) { + this.ctx = ctx; + this.method = method; + this.args = args; + this.names = names; + } + + /// Thrown with why an expression cannot be compiled. + static final class Refused extends Exception { + private static final long serialVersionUID = 1L; + + Refused(String message) { + super(message); + } + } + + /// The Java expression, a `String`, that `expression` keys by; empty means + /// Spring's default key over every parameter. + String compile(String expression) throws Refused { + String e = expression == null ? "" : expression.trim(); + if (e.length() == 0) { + StringBuilder sb = new StringBuilder( + "com.codename1.impl.backend.CacheRuntime.key(new Object[] {"); + for (int i = 0; i < args.length; i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(box(args[i], "a" + i)); + } + return sb.append("})").toString(); + } + List terms = split(e); + StringBuilder sb = new StringBuilder(); + for (String term : terms) { + if (sb.length() > 0) { + sb.append(" + "); + } + sb.append(term(term.trim())); + } + // "" + makes a lone number or char a String. + return terms.size() == 1 ? "String.valueOf(" + sb + ")" : "(\"\" + " + sb + ")"; + } + + /// The terms between top-level `+`, with quoted text kept whole. + private static List split(String e) throws Refused { + List out = new ArrayList(); + int start = 0; + boolean quoted = false; + for (int i = 0; i < e.length(); i++) { + char c = e.charAt(i); + if (c == '\'') { + quoted = !quoted; + } else if (c == '+' && !quoted) { + out.add(e.substring(start, i)); + start = i + 1; + } + } + if (quoted) { + throw new Refused("a quoted literal is not closed"); + } + out.add(e.substring(start)); + for (String t : out) { + if (t.trim().length() == 0) { + throw new Refused("a '+' has nothing on one side"); + } + } + return out; + } + + private String term(String t) throws Refused { + if (t.length() >= 2 && t.charAt(0) == '\'' && t.charAt(t.length() - 1) == '\'') { + String text = t.substring(1, t.length() - 1); + if (text.indexOf('\'') >= 0) { + throw new Refused("a literal cannot contain a quote"); + } + return BackendSources.quote(text); + } + if (isNumber(t)) { + return BackendSources.quote(t); + } + if (t.length() < 2 || t.charAt(0) != '#') { + throw new Refused("\"" + t + "\" is not a parameter (#name), a quoted literal or " + + "#root.methodName"); + } + String[] path = t.substring(1).split("\\.", -1); + if ("root".equals(path[0])) { + if (path.length == 2 && "methodName".equals(path[1])) { + return BackendSources.quote(method.getName()); + } + throw new Refused("of #root only #root.methodName is supported"); + } + int index = parameter(path[0]); + StringBuilder expr = new StringBuilder("a").append(index); + Type type = args[index]; + for (int i = 1; i < path.length; i++) { + String prop = path[i]; + if (!isIdentifier(prop)) { + throw new Refused("\"" + t + "\" is not a chain of property names; method " + + "calls, indexes and operators are not supported"); + } + if (type.getSort() != Type.OBJECT) { + throw new Refused("\"" + prop + "\" is read from a " + type.getClassName() + + ", which has no properties"); + } + String[] step = property(type.getInternalName(), prop); + if (step == null) { + throw new Refused(type.getClassName() + " has no public getter, accessor or " + + "field \"" + prop + "\""); + } + expr.append('.').append(step[0]); + type = Type.getType(step[1]); + } + return expr.toString(); + } + + private int parameter(String name) throws Refused { + Integer at = names.get(name); + if (at != null) { + return at.intValue(); + } + if (name.length() > 1 && (name.charAt(0) == 'p' || name.charAt(0) == 'a') + && isNumber(name.substring(1))) { + int i = Integer.parseInt(name.substring(1)); + if (i < args.length) { + return i; + } + } + throw new Refused("\"#" + name + "\" names no parameter of " + method.getName() + + (names.isEmpty() ? "; the class was compiled without parameter names, so " + + "use #p0, #p1 ... or compile with -parameters" : "")); + } + + /// The Java member access reading `prop` of `owner` and its descriptor, or + /// null when it has none the helper may read. + private String[] property(String owner, String prop) { + String cap = Character.toUpperCase(prop.charAt(0)) + prop.substring(1); + String current = owner; + for (int depth = 0; current != null && depth < 32; depth++) { + AnnotatedClass c = RestControllerAnnotationProcessor.resolveClass(ctx, current); + if (c == null) { + return null; + } + String[] names = {"get" + cap, "is" + cap, prop}; + for (String n : names) { + for (MethodInfo m : c.getMethods()) { + if (m.isPublic() && !m.isStatic() && n.equals(m.getName()) + && m.getDescriptor().startsWith("()") + && !m.getDescriptor().endsWith(")V") + && (!n.startsWith("is") || n.equals(prop) + || m.getDescriptor().equals("()Z"))) { + return new String[] {n + "()", Type.getReturnType(m.getDescriptor()) + .getDescriptor()}; + } + } + } + for (FieldInfo f : c.getFields()) { + if (f.isPublic() && !f.isStatic() && prop.equals(f.getName())) { + return new String[] {prop, f.getDescriptor()}; + } + } + current = c.getSuperInternalName(); + } + return null; + } + + private static String box(Type t, String expr) { + switch (t.getSort()) { + case Type.BOOLEAN: return "Boolean.valueOf(" + expr + ")"; + case Type.CHAR: return "Character.valueOf(" + expr + ")"; + case Type.BYTE: return "Byte.valueOf(" + expr + ")"; + case Type.SHORT: return "Short.valueOf(" + expr + ")"; + case Type.INT: return "Integer.valueOf(" + expr + ")"; + case Type.LONG: return "Long.valueOf(" + expr + ")"; + case Type.FLOAT: return "Float.valueOf(" + expr + ")"; + case Type.DOUBLE: return "Double.valueOf(" + expr + ")"; + default: return expr; + } + } + + private static boolean isNumber(String s) { + if (s.length() == 0) { + return false; + } + for (int i = 0; i < s.length(); i++) { + if (!Character.isDigit(s.charAt(i))) { + return false; + } + } + return true; + } + + private static boolean isIdentifier(String s) { + if (s.length() == 0 || !Character.isJavaIdentifierStart(s.charAt(0))) { + return false; + } + for (int i = 1; i < s.length(); i++) { + if (!Character.isJavaIdentifierPart(s.charAt(i))) { + return false; + } + } + return true; + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java index 1cf4dee77ae..c8ab660fb47 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java @@ -3940,6 +3940,12 @@ private static List propertiesDirectories(ProcessorContext ctx) { /// `application-.properties` beside it -- what a build can know about /// a setting that Config will read at run time. static boolean applicationPropertyKnown(ProcessorContext ctx, String key) { + return applicationProperty(ctx, key) != null; + } + + /// The first value `key` has in the module's `application.properties` or an + /// `application-.properties` beside it, or null for none. + static String applicationProperty(ProcessorContext ctx, String key) { for (File f : applicationPropertyFiles(ctx)) { java.util.Properties props = new java.util.Properties(); InputStream in = null; @@ -3957,11 +3963,12 @@ static boolean applicationPropertyKnown(ProcessorContext ctx, String key) { } } } - if (props.getProperty(key) != null) { - return true; + String v = props.getProperty(key); + if (v != null) { + return v; } } - return false; + return null; } /// The module's base `application.properties`, loaded, or null when it has diff --git a/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java index fc869932f7d..8c24f65fad7 100644 --- a/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java @@ -1506,6 +1506,221 @@ public void aTransactionOnAPrivateMethodReturningAPrivateClassCommitsAndRollsBac } } + /// `@Cacheable`, `@CachePut` and `@CacheEvict` woven into a bean, run against + /// the in-process store -- or a Redis server when `CN1_TEST_REDIS` names one, + /// which is how the same test proves the wire client. + @Test + public void cachingAnnotationsAnswerFromTheStoreAndEvict() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Product", PKG + "public class Product {\n" + + " public long id;\n" + + " public String name;\n" + + " public List tags;\n" + + " public long getId() { return id; }\n" + + "}\n"); + s.put("com.example.Catalog", PKG + "@Component @EnableCaching public class Catalog {\n" + + " public final AtomicInteger loads = new AtomicInteger();\n" + + " @Cacheable(value = \"products\", key = \"#p0\")\n" + + " public Product product(long id) {\n" + + " loads.incrementAndGet();\n" + + " Product p = new Product();\n" + + " p.id = id; p.name = \"p\" + id;\n" + + " p.tags = new ArrayList(Arrays.asList(\"a\", \"b\"));\n" + + " return p;\n" + + " }\n" + + " @Cacheable(\"counts\") public int count(String a, int b) {\n" + + " loads.incrementAndGet();\n" + + " return a.length() + b;\n" + + " }\n" + + " @CachePut(value = \"products\", key = \"#p0.id\")\n" + + " public Product save(Product p) { return p; }\n" + + " @CacheEvict(value = \"products\", key = \"#a0\") public void drop(long id) { }\n" + + " @CacheEvict(value = \"products\", allEntries = true) public void clear() { }\n" + + " @CacheEvict(cacheNames = \"counts\", allEntries = true) public void clearCounts() { }\n" + + " @CacheEvict(value = \"maybe\", allEntries = true) public void clearMaybe() { }\n" + + " @Cacheable(value = \"maybe\", key = \"'k'\", unless = \"#result == null\")\n" + + " public String nothing() { loads.incrementAndGet(); return null; }\n" + + " @Cacheable(value = \"maybe\", key = \"#root.methodName + ':' + #p0 + #p1\")\n" + + " public Map pair(String a, String b) {\n" + + " loads.incrementAndGet();\n" + + " Map m = new LinkedHashMap();\n" + + " m.put(a, Integer.valueOf(a.length())); m.put(b, Integer.valueOf(b.length()));\n" + + " return m;\n" + + " }\n" + + "}\n"); + s.put("com.example.Api", PKG + + "@RestController public class Api {\n" + + " private final Catalog c;\n" + + " private final com.codename1.backend.kv.KeyValueStore kv;\n" + + " public Api(Catalog c, com.codename1.backend.kv.KeyValueStore kv) {\n" + + " this.c = c; this.kv = kv;\n" + + " }\n" + + " @GetMapping(\"/run\") public String run() throws Exception {\n" + + " c.clear(); c.clearCounts(); c.clearMaybe(); c.drop(1);\n" + + " StringBuilder sb = new StringBuilder();\n" + + " Product a = c.product(1);\n" + + " Product b = c.product(1);\n" + + " sb.append(a.name).append(b.name).append(b.tags.size()).append(c.loads.get());\n" + + " Product x = new Product(); x.id = 1; x.name = \"x\";\n" + + " c.save(x);\n" + + " sb.append(' ').append(c.product(1).name).append(c.loads.get());\n" + + " c.drop(1);\n" + + " sb.append(' ').append(c.product(1).name).append(c.loads.get());\n" + + " sb.append(' ').append(c.count(\"ab\", 3)).append(c.count(\"ab\", 3))\n" + + " .append(c.count(\"ab\", 4)).append(c.loads.get());\n" + + " c.clear();\n" + + " c.product(1);\n" + + " sb.append(' ').append(c.loads.get());\n" + + " c.nothing(); c.nothing();\n" + + " sb.append(' ').append(c.loads.get());\n" + + " sb.append(' ').append(c.pair(\"q\", \"rr\")).append(c.pair(\"q\", \"rr\"))\n" + + " .append(c.loads.get());\n" + + " sb.append(' ').append(kv.get(\"cn1:cache:products:1\") != null);\n" + + " return sb.toString();\n" + + " }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + int port = freePort(); + URLClassLoader loader = new URLClassLoader(new URL[] {classes.toURI().toURL()}, + getClass().getClassLoader()); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + String redis = System.getenv("CN1_TEST_REDIS"); + settings.setProperty("cn1.kv.url", redis == null || redis.length() == 0 + ? "memory:" : redis); + Backend backend = withApplication(Backend.builder(Config.of(settings, "dev")).quiet(), + (BackendApplication) loader.loadClass("com.example.BackendWiring") + .newInstance()).start(); + try { + assertEquals("p1p121 x1 p12 5564 5 7 {q=1, rr=2}{q=1, rr=2}8 true", + http("GET", port, "/run")); + } finally { + backend.stop(); + } + } + + /// Replicas over one Redis, with no database anywhere: a job whose lock + /// another replica holds does not run, and a session one server stored is read + /// by the next. + /// Needs `CN1_TEST_REDIS`; the in-process store is per server and shares + /// nothing, so without one only the build half runs. + @Test + public void replicasShareLocksAndSessionsThroughTheStore() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Jobs", PKG + "@Component public class Jobs {\n" + + " public static final AtomicInteger inside = new AtomicInteger();\n" + + " public static final AtomicInteger most = new AtomicInteger();\n" + + " public static final AtomicInteger runs = new AtomicInteger();\n" + + " @Scheduled(fixedRate = 10, lock = \"tick\", lockAtMostFor = 5000)\n" + + " public void tick() throws Exception {\n" + + " int now = inside.incrementAndGet();\n" + + " int seen;\n" + + " while ((seen = most.get()) < now && !most.compareAndSet(seen, now)) { }\n" + + " Thread.sleep(30);\n" + + " inside.decrementAndGet();\n" + + " runs.incrementAndGet();\n" + + " }\n" + + "}\n"); + s.put("com.example.Api", PKG + + "@RestController public class Api {\n" + + " @GetMapping(\"/put\") public String put(HttpServer.Request r) {\n" + + " r.getSession(true).setAttribute(\"who\", \"alice\");\n" + + " return \"ok\";\n" + + " }\n" + + " @GetMapping(\"/get\") public String get(HttpServer.Request r) {\n" + + " HttpSession session = r.getSession(false);\n" + + " return session == null ? \"none\" : String.valueOf(session.getAttribute(\"who\"));\n" + + " }\n" + + "}\n"); + File classes = compile(s); + String redis = System.getenv("CN1_TEST_REDIS"); + String url = redis == null || redis.length() == 0 ? "redis://127.0.0.1:1/0" : redis; + // The build accepts the lock without a database because the module names + // a shared store. + writeProperties(classes, "application.properties", "cn1.kv.url=" + url + "\n"); + assertNoErrors(process(classes)); + org.junit.Assume.assumeTrue("CN1_TEST_REDIS names no Redis server", redis != null + && redis.length() > 0); + URLClassLoader loader = new URLClassLoader(new URL[] {classes.toURI().toURL()}, + getClass().getClassLoader()); + Class jobs = loader.loadClass("com.example.Jobs"); + java.util.concurrent.atomic.AtomicInteger runs = + (java.util.concurrent.atomic.AtomicInteger) jobs.getField("runs").get(null); + // Another replica holds the lock for the next 1.5 seconds. + com.codename1.backend.kv.KeyValueStore kv = + com.codename1.backend.kv.KeyValueStores.open(url, 5000, 1); + String cookie; + try { + kv.set("cn1:lock:tick", "another-replica".getBytes("UTF-8"), 1500); + Backend a = startShared(loader, url); + try { + Thread.sleep(700); + assertEquals("the job ran while another replica held its lock", 0, runs.get()); + HttpURLConnection first = (HttpURLConnection) new URL("http://127.0.0.1:" + + lastPort + "/put").openConnection(); + assertEquals("ok", read(first)); + cookie = first.getHeaderField("Set-Cookie"); + assertNotNull("no session cookie", cookie); + Thread.sleep(1800); + assertTrue("the job never ran once the lock was free", runs.get() > 0); + } finally { + a.stop(); + } + assertNull("the lock outlived the server that held it", kv.get("cn1:lock:tick")); + } finally { + kv.close(); + } + // A different server -- the one process runs one at a time -- reads the + // session the first one stored. + Backend b = startShared(loader, url); + try { + HttpURLConnection second = (HttpURLConnection) new URL("http://127.0.0.1:" + + lastPort + "/get").openConnection(); + second.setRequestProperty("Cookie", cookie.substring(0, cookie.indexOf(';'))); + assertEquals("alice", read(second)); + } finally { + b.stop(); + } + int most = ((java.util.concurrent.atomic.AtomicInteger) jobs.getField("most") + .get(null)).get(); + assertEquals("the locked job overlapped itself", 1, most); + } + + private int lastPort; + + private Backend startShared(URLClassLoader loader, String url) throws Exception { + lastPort = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(lastPort)); + settings.setProperty("cn1.kv.url", url); + settings.setProperty("cn1.session.store", "kv"); + return withApplication(Backend.builder(Config.of(settings, "dev")).quiet(), + (BackendApplication) loader.loadClass("com.example.BackendWiring") + .newInstance()).start(); + } + + /// A key expression the build cannot compile fails the build, naming why. + @Test + public void anUncompilableCacheKeyFailsTheBuild() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Bad", PKG + "@Component public class Bad {\n" + + " @Cacheable(value = \"c\", key = \"#id.toString()\")\n" + + " public String one(String id) { return id; }\n" + + " @Cacheable(value = \"c\", key = \"#nope\")\n" + + " public String two(String id) { return id; }\n" + + " @Cacheable(\"c\") public void three() { }\n" + + " @Cacheable(value = \"c\", unless = \"#result.isEmpty()\")\n" + + " public String four() { return \"\"; }\n" + + "}\n"); + ProcessorContext ctx = process(compile(s)); + String errors = String.valueOf(ctx.getErrors()); + assertTrue(errors, errors.contains("one cannot be compiled")); + assertTrue(errors, errors.contains("\"#nope\" names no parameter")); + assertTrue(errors, errors.contains("three returns nothing")); + assertTrue(errors, errors.contains("only \"#result == null\" is supported")); + } + /// A `@Repository` interface is injected like a bean: built once from the /// factory the ORM's bootstrap registered, over the injected Session, so it /// works inside the caller's @Transactional method and every injection point diff --git a/vm/backend/demo/kvcheck/com/demo/KvCheck.java b/vm/backend/demo/kvcheck/com/demo/KvCheck.java new file mode 100644 index 00000000000..1fa80d0896f --- /dev/null +++ b/vm/backend/demo/kvcheck/com/demo/KvCheck.java @@ -0,0 +1,316 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.demo; + +import com.codename1.backend.kv.KeyValueStore; +import com.codename1.backend.kv.KeyValueStores; +import com.codename1.backend.kv.RedisKeyValueStore; +import com.codename1.backend.security.ratelimit.KeyValueRateLimiter; +import java.util.ArrayList; +import java.util.List; + +/// Exercises the key/value store against the in-process store and, when +/// `CN1_KVCHECK_URL` (or the first argument) names one, a real Redis-protocol +/// server -- Redis, Valkey or KeyDB. The same checks run on both, so a wire +/// client that disagrees with the reference semantics fails here, on the JVM +/// and on the native build alike. +public class KvCheck { + private static int passed; + private static final List failures = new ArrayList(); + + public static void main(String[] args) throws Exception { + run("memory:"); + String url = args.length > 0 ? args[0] : System.getenv("CN1_KVCHECK_URL"); + if (url != null && url.length() > 0) { + run(url); + } + System.out.println("passed=" + passed + " failed=" + failures.size()); + for (String f : failures) { + System.out.println("FAIL " + f); + } + System.out.println(failures.isEmpty() ? "KVCHECK OK" : "KVCHECK FAILED"); + if (!failures.isEmpty()) { + System.exit(1); + } + } + + private static void run(String url) throws Exception { + KeyValueStore kv = KeyValueStores.open(url, 5000, 4); + String p = "kvcheck:" + Long.toString(System.nanoTime(), 36) + ":"; + String where = url.startsWith("memory:") ? "memory" : "redis"; + try { + check(where + " ping", true, kv.ping()); + check(where + " shared", !url.startsWith("memory:"), kv.isShared()); + basics(where, kv, p); + binary(where, kv, p); + prefix(where, kv, p); + concurrency(where, kv, p); + pubsub(where, kv, p); + rateLimit(where, kv, p); + if (kv instanceof RedisKeyValueStore) { + errors(where, (RedisKeyValueStore) kv, p); + } + } finally { + kv.deleteByPrefix(p); + kv.close(); + } + } + + private static void basics(String w, KeyValueStore kv, String p) throws Exception { + check(w + " missing get", null, str(kv.get(p + "none"))); + check(w + " missing ttl", -2L, kv.ttl(p + "none")); + kv.set(p + "a", b("one"), 0); + check(w + " get", "one", str(kv.get(p + "a"))); + check(w + " no expiry", -1L, kv.ttl(p + "a")); + kv.set(p + "t", b("x"), 60000); + long ttl = kv.ttl(p + "t"); + check(w + " ttl in range", true, ttl > 55000 && ttl <= 60000); + kv.set(p + "short", b("x"), 150); + Thread.sleep(400); + check(w + " expired", null, str(kv.get(p + "short"))); + check(w + " setIfAbsent first", true, kv.setIfAbsent(p + "n", b("1"), 0)); + check(w + " setIfAbsent second", false, kv.setIfAbsent(p + "n", b("2"), 0)); + check(w + " setIfAbsent kept", "1", str(kv.get(p + "n"))); + check(w + " cas wrong", false, kv.compareAndSet(p + "n", b("9"), b("3"), 0)); + check(w + " cas right", true, kv.compareAndSet(p + "n", b("1"), b("3"), 0)); + check(w + " cas value", "3", str(kv.get(p + "n"))); + check(w + " cas create", true, kv.compareAndSet(p + "c", null, b("new"), 0)); + check(w + " cas create twice", false, kv.compareAndSet(p + "c", null, b("x"), 0)); + check(w + " cas delete", true, kv.compareAndSet(p + "c", b("new"), null, 0)); + check(w + " cas deleted", null, str(kv.get(p + "c"))); + check(w + " incr new", 5L, kv.increment(p + "i", 5, 60000)); + check(w + " incr again", 3L, kv.increment(p + "i", -2, 60000)); + long ittl = kv.ttl(p + "i"); + check(w + " incr ttl set on create", true, ittl > 0 && ittl <= 60000); + check(w + " delete", true, kv.delete(p + "i")); + check(w + " delete again", false, kv.delete(p + "i")); + } + + private static void binary(String w, KeyValueStore kv, String p) throws Exception { + byte[] raw = new byte[256]; + for (int i = 0; i < raw.length; i++) { + raw[i] = (byte) i; + } + kv.set(p + "bin", raw, 0); + check(w + " binary round trip", true, same(raw, kv.get(p + "bin"))); + byte[] big = new byte[1 << 20]; + for (int i = 0; i < big.length; i++) { + big[i] = (byte) (i * 31); + } + kv.set(p + "big", big, 0); + check(w + " 1MB round trip", true, same(big, kv.get(p + "big"))); + kv.set(p + "utf\u00e9\u4e2d", b("\u00e9\r\n\u4e2d"), 0); + check(w + " utf8 key and value", "\u00e9\r\n\u4e2d", str(kv.get(p + "utf\u00e9\u4e2d"))); + kv.set(p + "empty", new byte[0], 0); + byte[] e = kv.get(p + "empty"); + check(w + " empty value", 0, e == null ? -1 : e.length); + } + + private static void prefix(String w, KeyValueStore kv, String p) throws Exception { + // More than one SCAN page, and a glob character in the prefix that must + // match only itself. + for (int i = 0; i < 1200; i++) { + kv.set(p + "pre*:" + i, b("v"), 0); + } + kv.set(p + "preX:keep", b("v"), 0); + kv.set(p + "pre*", b("v"), 0); + check(w + " deleteByPrefix count", 1200, kv.deleteByPrefix(p + "pre*:")); + check(w + " deleteByPrefix spared glob look-alike", "v", str(kv.get(p + "preX:keep"))); + check(w + " deleteByPrefix spared shorter", "v", str(kv.get(p + "pre*"))); + check(w + " deleteByPrefix gone", null, str(kv.get(p + "pre*:7"))); + } + + private static void concurrency(String w, final KeyValueStore kv, final String p) + throws Exception { + final int threads = 8; + final int each = 250; + final Throwable[] error = new Throwable[1]; + Thread[] t = new Thread[threads]; + for (int i = 0; i < threads; i++) { + t[i] = new Thread() { + public void run() { + try { + for (int n = 0; n < each; n++) { + kv.increment(p + "ctr", 1, 0); + } + } catch (Throwable err) { + synchronized (error) { + error[0] = err; + } + } + } + }; + t[i].start(); + } + for (int i = 0; i < threads; i++) { + t[i].join(); + } + check(w + " concurrent increments error", null, error[0] == null ? null + : String.valueOf(error[0])); + check(w + " concurrent increments", (long) threads * each, + kv.increment(p + "ctr", 0, 0)); + // A CAS race: exactly one of the contenders wins each round. + kv.set(p + "race", b("0"), 0); + final int[] wins = new int[1]; + for (int i = 0; i < threads; i++) { + t[i] = new Thread() { + public void run() { + try { + if (kv.compareAndSet(p + "race", b("0"), b("1"), 0)) { + synchronized (wins) { + wins[0]++; + } + } + } catch (Throwable err) { + synchronized (error) { + error[0] = err; + } + } + } + }; + t[i].start(); + } + for (int i = 0; i < threads; i++) { + t[i].join(); + } + check(w + " one CAS winner", 1, wins[0]); + } + + private static void pubsub(String w, KeyValueStore kv, String p) throws Exception { + final List got = new ArrayList(); + KeyValueStore.Subscription sub = kv.subscribe(p + "ch", + new KeyValueStore.MessageListener() { + public void onMessage(String channel, byte[] message) { + synchronized (got) { + got.add(channel + "=" + str(message)); + got.notifyAll(); + } + } + }); + // A subscription on a remote server is live once SUBSCRIBE is + // acknowledged; publish until the first message arrives. + long end = System.currentTimeMillis() + 5000; + synchronized (got) { + while (got.isEmpty() && System.currentTimeMillis() < end) { + got.wait(100); + if (got.isEmpty()) { + kv.publish(p + "ch", b("hello")); + } + } + } + synchronized (got) { + got.clear(); + } + kv.publish(p + "ch", b("second")); + kv.publish(p + "other", b("not mine")); + synchronized (got) { + end = System.currentTimeMillis() + 5000; + while (got.isEmpty() && System.currentTimeMillis() < end) { + got.wait(100); + } + } + Thread.sleep(200); + synchronized (got) { + check(w + " pubsub delivered once", "[" + p + "ch=second]", got.toString()); + } + sub.close(); + Thread.sleep(200); + kv.publish(p + "ch", b("after close")); + Thread.sleep(300); + synchronized (got) { + check(w + " no delivery after close", 1, got.size()); + } + } + + private static void rateLimit(String w, KeyValueStore kv, String p) throws Exception { + KeyValueRateLimiter rl = new KeyValueRateLimiter(kv, p + "login", 3, 60); + int allowed = 0; + for (int i = 0; i < 5; i++) { + if (rl.tryAcquire("1.2.3.4")) { + allowed++; + } + } + check(w + " rate limit permits", 3, allowed); + check(w + " other key unaffected", true, rl.tryAcquire("5.6.7.8")); + long retry = rl.retryAfterSeconds("1.2.3.4"); + check(w + " retry-after in window", true, retry >= 1 && retry <= 60); + rl.reset("1.2.3.4"); + check(w + " reset", true, rl.tryAcquire("1.2.3.4")); + kv.deleteByPrefix("cn1:rl:"); + } + + private static void errors(String w, RedisKeyValueStore kv, String p) throws Exception { + kv.set(p + "s", b("text"), 0); + String code = "none"; + try { + kv.call("LPUSH", p + "s", "x"); + } catch (com.codename1.backend.kv.RedisException err) { + code = err.getCode(); + } + check(w + " error reply code", "WRONGTYPE", code); + // The connection the error came back on is still in step. + check(w + " usable after error", "text", str(kv.get(p + "s"))); + } + + // ------------------------------------------------------------------ helpers + + private static byte[] b(String s) { + try { + return s.getBytes("UTF-8"); + } catch (java.io.UnsupportedEncodingException err) { + throw new IllegalStateException(err.toString()); + } + } + + private static String str(byte[] v) { + if (v == null) { + return null; + } + try { + return new String(v, "UTF-8"); + } catch (java.io.UnsupportedEncodingException err) { + throw new IllegalStateException(err.toString()); + } + } + + private static boolean same(byte[] a, byte[] c) { + if (a == null || c == null || a.length != c.length) { + return false; + } + for (int i = 0; i < a.length; i++) { + if (a[i] != c[i]) { + return false; + } + } + return true; + } + + private static void check(String what, Object expected, Object actual) { + boolean ok = expected == null ? actual == null : expected.equals(actual); + if (ok) { + passed++; + } else { + failures.add(what + ": expected <" + expected + "> but was <" + actual + ">"); + } + } +} diff --git a/vm/backend/src/com/codename1/backend/Access.java b/vm/backend/src/com/codename1/backend/Access.java index ec9d94aa006..e4664e716d9 100644 --- a/vm/backend/src/com/codename1/backend/Access.java +++ b/vm/backend/src/com/codename1/backend/Access.java @@ -355,4 +355,15 @@ public Object namedBean(String name) { public List executorsOf(Collection servers) { return Tasks.executorsOf(servers); } + + @Override + public Object serverResource(String key, BackendAccess.ResourceFactory factory) + throws IOException { + return Tasks.resource(key, factory); + } + + @Override + public Config currentConfig() { + return Tasks.currentConfig(); + } } diff --git a/vm/backend/src/com/codename1/backend/Sessions.java b/vm/backend/src/com/codename1/backend/Sessions.java index 10b390f7db9..dfbf93b61c8 100644 --- a/vm/backend/src/com/codename1/backend/Sessions.java +++ b/vm/backend/src/com/codename1/backend/Sessions.java @@ -144,9 +144,13 @@ public static Sessions configure(Config config, boolean tls, DataSource pool, + "server has none"); } out.setStore(new Db(pool, config.get("cn1.session.namespace", ""))); + } else if ("kv".equalsIgnoreCase(kind) || "redis".equalsIgnoreCase(kind)) { + // The server's key/value store, opened on first use: cn1.kv.url, + // or Spring Boot's spring.data.redis.* settings. + out.setStore(new KeyValue(null, config.get("cn1.session.namespace", ""))); } else if (!"memory".equalsIgnoreCase(kind)) { throw new IOException("cn1.session.store is \"" + kind - + "\"; it must be memory or db"); + + "\"; it must be memory, db or kv"); } return out; } @@ -281,7 +285,8 @@ void purgeIfDue(SessionStore s, long now) { // timeout, because the last use it has stored lags; the beans get // the same grace, or a request in that window would find the row // still valid after its beans were destroyed, and build a second set. - long grace = s instanceof Db ? Db.touchInterval(h.maxInactiveSeconds) : 0; + long grace = s instanceof Db || s instanceof KeyValue + ? Db.touchInterval(h.maxInactiveSeconds) : 0; if (h.maxInactiveSeconds > 0 && now - h.lastAccessed > h.maxInactiveSeconds * 1000L + grace && !busy.contains(e.getKey())) { @@ -418,6 +423,8 @@ HttpServer.Response finish(HttpServer.Request request, HttpSession session, session.clean(); } else if (s instanceof Db) { ((Db) s).touchIfStale(session); + } else if (s instanceof KeyValue) { + ((KeyValue) s).touchIfStale(session); } keep(session, previous); } @@ -898,6 +905,207 @@ public synchronized int size() { } } + /// Keeps sessions in the server's key/value store -- Redis once `cn1.kv.url` + /// names one -- chosen by `cn1.session.store=kv`, so any instance behind a + /// load balancer can serve any client, as Spring Session's Redis store does. + /// + /// Each session is one entry holding its times and attributes as JSON, with + /// an expiry of the timeout plus the touch interval, so the store expires + /// sessions by itself and nothing purges them. Saves merge this request's + /// changes onto what is stored, written back with compare-and-set, as [Db] + /// merges with its version column. + public static final class KeyValue implements SessionStore { + /// Attempts at an optimistic save before two requests' races are reported. + private static final int SAVE_ATTEMPTS = 8; + private final com.codename1.backend.kv.KeyValueStore fixed; + private final String prefix; + + /// A store over `store`, or over the calling thread's server's + /// [com.codename1.backend.kv.KeyValueStores#current()] when it is null, + /// keeping its sessions apart from other namespaces' as [Db] does. + public KeyValue(com.codename1.backend.kv.KeyValueStore store, String namespace) { + this.fixed = store; + this.prefix = "cn1:session:" + (namespace == null ? "" : namespace) + ":"; + } + + private com.codename1.backend.kv.KeyValueStore store() throws IOException { + return fixed != null ? fixed : com.codename1.backend.kv.KeyValueStores.current(); + } + + /// How long an entry lives: the timeout plus the touch interval, the + /// grace [Db] allows for a stored last use that lags the real one. + private static long ttl(int maxInactiveSeconds) { + return maxInactiveSeconds > 0 + ? maxInactiveSeconds * 1000L + Db.touchInterval(maxInactiveSeconds) : 0; + } + + private static byte[] encode(long created, long accessed, int maxInactive, Map values) { + Map doc = new LinkedHashMap(); + doc.put("c", Long.valueOf(created)); + doc.put("a", Long.valueOf(accessed)); + doc.put("m", Integer.valueOf(maxInactive)); + doc.put("at", values); + return com.codename1.impl.backend.WireBuffer.utf8(Json.write(doc)); + } + + private static Map decode(byte[] raw) throws IOException { + return raw == null ? null + : Json.parseObject(com.codename1.impl.backend.WireBuffer.fromUtf8(raw)); + } + + private static long number(Map doc, String name) { + Object v = doc.get(name); + return v instanceof Number ? ((Number) v).longValue() : 0L; + } + + private static Map attributes(Map doc) { + Object v = doc.get("at"); + return v instanceof Map ? new LinkedHashMap((Map) v) : new LinkedHashMap(); + } + + @Override + public HttpSession load(String id) throws IOException { + Map doc = decode(store().get(prefix + id)); + if (doc == null) { + return null; + } + int maxInactive = (int) number(doc, "m"); + HttpSession s = new HttpSession(id, number(doc, "c"), number(doc, "a"), maxInactive); + s.setExpiryGrace(Db.touchInterval(maxInactive)); + s.loadAttributes(attributes(doc)); + return s; + } + + @Override + public Object consumeAttribute(String id, String name) throws IOException { + com.codename1.backend.kv.KeyValueStore kv = store(); // NOPMD CloseResource - the server's store, closed when it stops + String key = prefix + id; + for (int attempt = 0; attempt < SAVE_ATTEMPTS; attempt++) { + byte[] raw = kv.get(key); + Map doc = decode(raw); + if (doc == null) { + return null; + } + Map values = attributes(doc); + Object taken = values.remove(name); + if (taken == null) { + return null; + } + int maxInactive = (int) number(doc, "m"); + long left = kv.ttl(key); + if (kv.compareAndSet(key, raw, encode(number(doc, "c"), number(doc, "a"), + maxInactive, values), left > 0 ? left : ttl(maxInactive))) { + return taken; + } + } + throw new IOException("Session attribute could not be consumed: concurrent updates"); + } + + /// The same merge [Db] does: this request's changed attributes applied to + /// what is stored now, written back only if nobody wrote in between. + @Override + public void save(HttpSession session, String previousId) throws IOException { + com.codename1.backend.kv.KeyValueStore kv = store(); // NOPMD CloseResource - the server's store, closed when it stops + java.util.Set changed = session.changedNames(); + Map mine = session.attributesCopy(); + if (session.isNew()) { + kv.set(prefix + session.getId(), encode(session.getCreationTime(), + session.getLastAccessedTime(), session.getMaxInactiveInterval(), mine), + ttl(session.getMaxInactiveInterval())); + session.storedAccessed = session.getLastAccessedTime(); + if (previousId != null) { + kv.delete(prefix + previousId); + } + return; + } + String from = prefix + (previousId != null ? previousId : session.getId()); + for (int attempt = 0; attempt < SAVE_ATTEMPTS; attempt++) { + byte[] raw = kv.get(from); + Map doc = decode(raw); + if (doc == null) { + // Invalidated or expired meanwhile by another request: writing + // it back would undo a logout with a stale cookie. + if (previousId != null) { + session.markRotationLost(); + } + return; + } + Map merged = attributes(doc); + Iterator names = changed.iterator(); + while (names.hasNext()) { + Object name = names.next(); + if (mine.containsKey(name)) { + merged.put(name, mine.get(name)); + } else { + merged.remove(name); + } + } + int maxInactive = session.isMaxInactiveChanged() + ? session.getMaxInactiveInterval() : (int) number(doc, "m"); + long accessed = Math.max(number(doc, "a"), session.getLastAccessedTime()); + byte[] next = encode(number(doc, "c"), accessed, maxInactive, merged); + if (previousId != null) { + // A rotation: claim the old entry by deleting it, and only the + // request that deleted it writes the new one. + if (!kv.compareAndSet(from, raw, null, 0)) { + continue; + } + kv.set(prefix + session.getId(), next, ttl(maxInactive)); + session.storedAccessed = session.getLastAccessedTime(); + return; + } + if (kv.compareAndSet(from, raw, next, ttl(maxInactive))) { + session.storedAccessed = session.getLastAccessedTime(); + return; + } + } + throw new IOException("Session changes could not be saved: other requests " + + "kept changing it (" + SAVE_ATTEMPTS + " attempts)"); + } + + /// Moves the stored last use forward and renews the expiry, at most once + /// per touch interval -- reads keep a session alive without making every + /// request a write. + void touchIfStale(HttpSession session) throws IOException { + long now = System.currentTimeMillis(); + if (now - session.storedAccessed < Db.touchInterval(session.getMaxInactiveInterval())) { + return; + } + com.codename1.backend.kv.KeyValueStore kv = store(); // NOPMD CloseResource - the server's store, closed when it stops + String key = prefix + session.getId(); + for (int attempt = 0; attempt < SAVE_ATTEMPTS; attempt++) { + byte[] raw = kv.get(key); + Map doc = decode(raw); + if (doc == null) { + return; + } + int maxInactive = (int) number(doc, "m"); + byte[] next = encode(number(doc, "c"), Math.max(now, number(doc, "a")), + maxInactive, attributes(doc)); + if (kv.compareAndSet(key, raw, next, ttl(maxInactive))) { + session.storedAccessed = now; + return; + } + } + } + + @Override + public void delete(String id) throws IOException { + store().delete(prefix + id); + } + + /// Nothing to do: every entry carries its own expiry. + @Override + public int purgeExpired(long now) { + return 0; + } + + @Override + public int size() { + return -1; + } + } + /// Sessions in the server's database, in `cn1_http_session`, so every /// instance of a server sees every session. Attributes are stored as JSON. /// diff --git a/vm/backend/src/com/codename1/backend/Tasks.java b/vm/backend/src/com/codename1/backend/Tasks.java index 46b2f805491..960a16e14dd 100644 --- a/vm/backend/src/com/codename1/backend/Tasks.java +++ b/vm/backend/src/com/codename1/backend/Tasks.java @@ -95,6 +95,8 @@ static final class Registry { /// a bean an authorization expression names -- or null. Kept here because /// this is what a thread carries of the server it works for. private Map namedBeans; + /// Resources opened on first use by [Tasks#resource], closed at shutdown. + private final Map resources = new LinkedHashMap(); synchronized void namedBeans(Map beans) { this.namedBeans = beans; @@ -178,6 +180,57 @@ private static Registry current() { /// The bean the wiring of the calling thread's server registered under /// `name`, or null. + /// The calling thread's server's configuration, or null. + static Config currentConfig() { + return current().config; + } + + /// The calling thread's server's resource under `key`, opened on first use. + /// Opened outside the registry's lock, since opening one may take a network + /// round trip; two callers racing to open it keep the first and close the + /// other. + static Object resource(String key, com.codename1.impl.backend.BackendAccess.ResourceFactory + factory) throws java.io.IOException { + Registry r = current(); + synchronized (r) { + if (r.shutdown) { + throw new java.io.IOException("The server is stopping"); + } + Object existing = r.resources.get(key); + if (existing != null) { + return existing; + } + } + Object opened = factory.open(r.config == null + ? Config.of(new java.util.Properties(), null) : r.config); + synchronized (r) { + Object existing = r.resources.get(key); + if (existing == null && !r.shutdown) { + r.resources.put(key, opened); + return opened; + } + } + closeQuietly(opened); + Object existing; + synchronized (r) { + existing = r.resources.get(key); + } + if (existing == null) { + throw new java.io.IOException("The server is stopping"); + } + return existing; + } + + private static void closeQuietly(Object resource) { + if (resource instanceof java.io.Closeable) { + try { + ((java.io.Closeable) resource).close(); + } catch (java.io.IOException | RuntimeException err) { + System.err.println("Closing " + resource + " failed: " + err); + } + } + } + static Object namedBean(String name) { return current().namedBean(name); } @@ -398,5 +451,16 @@ static void shutdown(Registry registry, long waitMillis) { long left = deadline - System.currentTimeMillis(); ((TaskExecutor) element).shutdown(left > 0 ? left : 0); } + // After the executors: work still finishing used these. Newest first, + // so a resource built over another (a listener container over its + // connection) goes before what it uses. + List resources; + synchronized (registry) { + resources = new ArrayList(registry.resources.values()); + registry.resources.clear(); + } + for (int i = resources.size() - 1; i >= 0; i--) { + closeQuietly(resources.get(i)); + } } } diff --git a/vm/backend/src/com/codename1/backend/annotations/CacheEvict.java b/vm/backend/src/com/codename1/backend/annotations/CacheEvict.java new file mode 100644 index 00000000000..5c514dc6605 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/CacheEvict.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Removes an entry from the cache when the method returns -- Spring's +/// `@CacheEvict`. +/// +/// Keys and names are as [Cacheable] describes. A method that throws evicts +/// nothing unless [#beforeInvocation()] says to evict first. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface CacheEvict { + /// The cache's name; the first is used when several are given. + String[] value() default {}; + /// The same as [#value], as Spring spells it. + String[] cacheNames() default {}; + /// The key expression; see [Cacheable]. Ignored with [#allEntries()]. + String key() default ""; + /// Removes every entry of the cache rather than one. + boolean allEntries() default false; + /// Evicts before the method runs, so even a call that throws evicts. + boolean beforeInvocation() default false; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/CachePut.java b/vm/backend/src/com/codename1/backend/annotations/CachePut.java new file mode 100644 index 00000000000..cfd5d78bfb9 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/CachePut.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Always runs the method, then keeps what it returned under its key, so a later +/// [Cacheable] call of the same key is answered with it -- Spring's `@CachePut`. +/// +/// Keys, names and lifetimes are as [Cacheable] describes. A null result removes +/// the entry, so the cache never answers with a value the method no longer gives. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface CachePut { + /// The cache's name; the first is used when several are given. + String[] value() default {}; + /// The same as [#value], as Spring spells it. + String[] cacheNames() default {}; + /// The key expression; see [Cacheable]. + String key() default ""; + /// Accepted when it is `#result == null`; see [Cacheable#unless()]. + String unless() default ""; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/Cacheable.java b/vm/backend/src/com/codename1/backend/annotations/Cacheable.java new file mode 100644 index 00000000000..0778cc3d0d6 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/Cacheable.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Answers a call from the cache when the cache holds an answer for its key, and +/// otherwise runs the method and keeps what it returned -- Spring's `@Cacheable`. +/// +/// ```java +/// @Cacheable(value = "products", key = "#id") +/// public Product product(long id) { ... } +/// ``` +/// +/// The build rewrites the method: there is no proxy, so a call from inside the +/// same class is cached too, which Spring's proxy would skip. The key expression +/// is compiled when the application is built, and one the build cannot compile +/// fails the build. It may name a parameter (`#id`, or `#p0`/`#a0` by +/// position), a property of one (`#user.id`, read through its getter or public +/// field), a quoted literal (`'all'`), `#root.methodName`, and join them with +/// `+`. Empty means every parameter, as Spring's `SimpleKeyGenerator` keys. +/// +/// The value is kept in the server's key/value store -- Redis once `cn1.kv.url` +/// names one, so every process shares it -- written as JSON by a codec the build +/// generates for the return type. A null result is not kept, as Spring keeps +/// none with `unless = "#result == null"`. How long an entry lives is +/// `cn1.cache..ttl` in seconds, then `cn1.cache.ttl`, then +/// `spring.cache.redis.time-to-live`; unset, it lives until evicted. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface Cacheable { + /// The cache's name; the first is used when several are given. + String[] value() default {}; + /// The same as [#value], as Spring spells it. + String[] cacheNames() default {}; + /// The key expression; see the class description. + String key() default ""; + /// Accepted when it is `#result == null`, which is what happens anyway; + /// any other expression fails the build. + String unless() default ""; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/EnableCaching.java b/vm/backend/src/com/codename1/backend/annotations/EnableCaching.java new file mode 100644 index 00000000000..fc036a918df --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/EnableCaching.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Accepted for Spring's sake and changes nothing: the build applies every +/// [Cacheable], [CachePut] and [CacheEvict] it finds, so caching needs no switch. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface EnableCaching { +} diff --git a/vm/backend/src/com/codename1/backend/kv/KeyValueStore.java b/vm/backend/src/com/codename1/backend/kv/KeyValueStore.java new file mode 100644 index 00000000000..7e6b6e64f5e --- /dev/null +++ b/vm/backend/src/com/codename1/backend/kv/KeyValueStore.java @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.kv; + +import java.io.Closeable; +import java.io.IOException; + +/// A key/value store that several server instances can share: the place a +/// cache, the sessions, rate-limit counters, scheduler locks and pub/sub +/// notifications live once an application runs more than one process. +/// +/// Two implementations ship, chosen by configuration and never by code: +/// +/// ``` +/// cn1.kv.url=memory: # the default: this process only +/// cn1.kv.url=redis://user:pw@host:6379/0 # Redis, Valkey or KeyDB +/// cn1.kv.url=rediss://host:6380 # the same over TLS +/// ``` +/// +/// Spring Boot's `spring.data.redis.url` (and `.host`, `.port`, `.password`, +/// `.database`, `.ssl.enabled`) is read when `cn1.kv.url` is not set, so a +/// configuration written for a Spring application needs no change. +/// [KeyValueStores#current()] answers the calling thread's server's store. +/// +/// Keys are strings and values are bytes; what a value means is the caller's. +/// Every method may block on the network, and parks only its own virtual +/// thread when it does. +public interface KeyValueStore extends Closeable { + /// The value under `key`, or null when there is none or it expired. + byte[] get(String key) throws IOException; + + /// Stores `value` under `key`, replacing what was there. + /// + /// - `ttlMillis`: how long it lives; zero or less keeps it until it is + /// deleted + void set(String key, byte[] value, long ttlMillis) throws IOException; + + /// Stores `value` only if `key` holds nothing; answers whether it did. + /// This is the primitive a lock is built on. + boolean setIfAbsent(String key, byte[] value, long ttlMillis) throws IOException; + + /// Replaces the value under `key` only while it still equals `expected` + /// (null: while there is none); answers whether it did. A null `value` + /// deletes the key instead. What an optimistic read-modify-write loops on. + boolean compareAndSet(String key, byte[] expected, byte[] value, long ttlMillis) + throws IOException; + + /// Removes `key`; answers whether it existed. + boolean delete(String key) throws IOException; + + /// Removes every key that starts with `prefix`; answers how many. Walks the + /// key space incrementally on a shared server rather than blocking it, so + /// keys written while it runs may survive. + int deleteByPrefix(String prefix) throws IOException; + + /// Adds `delta` to the integer under `key` and answers the result; a missing + /// key counts from zero and is given `ttlMillis` (zero or less: none). The + /// expiry of an existing key is left as it is, so a fixed window counts + /// from its first request. + long increment(String key, long delta, long ttlMillis) throws IOException; + + /// How many milliseconds `key` has left: -1 when it never expires, -2 when + /// it does not exist. + long ttl(String key) throws IOException; + + /// Sends `message` to everybody subscribed to `channel`, on every instance; + /// answers how many subscribers received it (on Redis, how many + /// connections). Delivery is at most once: a subscriber that is not + /// connected at that moment never sees it. + long publish(String channel, byte[] message) throws IOException; + + /// Calls `listener` for every message published to `channel` until the + /// returned subscription is closed. The listener runs on the store's own + /// receiving thread, one message at a time; it should hand slow work off. + Subscription subscribe(String channel, MessageListener listener) throws IOException; + + /// Whether the store answers right now; what a health check asks. + boolean ping(); + + /// Whether this store is shared with other processes. A local store's + /// locks and counters are only good within one process. + boolean isShared(); + + /// Receives published messages. + interface MessageListener { + /// One message, in the order the channel delivered them. + void onMessage(String channel, byte[] message); + } + + /// An active subscription; closing it stops the deliveries. + interface Subscription extends Closeable { + /// Stops the deliveries. Never throws. + @Override + void close(); + } +} diff --git a/vm/backend/src/com/codename1/backend/kv/KeyValueStores.java b/vm/backend/src/com/codename1/backend/kv/KeyValueStores.java new file mode 100644 index 00000000000..9802dcf7402 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/kv/KeyValueStores.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.kv; + +import com.codename1.backend.Config; +import com.codename1.impl.backend.BackendAccess; +import java.io.IOException; + +/// Opens the [KeyValueStore] a configuration names, and finds the one of the +/// calling thread's server. +/// +/// | Setting | Default | | +/// |---|---|---| +/// | `cn1.kv.url` | `memory:` | `memory:`, `redis://...` or `rediss://...` | +/// | `cn1.kv.timeout` | 5000 | milliseconds a connection or a reply may take | +/// | `cn1.kv.pool.max` | 16 | idle connections kept | +/// +/// Without `cn1.kv.url`, Spring Boot's `spring.data.redis.url` is used, and +/// without that `spring.data.redis.host` (with `.port`, `.username`, +/// `.password`, `.database` and `.ssl.enabled`) -- so a Spring configuration +/// carries over unchanged. Nothing set at all is the in-process store. +public final class KeyValueStores { + /// The key a server keeps its store under. + private static final String RESOURCE = "cn1.kv"; + + private KeyValueStores() { + } + + /// The store of the calling thread's server: opened from its configuration + /// the first time it is asked for, and closed when the server stops. + public static KeyValueStore current() throws IOException { + return (KeyValueStore) BackendAccess.get().serverResource(RESOURCE, + new BackendAccess.ResourceFactory() { + @Override + public Object open(Config config) throws IOException { + return KeyValueStores.open(config); + } + }); + } + + /// Whether `config` names a shared store, not the in-process one. + public static boolean isConfigured(Config config) throws IOException { + String url = url(config); + return url != null && !url.startsWith("memory:"); + } + + /// The URL `config` names, or null for none. + static String url(Config config) throws IOException { + if (config == null) { + return null; + } + String url = config.get("cn1.kv.url"); + if (url != null && url.trim().length() > 0) { + return url.trim(); + } + url = config.get("spring.data.redis.url"); + if (url != null && url.trim().length() > 0) { + return url.trim(); + } + String host = config.get("spring.data.redis.host"); + if (host == null || host.trim().length() == 0) { + return null; + } + StringBuilder sb = new StringBuilder( + config.getBoolean("spring.data.redis.ssl.enabled", false) ? "rediss://" : "redis://"); + String password = config.get("spring.data.redis.password"); + String user = config.get("spring.data.redis.username"); + if (password != null && password.length() > 0) { + sb.append(user == null ? "" : escape(user)).append(':').append(escape(password)) + .append('@'); + } + sb.append(host.trim()).append(':').append(config.getInt("spring.data.redis.port", 6379)) + .append('/').append(config.getInt("spring.data.redis.database", 0)); + return sb.toString(); + } + + private static String escape(String s) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '%' || c == '@' || c == ':' || c == '/') { + sb.append('%').append(Character.forDigit((c >> 4) & 15, 16)) + .append(Character.forDigit(c & 15, 16)); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + /// Opens the store `config` names; the caller closes it. + public static KeyValueStore open(Config config) throws IOException { + String url = url(config); + int timeout = config == null ? 5000 : config.getInt("cn1.kv.timeout", 5000); + int pool = config == null ? 16 : config.getInt("cn1.kv.pool.max", 16); + return open(url == null ? "memory:" : url, timeout, pool); + } + + /// Opens the store at `url`; the caller closes it. + public static KeyValueStore open(String url, int timeoutMillis, int maxIdle) + throws IOException { + if (url.startsWith("memory:")) { + return new MemoryKeyValueStore(); + } + if (url.regionMatches(true, 0, "redis://", 0, 8) + || url.regionMatches(true, 0, "rediss://", 0, 9)) { + return new RedisKeyValueStore(url, timeoutMillis, timeoutMillis, maxIdle); + } + throw new IOException("cn1.kv.url \"" + url + "\" names no store this server " + + "knows; use memory:, redis:// or rediss://"); + } +} diff --git a/vm/backend/src/com/codename1/backend/kv/MemoryKeyValueStore.java b/vm/backend/src/com/codename1/backend/kv/MemoryKeyValueStore.java new file mode 100644 index 00000000000..0b4e3d45e30 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/kv/MemoryKeyValueStore.java @@ -0,0 +1,259 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.kv; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/// The process-local [KeyValueStore]: what `cn1.kv.url=memory:` and an +/// unconfigured server get. +/// +/// Everything a shared store does works here too -- expiry, compare-and-set, +/// counters and pub/sub -- within this process, so an application developed +/// against it behaves the same when Redis is configured, minus the sharing. +/// Expired entries are dropped when touched and by a sweep every 1024 writes. +public final class MemoryKeyValueStore implements KeyValueStore { + private static final class Entry { + final byte[] value; + final long expires; + + Entry(byte[] value, long expires) { + this.value = value; + this.expires = expires; + } + } + + private final Map entries = new HashMap(); + private final Map> channels = + new HashMap>(); + private int writes; + private boolean closed; + + private static long expiry(long ttlMillis) { + return ttlMillis > 0 ? System.currentTimeMillis() + ttlMillis : Long.MAX_VALUE; + } + + private Entry live(String key, long now) { + Entry e = entries.get(key); + if (e != null && e.expires <= now) { + entries.remove(key); + return null; + } + return e; + } + + private void wrote() { + writes++; + if (writes % 1024 == 0) { + long now = System.currentTimeMillis(); + Iterator it = entries.values().iterator(); + while (it.hasNext()) { + if (it.next().expires <= now) { + it.remove(); + } + } + } + } + + private void checkOpen() throws IOException { + if (closed) { + throw new IOException("The key/value store is closed"); + } + } + + @Override + public synchronized byte[] get(String key) throws IOException { + checkOpen(); + Entry e = live(key, System.currentTimeMillis()); + return e == null ? null : e.value; + } + + @Override + public synchronized void set(String key, byte[] value, long ttlMillis) throws IOException { + checkOpen(); + entries.put(key, new Entry(value, expiry(ttlMillis))); + wrote(); + } + + @Override + public synchronized boolean setIfAbsent(String key, byte[] value, long ttlMillis) + throws IOException { + checkOpen(); + if (live(key, System.currentTimeMillis()) != null) { + return false; + } + entries.put(key, new Entry(value, expiry(ttlMillis))); + wrote(); + return true; + } + + @Override + public synchronized boolean compareAndSet(String key, byte[] expected, byte[] value, + long ttlMillis) throws IOException { + checkOpen(); + Entry e = live(key, System.currentTimeMillis()); + byte[] current = e == null ? null : e.value; + if (expected == null ? current != null : !Arrays.equals(expected, current)) { + return false; + } + if (value == null) { + entries.remove(key); + } else { + entries.put(key, new Entry(value, expiry(ttlMillis))); + wrote(); + } + return true; + } + + @Override + public synchronized boolean delete(String key) throws IOException { + checkOpen(); + return live(key, System.currentTimeMillis()) != null && entries.remove(key) != null; + } + + @Override + public synchronized int deleteByPrefix(String prefix) throws IOException { + checkOpen(); + int n = 0; + long now = System.currentTimeMillis(); + Iterator> it = entries.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry e = it.next(); + if (e.getKey().startsWith(prefix)) { + if (e.getValue().expires > now) { + n++; + } + it.remove(); + } + } + return n; + } + + @Override + public synchronized long increment(String key, long delta, long ttlMillis) + throws IOException { + checkOpen(); + Entry e = live(key, System.currentTimeMillis()); + long current = 0; + long expires = expiry(ttlMillis); + if (e != null) { + current = parseLong(e.value); + expires = e.expires; + } + long next = current + delta; + entries.put(key, new Entry(com.codename1.impl.backend.WireBuffer.ascii( + Long.toString(next)), expires)); + wrote(); + return next; + } + + static long parseLong(byte[] value) throws IOException { + try { + return Long.parseLong(com.codename1.impl.backend.WireBuffer.fromUtf8(value)); + } catch (NumberFormatException err) { + throw new IOException("The value is not an integer"); // NOPMD PreserveStackTrace - JavaAPI's IOException takes no cause, and the message says it all + } + } + + @Override + public synchronized long ttl(String key) throws IOException { + checkOpen(); + long now = System.currentTimeMillis(); + Entry e = live(key, now); + if (e == null) { + return -2; + } + return e.expires == Long.MAX_VALUE ? -1 : e.expires - now; + } + + @Override + public long publish(String channel, byte[] message) throws IOException { + List targets; + synchronized (this) { + checkOpen(); + List list = channels.get(channel); + if (list == null) { + return 0; + } + targets = new ArrayList(list); + } + // Outside the lock, so a listener may use the store. + for (MessageListener l : targets) { + try { + l.onMessage(channel, message); + } catch (RuntimeException err) { + System.err.println("Subscriber of " + channel + " failed: " + err); + } + } + return targets.size(); + } + + @Override + public synchronized Subscription subscribe(final String channel, + final MessageListener listener) + throws IOException { + checkOpen(); + List list = channels.get(channel); + if (list == null) { + list = new ArrayList(); + channels.put(channel, list); + } + list.add(listener); + return new Subscription() { + @Override + public void close() { + synchronized (MemoryKeyValueStore.this) { + List l = channels.get(channel); + if (l != null) { + l.remove(listener); + if (l.isEmpty()) { + channels.remove(channel); + } + } + } + } + }; + } + + @Override + public synchronized boolean ping() { + return !closed; + } + + @Override + public boolean isShared() { + return false; + } + + @Override + public synchronized void close() { + closed = true; + entries.clear(); + channels.clear(); + } +} diff --git a/vm/backend/src/com/codename1/backend/kv/RedisConnection.java b/vm/backend/src/com/codename1/backend/kv/RedisConnection.java new file mode 100644 index 00000000000..b09e1471688 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/kv/RedisConnection.java @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.kv; + +import com.codename1.backend.Tcp; +import com.codename1.impl.backend.WireBuffer; +import java.io.IOException; + +/// One connection to a Redis server speaking RESP2, the protocol every Redis, +/// Valkey and KeyDB release understands. +/// +/// [RedisKeyValueStore] keeps a pool of these and lends one per call; ask it for +/// a [RedisKeyValueStore#dedicated()] connection for a command that blocks -- +/// `XREADGROUP ... BLOCK`, `BLPOP`, `SUBSCRIBE` -- so it never holds a pooled one. +/// +/// Replies come back as Java values: a simple string as `String`, a bulk string +/// as `byte[]`, an integer as `Long`, an array as `Object[]`, a nil as null. An +/// error reply is thrown as a [RedisException], and leaves the connection usable. +/// Any other failure leaves it in an unknown state; the caller closes it. +/// +/// Not thread safe. +public final class RedisConnection { + private final WireBuffer wire; + private boolean broken; + + private RedisConnection(WireBuffer wire) { + this.wire = wire; + } + + /// Connects and authenticates. + /// + /// - `user`: the ACL user, or null for the default user + /// - `password`: or null when the server asks for none + /// - `database`: the logical database to select, 0 for the default + /// - `readTimeoutMillis`: how long a reply may take; zero waits forever + static RedisConnection open(String host, int port, boolean tls, String user, + String password, int database, int connectTimeoutMillis, + int readTimeoutMillis) throws IOException { + Tcp tcp = Tcp.connect(host, port, connectTimeoutMillis); + RedisConnection c = null; + try { + if (tls) { + tcp.startTls(host); + } + if (readTimeoutMillis > 0) { + tcp.setReadTimeout(readTimeoutMillis); + } + c = new RedisConnection(new WireBuffer(tcp)); + if (password != null) { + if (user != null) { + c.call("AUTH", user, password); + } else { + c.call("AUTH", password); + } + } + if (database != 0) { + c.call("SELECT", Integer.toString(database)); + } + return c; + } catch (IOException err) { + tcp.close(); + throw err; + } + } + + /// Whether a failure other than an error reply happened on this connection, + /// after which it must not be reused. + public boolean isBroken() { + return broken; + } + + /// Changes how long a reply may take; zero waits forever. + public void setReadTimeout(int millis) throws IOException { + wire.getConnection().setReadTimeout(millis); + } + + /// Sends one command and answers its reply. + public Object call(String... args) throws IOException { + byte[][] raw = new byte[args.length][]; + for (int i = 0; i < args.length; i++) { + raw[i] = WireBuffer.utf8(args[i]); + } + return call(raw); + } + + /// Sends one command whose arguments are already bytes and answers its reply. + public Object call(byte[][] args) throws IOException { + write(args); + flush(); + return read(); + } + + /// Stages one command without sending it, for a pipeline: stage several, + /// [#flush] once, then [#read] each reply in order. + public void write(byte[][] args) { + wire.writeByte('*'); + wire.writeDecimal(args.length); + wire.writeByte('\r'); + wire.writeByte('\n'); + for (byte[] a : args) { + wire.writeByte('$'); + wire.writeDecimal(a.length); + wire.writeByte('\r'); + wire.writeByte('\n'); + wire.writeBytes(a); + wire.writeByte('\r'); + wire.writeByte('\n'); + } + } + + /// Sends what [#write] staged. + public void flush() throws IOException { + try { + wire.flush(); + } catch (IOException err) { + broken = true; + throw err; + } + } + + /// Reads the next reply. An error reply is thrown after it has been read + /// whole, so the connection stays in step. + public Object read() throws IOException { + Object reply; + try { + reply = readValue(); + } catch (IOException err) { + broken = true; + throw err; + } catch (RuntimeException err) { + broken = true; + throw new IOException("Malformed reply from Redis: " + err, err); + } + if (reply instanceof RedisException) { + throw (RedisException) reply; + } + return reply; + } + + private Object readValue() throws IOException { + int type = wire.read(); + String line = wire.readLine(); + switch (type) { + case '+': + return line; + case '-': + return new RedisException(line); + case ':': + return Long.valueOf(Long.parseLong(line)); + case '$': { + int length = Integer.parseInt(line); + if (length < 0) { + return null; + } + byte[] data = wire.readFully(length); + wire.skip(2); + return data; + } + case '*': { + int count = Integer.parseInt(line); + if (count < 0) { + return null; + } + Object[] items = new Object[count]; + // An error nested in an array (EXEC) stays in it as an element: + // the caller reads the rest of the array either way. + for (int i = 0; i < count; i++) { + items[i] = readValue(); + } + return items; + } + default: + throw new IOException("Unknown RESP reply type " + type); + } + } + + /// Closes the socket. Never throws. + public void close() { + broken = true; + wire.getConnection().close(); + } +} diff --git a/vm/backend/src/com/codename1/backend/kv/RedisException.java b/vm/backend/src/com/codename1/backend/kv/RedisException.java new file mode 100644 index 00000000000..30fa041fde6 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/kv/RedisException.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.kv; + +import java.io.IOException; + +/// An error reply from a Redis server -- `WRONGTYPE`, `NOSCRIPT`, `NOAUTH` and the +/// like. The connection it arrived on is still usable. +public class RedisException extends IOException { + private static final long serialVersionUID = 1L; + + /// An error with the server's text, whose first word is its code. + public RedisException(String message) { + super(message); + } + + /// The error's code: the first word of the server's text (`WRONGTYPE`, + /// `NOSCRIPT`, `BUSYGROUP`, ...). + public String getCode() { + String m = getMessage(); + if (m == null) { + return ""; + } + int space = m.indexOf(' '); + return space < 0 ? m : m.substring(0, space); + } +} diff --git a/vm/backend/src/com/codename1/backend/kv/RedisKeyValueStore.java b/vm/backend/src/com/codename1/backend/kv/RedisKeyValueStore.java new file mode 100644 index 00000000000..280fe66e36c --- /dev/null +++ b/vm/backend/src/com/codename1/backend/kv/RedisKeyValueStore.java @@ -0,0 +1,643 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.kv; + +import com.codename1.impl.backend.WireBuffer; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// A [KeyValueStore] on a Redis server -- or Valkey, KeyDB, Dragonfly, any +/// server that speaks RESP2. No client library: the protocol is spoken +/// directly over the backend's own sockets, so a binary that never names this +/// class carries none of it. +/// +/// ``` +/// redis://[[user]:password@]host[:port][/database] +/// rediss://... # TLS, verifying the server's certificate +/// ``` +/// +/// ## Connections +/// +/// Calls borrow a connection from a pool, use it and give it back; a pool that +/// has none idle opens another rather than making the caller wait, and keeps +/// at most `cn1.kv.pool.max` (16) idle. A connection that failed with anything +/// but an error reply is closed, never returned, so one dropped socket costs one +/// call. Commands that block go on a [#dedicated()] connection. +/// +/// Subscriptions share ONE further connection and one receiving thread for the +/// whole store. When it drops, it reconnects with a back-off and subscribes +/// again; messages published meanwhile are lost, as pub/sub always loses them. +/// +/// ## Atomic operations +/// +/// [#compareAndSet] and [#increment] with an expiry are Lua scripts, loaded once +/// per server with `SCRIPT LOAD` and called by digest, so a call sends the +/// digest rather than the script. A server that forgot them (a restart, a +/// `SCRIPT FLUSH`) is answered `NOSCRIPT`, and they are loaded again. +public final class RedisKeyValueStore implements KeyValueStore { + private static final String CAS_SCRIPT = + "local v = redis.call('GET', KEYS[1]) " + + "if (ARGV[1] == '0' and v == false) or (ARGV[1] == '1' and v == ARGV[2]) then " + + "if ARGV[3] == '1' then redis.call('DEL', KEYS[1]) " + + "elseif tonumber(ARGV[5]) > 0 then redis.call('SET', KEYS[1], ARGV[4], 'PX', ARGV[5]) " + + "else redis.call('SET', KEYS[1], ARGV[4]) end " + + "return 1 end return 0"; + private static final String INCR_SCRIPT = + "if tonumber(ARGV[2]) > 0 then redis.call('SET', KEYS[1], '0', 'NX', 'PX', ARGV[2]) end " + + "return redis.call('INCRBY', KEYS[1], ARGV[1])"; + private static final byte[] EMPTY = new byte[0]; + + private final String host; + private final int port; + private final boolean tls; + private final String user; + private final String password; + private final int database; + private final int connectTimeout; + private final int readTimeout; + private final int maxIdle; + private final List idle = new ArrayList(); + private final Map scripts = new HashMap(); + private final java.util.concurrent.atomic.AtomicBoolean closed = + new java.util.concurrent.atomic.AtomicBoolean(); + + private final Object subscriberLock = new Object(); + private final Map> listeners = + new HashMap>(); + private Subscriber subscriber; + + /// A store for `url` (see the class description). + /// + /// - `connectTimeoutMillis`: how long a connection may take to open + /// - `readTimeoutMillis`: how long a reply may take; zero waits forever + /// - `maxIdle`: how many idle connections the pool keeps + public RedisKeyValueStore(String url, int connectTimeoutMillis, int readTimeoutMillis, + int maxIdle) throws IOException { + Endpoint e = Endpoint.parse(url); + this.host = e.host; + this.port = e.port; + this.tls = e.tls; + this.user = e.user; + this.password = e.password; + this.database = e.database; + this.connectTimeout = connectTimeoutMillis; + this.readTimeout = readTimeoutMillis; + this.maxIdle = Math.max(0, maxIdle); + } + + /// A parsed `redis://` URL. + static final class Endpoint { + String host; + int port = 6379; + boolean tls; + String user; + String password; + int database; + + static Endpoint parse(String url) throws IOException { + Endpoint e = new Endpoint(); + String rest; + if (url.regionMatches(true, 0, "rediss://", 0, 9)) { + e.tls = true; + rest = url.substring(9); + } else if (url.regionMatches(true, 0, "redis://", 0, 8)) { + rest = url.substring(8); + } else { + throw new IOException("Not a redis:// or rediss:// URL: " + url); + } + int query = rest.indexOf('?'); + if (query >= 0) { + rest = rest.substring(0, query); + } + int at = rest.lastIndexOf('@'); + if (at >= 0) { + String credentials = rest.substring(0, at); + rest = rest.substring(at + 1); + int colon = credentials.indexOf(':'); + if (colon < 0) { + e.password = decode(credentials); + } else { + String u = credentials.substring(0, colon); + e.user = u.length() == 0 ? null : decode(u); + e.password = decode(credentials.substring(colon + 1)); + } + if (e.password != null && e.password.length() == 0) { + e.password = null; + } + } + int slash = rest.indexOf('/'); + if (slash >= 0) { + String db = rest.substring(slash + 1); + rest = rest.substring(0, slash); + if (db.length() > 0) { + try { + e.database = Integer.parseInt(db); + } catch (NumberFormatException err) { + throw new IOException("Redis database must be a number: " + db); // NOPMD PreserveStackTrace - no cause constructor in JavaAPI + } + } + } + int colon = rest.lastIndexOf(':'); + if (colon > 0 && rest.indexOf(']') < colon) { + try { + e.port = Integer.parseInt(rest.substring(colon + 1)); + } catch (NumberFormatException err) { + throw new IOException("Redis port must be a number: " + rest); // NOPMD PreserveStackTrace - no cause constructor in JavaAPI + } + rest = rest.substring(0, colon); + } + if (rest.startsWith("[") && rest.endsWith("]")) { + rest = rest.substring(1, rest.length() - 1); + } + e.host = rest.length() == 0 ? "localhost" : rest; + return e; + } + + /// Percent-decodes a URL user or password, ASCII escapes only. + private static String decode(String s) { + if (s.indexOf('%') < 0) { + return s; + } + StringBuilder sb = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '%' && i + 2 < s.length()) { + int v = Character.digit(s.charAt(i + 1), 16) * 16 + + Character.digit(s.charAt(i + 2), 16); + if (v >= 0) { + sb.append((char) v); + i += 2; + continue; + } + } + sb.append(c); + } + return sb.toString(); + } + } + + // ------------------------------------------------------------ the pool + + private RedisConnection borrow() throws IOException { + synchronized (idle) { + if (closed.get()) { + throw new IOException("The Redis store is closed"); + } + int n = idle.size(); + if (n > 0) { + return idle.remove(n - 1); + } + } + return RedisConnection.open(host, port, tls, user, password, database, connectTimeout, + readTimeout); + } + + private void release(RedisConnection c) { + if (c.isBroken()) { + c.close(); + return; + } + synchronized (idle) { + if (!closed.get() && idle.size() < maxIdle) { + idle.add(c); + return; + } + } + c.close(); + } + + /// A connection of its own, outside the pool, for a command that blocks. + /// Its reply timeout is zero -- it waits as long as the command does. The + /// caller closes it. + public RedisConnection dedicated() throws IOException { + return RedisConnection.open(host, port, tls, user, password, database, connectTimeout, 0); + } + + /// Runs one command on a pooled connection and answers its reply, in the + /// form [RedisConnection] describes -- for the commands this interface does + /// not wrap (`XADD`, `HSET`, ...). + public Object call(String... args) throws IOException { + byte[][] raw = new byte[args.length][]; + for (int i = 0; i < args.length; i++) { + raw[i] = WireBuffer.utf8(args[i]); + } + return call(raw); + } + + /// [#call(String...)] with arguments that are already bytes. + public Object call(byte[][] args) throws IOException { + RedisConnection c = borrow(); + try { + return c.call(args); + } finally { + release(c); + } + } + + /// Sends several commands in one round trip and answers their replies in + /// order; an error reply is in the array as a [RedisException], not thrown. + public Object[] pipeline(byte[][][] commands) throws IOException { + RedisConnection c = borrow(); + try { + for (byte[][] command : commands) { + c.write(command); + } + c.flush(); + Object[] replies = new Object[commands.length]; + for (int i = 0; i < commands.length; i++) { + try { + replies[i] = c.read(); + } catch (RedisException err) { + replies[i] = err; + } + } + return replies; + } finally { + release(c); + } + } + + private static byte[] b(String s) { + return WireBuffer.utf8(s); + } + + private static byte[] n(long v) { + return WireBuffer.ascii(Long.toString(v)); + } + + private Object script(String body, byte[][] keysAndArgs) throws IOException { + String digest; + synchronized (scripts) { + digest = scripts.get(body); + } + for (int attempt = 0; attempt < 2; attempt++) { + if (digest == null) { + Object loaded = call("SCRIPT", "LOAD", body); + digest = loaded instanceof byte[] ? WireBuffer.fromUtf8((byte[]) loaded) + : String.valueOf(loaded); + synchronized (scripts) { + scripts.put(body, digest); + } + } + byte[][] command = new byte[keysAndArgs.length + 2][]; + command[0] = b("EVALSHA"); + command[1] = b(digest); + System.arraycopy(keysAndArgs, 0, command, 2, keysAndArgs.length); + try { + return call(command); + } catch (RedisException err) { + if (!"NOSCRIPT".equals(err.getCode())) { + throw err; + } + digest = null; + } + } + throw new IOException("Redis would not keep a script loaded"); + } + + // ---------------------------------------------------------- the store + + private static long number(Object reply) throws IOException { + if (reply instanceof Long) { + return ((Long) reply).longValue(); + } + throw new IOException("Redis answered " + reply + " where a number was expected"); + } + + @Override + public byte[] get(String key) throws IOException { + Object r = call(new byte[][] {b("GET"), b(key)}); + return r instanceof byte[] ? (byte[]) r : null; + } + + @Override + public void set(String key, byte[] value, long ttlMillis) throws IOException { + if (ttlMillis > 0) { + call(new byte[][] {b("SET"), b(key), value, b("PX"), n(ttlMillis)}); + } else { + call(new byte[][] {b("SET"), b(key), value}); + } + } + + @Override + public boolean setIfAbsent(String key, byte[] value, long ttlMillis) throws IOException { + Object r = ttlMillis > 0 + ? call(new byte[][] {b("SET"), b(key), value, b("NX"), b("PX"), n(ttlMillis)}) + : call(new byte[][] {b("SET"), b(key), value, b("NX")}); + return r != null; + } + + @Override + public boolean compareAndSet(String key, byte[] expected, byte[] value, long ttlMillis) + throws IOException { + byte[][] args = {b("1"), b(key), b(expected == null ? "0" : "1"), + expected == null ? EMPTY : expected, b(value == null ? "1" : "0"), + value == null ? EMPTY : value, n(ttlMillis)}; + Object r = script(CAS_SCRIPT, args); + return number(r) == 1; + } + + @Override + public boolean delete(String key) throws IOException { + return number(call(new byte[][] {b("DEL"), b(key)})) > 0; + } + + @Override + public int deleteByPrefix(String prefix) throws IOException { + String pattern = globEscape(prefix) + "*"; + String cursor = "0"; + int deleted = 0; + do { + Object r = call("SCAN", cursor, "MATCH", pattern, "COUNT", "500"); + if (!(r instanceof Object[]) || ((Object[]) r).length != 2) { + throw new IOException("Unexpected SCAN reply"); + } + Object[] page = (Object[]) r; + cursor = WireBuffer.fromUtf8((byte[]) page[0]); + Object[] keys = (Object[]) page[1]; + if (keys.length > 0) { + byte[][] command = new byte[keys.length + 1][]; + command[0] = b("UNLINK"); + for (int i = 0; i < keys.length; i++) { + command[i + 1] = (byte[]) keys[i]; + } + deleted += (int) number(call(command)); + } + } while (!"0".equals(cursor)); + return deleted; + } + + /// `s` with Redis's glob characters escaped, so a prefix matches only itself. + static String globEscape(String s) { + StringBuilder sb = new StringBuilder(s.length() + 8); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '*' || c == '?' || c == '[' || c == ']' || c == '\\' || c == '^') { + sb.append('\\'); + } + sb.append(c); + } + return sb.toString(); + } + + @Override + public long increment(String key, long delta, long ttlMillis) throws IOException { + if (ttlMillis <= 0) { + return number(call(new byte[][] {b("INCRBY"), b(key), n(delta)})); + } + return number(script(INCR_SCRIPT, new byte[][] {b("1"), b(key), n(delta), + n(ttlMillis)})); + } + + @Override + public long ttl(String key) throws IOException { + return number(call(new byte[][] {b("PTTL"), b(key)})); + } + + @Override + public long publish(String channel, byte[] message) throws IOException { + return number(call(new byte[][] {b("PUBLISH"), b(channel), message})); + } + + @Override + public boolean ping() { + try { + return "PONG".equals(call("PING")); + } catch (IOException err) { + return false; + } + } + + @Override + public boolean isShared() { + return true; + } + + // ------------------------------------------------------ subscriptions + + @Override + public Subscription subscribe(final String channel, final MessageListener listener) + throws IOException { + Subscriber s; + boolean first; + synchronized (subscriberLock) { + if (closed.get()) { + throw new IOException("The Redis store is closed"); + } + List list = listeners.get(channel); + first = list == null; + if (first) { + list = new ArrayList(); + listeners.put(channel, list); + } + list.add(listener); + if (subscriber == null) { + subscriber = new Subscriber(); + subscriber.start(); + } + s = subscriber; + } + if (first) { + s.command("SUBSCRIBE", channel); + } + return new Subscription() { + @Override + public void close() { + Subscriber current; + boolean last = false; + synchronized (subscriberLock) { + List list = listeners.get(channel); + if (list != null && list.remove(listener) && list.isEmpty()) { + listeners.remove(channel); + last = true; + } + current = subscriber; + } + if (last && current != null) { + current.command("UNSUBSCRIBE", channel); + } + } + }; + } + + private void deliver(String channel, byte[] message) { + List targets; + synchronized (subscriberLock) { + List list = listeners.get(channel); + if (list == null) { + return; + } + targets = new ArrayList(list); + } + for (MessageListener l : targets) { + try { + l.onMessage(channel, message); + } catch (RuntimeException err) { + System.err.println("Subscriber of " + channel + " failed: " + err); + } + } + } + + /// The one connection and thread that receive every subscription's messages. + private final class Subscriber implements Runnable { + private RedisConnection connection; + private boolean stopped; + private Thread thread; + + void start() { + thread = new Thread(this, "cn1-redis-subscriber"); + thread.setDaemon(true); + thread.start(); + } + + /// Sends SUBSCRIBE or UNSUBSCRIBE on the live connection; a dropped one + /// re-subscribes everything when it comes back, so a failure here is + /// recovered there. + void command(String verb, String channel) { + synchronized (this) { + if (connection == null) { + return; + } + try { + connection.write(new byte[][] {b(verb), b(channel)}); + connection.flush(); + } catch (IOException err) { + connection.close(); + } + } + } + + void stop() { + synchronized (this) { + stopped = true; + if (connection != null) { + connection.close(); + } + } + } + + @Override + public void run() { + long backoff = 100; + while (true) { + RedisConnection c; + synchronized (this) { + if (stopped) { + return; + } + } + try { + c = dedicated(); + } catch (IOException err) { + if (!sleep(backoff)) { + return; + } + backoff = Math.min(backoff * 2, 5000); + continue; + } + List channels; + synchronized (subscriberLock) { + channels = new ArrayList(listeners.keySet()); + } + synchronized (this) { + if (stopped) { + c.close(); + return; + } + connection = c; + if (!channels.isEmpty()) { + byte[][] command = new byte[channels.size() + 1][]; + command[0] = b("SUBSCRIBE"); + for (int i = 0; i < channels.size(); i++) { + command[i + 1] = b(channels.get(i)); + } + c.write(command); + try { + c.flush(); + } catch (IOException err) { + c.close(); + } + } + } + backoff = 100; + receive(c); + synchronized (this) { + connection = null; + } + } + } + + private void receive(RedisConnection c) { + try { + while (true) { + Object reply = c.read(); + if (!(reply instanceof Object[])) { + continue; + } + Object[] parts = (Object[]) reply; + if (parts.length == 3 && parts[0] instanceof byte[] + && "message".equals(WireBuffer.fromUtf8((byte[]) parts[0])) + && parts[1] instanceof byte[] && parts[2] instanceof byte[]) { + deliver(WireBuffer.fromUtf8((byte[]) parts[1]), (byte[]) parts[2]); + } + } + } catch (IOException err) { + c.close(); + } + } + + private boolean sleep(long millis) { + try { + Thread.sleep(millis); + return true; + } catch (InterruptedException err) { + return false; + } + } + } + + @Override + public void close() { + List drop; + Subscriber s; + synchronized (idle) { + closed.set(true); + drop = new ArrayList(idle); + idle.clear(); + } + synchronized (subscriberLock) { + s = subscriber; + subscriber = null; + listeners.clear(); + } + if (s != null) { + s.stop(); + } + for (RedisConnection c : drop) { + c.close(); + } + } +} diff --git a/vm/backend/src/com/codename1/backend/kv/package-info.java b/vm/backend/src/com/codename1/backend/kv/package-info.java new file mode 100644 index 00000000000..4a46d0a3ebb --- /dev/null +++ b/vm/backend/src/com/codename1/backend/kv/package-info.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +/// A key/value store shared by every process of a deployment -- Redis, Valkey +/// or KeyDB, spoken directly over RESP2 with no client library -- and the +/// in-process store that stands in for one when none is configured. +/// +/// Set `cn1.kv.url` (or Spring Boot's `spring.data.redis.*`) and the server's +/// sessions (`cn1.session.store=kv`), `@Cacheable` entries, scheduler locks and +/// [com.codename1.backend.security.ratelimit.KeyValueRateLimiter] counts all +/// live there, so replicas behind a load balancer agree. [KeyValueStores#current()] +/// is the store of the calling thread's server, and a [KeyValueStore] parameter +/// of a bean is given it. +package com.codename1.backend.kv; diff --git a/vm/backend/src/com/codename1/backend/security/ratelimit/KeyValueRateLimiter.java b/vm/backend/src/com/codename1/backend/security/ratelimit/KeyValueRateLimiter.java new file mode 100644 index 00000000000..d90684c65a0 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/security/ratelimit/KeyValueRateLimiter.java @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.security.ratelimit; + +import com.codename1.backend.kv.KeyValueStore; +import com.codename1.backend.kv.KeyValueStores; +import java.io.IOException; + +/// Counts in the server's key/value store -- Redis once `cn1.kv.url` names one +/// -- so every process of a deployment shares one limit, at one round trip per +/// request and with no table to clean up. +/// +/// ```java +/// http.rateLimit("/login", RateLimitKeys.clientAddress(), +/// new KeyValueRateLimiter("login", 5, 60)); +/// ``` +/// +/// A fixed window, as [JdbcRateLimiter] counts: `permits` requests in each +/// `periodSeconds`, counted from the first request of the window. A request is +/// one atomic increment of a counter that expires with its window, so two +/// processes counting at once cannot both take the last permit. A store that +/// cannot be reached refuses nobody, as the database limiter does not. +public final class KeyValueRateLimiter implements RateLimiter { + private final KeyValueStore fixed; + private final String name; + private final int permits; + private final long periodMillis; + + /// A limiter in the calling thread's server's store. + /// + /// - `name`: what keeps this limiter's counts apart from another's; two + /// limiters of one name share their counts on purpose + public KeyValueRateLimiter(String name, int permits, long periodSeconds) { + this(null, name, permits, periodSeconds); + } + + /// A limiter in `store`; null means the calling thread's server's store. + public KeyValueRateLimiter(KeyValueStore store, String name, int permits, + long periodSeconds) { + if (name == null || name.length() == 0) { + throw new IllegalArgumentException("A name is required"); + } + if (permits < 1 || periodSeconds < 1 || periodSeconds > Long.MAX_VALUE / 1000L) { + throw new IllegalArgumentException("A rate limit needs at least one permit and a " + + "period of at least a second"); + } + this.fixed = store; + this.name = name; + this.permits = permits; + this.periodMillis = periodSeconds * 1000L; + } + + private KeyValueStore store() throws IOException { + return fixed != null ? fixed : KeyValueStores.current(); + } + + private String key(String key) { + // The same bounded form the database limiter keys its rows by, so a + // client-chosen key cannot grow without limit. + return "cn1:rl:" + JdbcRateLimiter.row(name, key); + } + + @Override + public RateLimiter derive(String name, int permits, long periodSeconds) { + return new KeyValueRateLimiter(fixed, name, permits, periodSeconds); + } + + @Override + public boolean tryAcquire(String key) { + if (key == null) { + throw new IllegalArgumentException("key cannot be null"); + } + try { + return store().increment(key(key), 1, periodMillis) <= permits; + } catch (IOException err) { + System.err.println("cn1: the rate limit store could not be reached, so the request " + + "is not counted: " + err.getMessage()); + return true; + } + } + + @Override + public void reset(String key) { + if (key == null) { + return; + } + try { + store().delete(key(key)); + } catch (IOException err) { + System.err.println("cn1: the rate limit store could not be reached, so a count " + + "was not cleared: " + err.getMessage()); + } + } + + @Override + public long retryAfterSeconds(String key) { + if (key == null) { + return 1; + } + try { + long left = store().ttl(key(key)); + long seconds = (left + 999) / 1000; + return seconds < 1 ? 1 : seconds; + } catch (IOException err) { + return 1; + } + } +} diff --git a/vm/backend/src/com/codename1/impl/backend/BackendAccess.java b/vm/backend/src/com/codename1/impl/backend/BackendAccess.java index df26e98c4d4..dd91b3f6c48 100644 --- a/vm/backend/src/com/codename1/impl/backend/BackendAccess.java +++ b/vm/backend/src/com/codename1/impl/backend/BackendAccess.java @@ -271,4 +271,23 @@ public abstract Object begin(int propagation, boolean readOnly, int timeoutSecon /// Every executor of the given servers. public abstract List executorsOf(Collection servers); + + // ------------------------------------------------------- server resources + + /// Opens a resource that belongs to one server -- a key/value store, a + /// messaging connection -- the first time its key is asked for. + public interface ResourceFactory { + /// Opens the resource from the server's configuration. What it answers is + /// closed, when it is [java.io.Closeable], as the server stops. + Object open(Config config) throws IOException; + } + + /// The calling thread's server's resource under `key`, opened by `factory` + /// on first use and closed when that server stops. Never cached in a + /// static: two servers in one process each have their own. + public abstract Object serverResource(String key, ResourceFactory factory) + throws IOException; + + /// The calling thread's server's configuration, or null outside any server. + public abstract Config currentConfig(); } diff --git a/vm/backend/src/com/codename1/impl/backend/CacheRuntime.java b/vm/backend/src/com/codename1/impl/backend/CacheRuntime.java new file mode 100644 index 00000000000..96992a60650 --- /dev/null +++ b/vm/backend/src/com/codename1/impl/backend/CacheRuntime.java @@ -0,0 +1,231 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.backend; + +import com.codename1.backend.ByteSink; +import com.codename1.backend.Config; +import com.codename1.backend.Json; +import com.codename1.backend.kv.KeyValueStore; +import com.codename1.backend.kv.KeyValueStores; +import java.io.IOException; + +/// What the code the build weaves for `@Cacheable`, `@CachePut` and +/// `@CacheEvict` calls. Not application API. +/// +/// Every entry lives in the calling thread's server's [KeyValueStore] under +/// `cn1:cache::`, as the JSON the generated codec wrote. A store +/// that cannot be reached is logged and treated as a miss: the method runs, as +/// it would with no cache, rather than the call failing. +public final class CacheRuntime { + private static final String PREFIX = "cn1:cache:"; + + private CacheRuntime() { + } + + /// The stored JSON of `key` in `cache`, or null for a miss. + public static byte[] get(String cache, String key) { + try { + return KeyValueStores.current().get(entry(cache, key)); + } catch (IOException err) { + warn("read", cache, err); + return null; + } + } + + /// Parses what [#get] answered, for the generated codec to read. + public static Object parse(byte[] stored) throws IOException { + return Json.parse(WireBuffer.fromUtf8(stored)); + } + + /// Keeps `value` under `key`; a null `value` removes the entry. + public static void put(String cache, String key, Json.Writable value) { + try { + KeyValueStore store = KeyValueStores.current(); // NOPMD CloseResource - the server's store, closed when it stops + if (value == null) { + store.delete(entry(cache, key)); + return; + } + ByteSink out = new ByteSink(128); + value.writeTo(out); + byte[] data = new byte[out.length()]; + System.arraycopy(out.bytes(), 0, data, 0, data.length); + store.set(entry(cache, key), data, ttlMillis(cache)); + } catch (IOException err) { + warn("write", cache, err); + } + } + + /// Removes `key` from `cache`. + public static void evict(String cache, String key) { + try { + KeyValueStores.current().delete(entry(cache, key)); + } catch (IOException err) { + warn("evict from", cache, err); + } + } + + /// Removes every entry of `cache`. + public static void evictAll(String cache) { + try { + KeyValueStores.current().deleteByPrefix(PREFIX + cache + ':'); + } catch (IOException err) { + warn("clear", cache, err); + } + } + + /// Spring's default key: nothing for no parameter, the one parameter's text + /// for one, and every parameter's otherwise -- separated so that `("a,b")` + /// and `("a", "b")` stay two keys. + public static String key(Object[] parts) { + if (parts.length == 0) { + return ""; + } + if (parts.length == 1) { + return String.valueOf(parts[0]); + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < parts.length; i++) { + if (i > 0) { + sb.append(','); + } + String s = String.valueOf(parts[i]); + for (int c = 0; c < s.length(); c++) { + char ch = s.charAt(c); + if (ch == ',' || ch == '\\') { + sb.append('\\'); + } + sb.append(ch); + } + } + return sb.toString(); + } + + private static String entry(String cache, String key) { + return PREFIX + cache + ':' + key; + } + + /// How long an entry of `cache` lives, 0 for until evicted. + static long ttlMillis(String cache) throws IOException { + Config config = BackendAccess.get().currentConfig(); + if (config == null) { + return 0; + } + String own = config.get("cn1.cache." + cache + ".ttl"); + if (own == null) { + own = config.get("cn1.cache.ttl"); + } + if (own != null) { + return seconds(own.trim(), "cn1.cache ttl") * 1000L; + } + String spring = config.get("spring.cache.redis.time-to-live"); + return spring == null ? 0 : duration(spring.trim()); + } + + private static long seconds(String value, String what) { + try { + long s = Long.parseLong(value); + return s < 0 ? 0 : s; + } catch (NumberFormatException err) { + throw new IllegalStateException(what + " \"" + value + "\" is not a number of " + + "seconds", err); + } + } + + /// A Spring Boot duration: a bare number of milliseconds, a number with one of + /// `ms s m h d`, or ISO-8601 `PT..H..M..S`. + static long duration(String value) { + String v = value; + if (v.length() > 2 && (v.charAt(0) == 'P' || v.charAt(0) == 'p')) { + return iso(v); + } + int end = 0; + while (end < v.length() && Character.isDigit(v.charAt(end))) { + end++; + } + if (end == 0) { + throw new IllegalStateException("spring.cache.redis.time-to-live \"" + value + + "\" is not a duration"); + } + long n = Long.parseLong(v.substring(0, end)); + String unit = v.substring(end).trim(); + long scale; + if (unit.length() == 0 || "ms".equalsIgnoreCase(unit)) { + scale = 1; + } else if ("s".equalsIgnoreCase(unit)) { + scale = 1000L; + } else if ("m".equalsIgnoreCase(unit)) { + scale = 60000L; + } else if ("h".equalsIgnoreCase(unit)) { + scale = 3600000L; + } else if ("d".equalsIgnoreCase(unit)) { + scale = 86400000L; + } else { + throw new IllegalStateException("spring.cache.redis.time-to-live \"" + value + + "\" has a unit this server does not know; use ms, s, m, h or d"); + } + return n * scale; + } + + private static long iso(String value) { + long total = 0; + long n = 0; + boolean time = false; + boolean digits = false; + for (int i = 1; i < value.length(); i++) { + char c = value.charAt(i); + if (c >= '0' && c <= '9') { + n = n * 10 + (c - '0'); + digits = true; + continue; + } + char u = c >= 'a' && c <= 'z' ? (char) (c - 32) : c; + if (u == 'T') { + time = true; + continue; + } + if (!digits) { + break; + } + if (u == 'D' && !time) { + total += n * 86400000L; + } else if (u == 'H' && time) { + total += n * 3600000L; + } else if (u == 'M' && time) { + total += n * 60000L; + } else if (u == 'S' && time) { + total += n * 1000L; + } else { + throw new IllegalStateException("spring.cache.redis.time-to-live \"" + value + + "\" is not an ISO-8601 duration this server reads"); + } + n = 0; + digits = false; + } + return total; + } + + private static void warn(String what, String cache, IOException err) { + System.err.println("cn1: could not " + what + " cache \"" + cache + "\": " + + err.getMessage()); + } +} diff --git a/vm/backend/src/com/codename1/impl/backend/Management.java b/vm/backend/src/com/codename1/impl/backend/Management.java index acc868ffb8c..0a07dbcf771 100644 --- a/vm/backend/src/com/codename1/impl/backend/Management.java +++ b/vm/backend/src/com/codename1/impl/backend/Management.java @@ -319,6 +319,7 @@ private HttpServer.Response health(HttpServer.Request request) { db.put("idle", Integer.valueOf(backend.getDataSource().getIdleCount())); out.put("database", db); } + keyValue(out); } else { // Not attached yet: the server is still being put together. up = false; @@ -327,6 +328,25 @@ private HttpServer.Response health(HttpServer.Request request) { return request.respondJson(up ? 200 : 503, out); } + /// The shared key/value store's reachability, when `cn1.kv.url` names one. + /// Reported, not judged: caching and rate limits carry on without the store, + /// so taking every replica out of the load balancer because Redis blinked + /// would turn a degraded service into no service. + private void keyValue(Map out) { + Map kv = new LinkedHashMap(); + try { + if (!com.codename1.backend.kv.KeyValueStores.isConfigured(backend.getConfig())) { + return; + } + kv.put("status", com.codename1.backend.kv.KeyValueStores.current().ping() + ? "UP" : "DOWN"); + } catch (IOException err) { + kv.put("status", "DOWN"); + kv.put("error", String.valueOf(err.getMessage())); + } + out.put("keyValue", kv); + } + /// Whether the process should be left running, as a liveness probe asks: /// UP while it starts, serves and drains. A draining server is finishing its /// requests, and a probe that restarted it then would cut them off; readiness diff --git a/vm/backend/src/com/codename1/impl/backend/Scheduler.java b/vm/backend/src/com/codename1/impl/backend/Scheduler.java index 292c9f0c3af..7b2a6e492f2 100644 --- a/vm/backend/src/com/codename1/impl/backend/Scheduler.java +++ b/vm/backend/src/com/codename1/impl/backend/Scheduler.java @@ -56,6 +56,12 @@ /// and is skipped when another instance holds it, so a job runs on one replica /// at a time. The claim expires after `lockAtMostFor` milliseconds, so a /// replica that dies mid-run does not keep it. +/// +/// A server with no database takes the lock in its key/value store instead, +/// when `cn1.kv.url` names a shared one: one `SET NX PX` whose expiry is the +/// same `lockAtMostFor`, released by a compare-and-delete of this run's lease. +/// A server with both keeps the database, so replicas of one deployment never +/// disagree about where its locks are. public final class Scheduler { public static final int CRON = 0; public static final int FIXED_RATE = 1; @@ -63,6 +69,8 @@ public final class Scheduler { private static final String LOCK_TABLE = "cn1_scheduler_lock"; private static final long DEFAULT_LOCK_MILLIS = 10 * 60 * 1000L; + /// The key prefix of a lock claimed in the key/value store. + private static final String LOCK_PREFIX = "cn1:lock:"; /// One registered job and what has happened to it. public static final class Job { @@ -150,13 +158,37 @@ public synchronized void bind(Backend backend) { // to whichever tracer another server in the process installed. Tracer own = backend.getTracer(); this.tracer = own != null ? own : BackendAccess.get().untraced(); + this.config = backend.getConfig(); + } + + /// The server's configuration, for the key/value store locks fall back to. + private com.codename1.backend.Config config; + /// The store locks are claimed in when there is no database; opened on the + /// first claim and closed when the scheduler stops. + private com.codename1.backend.kv.KeyValueStore kvLocks; + + /// Whether locks can be claimed in a shared key/value store. + private boolean hasSharedStore() { + try { + return config != null && com.codename1.backend.kv.KeyValueStores.isConfigured(config); + } catch (IOException err) { + return false; + } + } + + private synchronized com.codename1.backend.kv.KeyValueStore kvLocks() throws IOException { + if (kvLocks == null) { + kvLocks = com.codename1.backend.kv.KeyValueStores.open(config); + } + return kvLocks; } private boolean lockTableReady; /// #### Parameters /// /// - `locks`: @param locks the pool locked jobs claim their row in, or null when this - /// server has no database -- the build refuses a lock without one + /// server has no database; locks are then claimed in the shared key/value store + /// `cn1.kv.url` names, and the build refuses a lock with neither public Scheduler(DataSource locks) { this.locks = locks; String id; @@ -216,9 +248,10 @@ private void add(Job job) { if (started) { throw new IllegalStateException("Jobs are registered before the scheduler starts"); } - if (job.lock != null && locks == null) { + if (job.lock != null && locks == null && !hasSharedStore()) { throw new IllegalStateException("Scheduled job " + job.name + " takes lock \"" - + job.lock + "\", which needs a database, and this server has none"); + + job.lock + "\", which needs a database or a shared key/value store " + + "(cn1.kv.url), and this server has neither"); } for (Object element : jobs) { if (((Job) element).name.equals(job.name)) { @@ -274,6 +307,7 @@ public void stop(long waitMillis) { notifyAll(); } long deadline = AsyncTask.deadline(System.currentTimeMillis(), Math.max(0, waitMillis)); + com.codename1.backend.kv.KeyValueStore store; // NOPMD CloseResource - closed below, outside the lock synchronized (this) { while (anyRunning()) { long left = deadline - System.currentTimeMillis(); @@ -286,6 +320,15 @@ public void stop(long waitMillis) { break; } } + store = kvLocks; + kvLocks = null; + } + if (store != null) { + try { + store.close(); + } catch (IOException err) { + System.err.println("Could not close the scheduler's lock store: " + err); + } } } @@ -457,12 +500,18 @@ private void runJob(final Job job) { /// Claims the job's lock row; the lease that now holds it, or null when another /// run does. String claim(Job job) throws IOException { - prepareLockTable(); String lease; synchronized (this) { claims++; lease = instance + "#" + claims; } + if (locks == null) { + // The store's own clock expires the key, so no replica's clock is + // trusted for the lease, as the database's clock is below. + return kvLocks().setIfAbsent(LOCK_PREFIX + job.lock, WireBuffer.utf8(lease), + job.lockAtMostFor) ? lease : null; + } + prepareLockTable(); // The DATABASE's clock, which every replica shares: with each replica's // own, one running ahead by more than the remaining lease would take a // lock another still legitimately holds, and run the job beside it. @@ -519,6 +568,14 @@ private String liveOrNull(Job job, String lease, long until) throws IOException /// Gives up `lease`'s claim -- and only that one: after an expiry the row /// may belong to a later run, of this job or another sharing its lock name. void release(Job job, String lease) { + if (locks == null) { + try { + kvLocks().compareAndSet(LOCK_PREFIX + job.lock, WireBuffer.utf8(lease), null, 0); + } catch (IOException err) { + System.err.println("Could not release scheduler lock " + job.lock + ": " + err); + } + return; + } try { locks.execute("UPDATE " + LOCK_TABLE + " SET lock_until = ? WHERE name = ? AND " + "locked_by = ?", new Object[] {Long.valueOf(databaseNow()), diff --git a/vm/backend/src/com/codename1/impl/backend/WireBuffer.java b/vm/backend/src/com/codename1/impl/backend/WireBuffer.java new file mode 100644 index 00000000000..952858016d2 --- /dev/null +++ b/vm/backend/src/com/codename1/impl/backend/WireBuffer.java @@ -0,0 +1,349 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.backend; + +import com.codename1.backend.Tcp; +import java.io.IOException; +import java.io.UnsupportedEncodingException; + +/// Buffered framing over a [Tcp] connection, for the binary and line protocols +/// the messaging and key/value clients speak: Redis's RESP, AMQP 0-9-1 and +/// Kafka's. +/// +/// Reads come out of one fixed buffer filled a socket read at a time, so +/// decoding a frame is a memory operation rather than a system call per field. +/// Writes are staged in one growable array and sent with a single write by +/// [#flush], which is what makes a pipelined batch of commands one packet. The +/// staging array is reused: steady traffic allocates nothing here. +/// +/// Not thread safe; a connection belongs to one caller at a time. +public final class WireBuffer { + private final Tcp connection; + private final byte[] in; + private int position; + private int limit; + private byte[] out = new byte[1024]; + private int outLength; + + /// A buffer over `connection` with a 16 KiB read buffer. + public WireBuffer(Tcp connection) { + this(connection, 16384); + } + + /// A buffer over `connection` reading `readBuffer` bytes at a time. + public WireBuffer(Tcp connection, int readBuffer) { + this.connection = connection; + this.in = new byte[readBuffer]; + } + + /// The connection underneath. + public Tcp getConnection() { + return connection; + } + + // ------------------------------------------------------------- reading + + private void fill() throws IOException { + position = 0; + limit = 0; + int n = connection.read(in, 0, in.length); + if (n <= 0) { + throw new IOException("The connection was closed by the server"); + } + limit = n; + } + + /// Whether a read would be answered without touching the socket. + public boolean hasBuffered() { + return position < limit; + } + + /// One byte; an IOException at end of stream. + public int read() throws IOException { + if (position >= limit) { + fill(); + } + return in[position++] & 0xff; + } + + /// Exactly `length` bytes into `target`, or an IOException. + public void readFully(byte[] target, int offset, int length) throws IOException { + while (length > 0) { + if (position >= limit) { + if (length >= in.length) { + // A large body goes straight into its destination. + int n = connection.read(target, offset, length); + if (n <= 0) { + throw new IOException("The connection was closed by the server"); + } + offset += n; + length -= n; + continue; + } + fill(); + } + int chunk = Math.min(length, limit - position); + System.arraycopy(in, position, target, offset, chunk); + position += chunk; + offset += chunk; + length -= chunk; + } + } + + /// Exactly `length` bytes, newly allocated. + public byte[] readFully(int length) throws IOException { + if (length < 0) { + throw new IOException("Negative frame length " + length); + } + byte[] data = new byte[length]; + readFully(data, 0, length); + return data; + } + + /// Discards `length` bytes. + public void skip(long length) throws IOException { + while (length > 0) { + if (position >= limit) { + fill(); + } + int chunk = (int) Math.min(length, limit - position); + position += chunk; + length -= chunk; + } + } + + /// A big-endian 16 bit unsigned value. + public int readShortBE() throws IOException { + return (read() << 8) | read(); + } + + /// A big-endian 32 bit value. + public int readIntBE() throws IOException { + return (read() << 24) | (read() << 16) | (read() << 8) | read(); + } + + /// A big-endian 64 bit value. + public long readLongBE() throws IOException { + return ((long) readIntBE() << 32) | (readIntBE() & 0xffffffffL); + } + + /// The bytes up to a CR LF, without it, decoded as ASCII -- a RESP header. + /// Refuses a line longer than the read buffer: no header is. + public String readLine() throws IOException { + StringBuilder sb = null; + int start = position; + while (true) { + if (position >= limit) { + if (sb == null) { + sb = new StringBuilder(); + } + appendAscii(sb, start, position); + if (sb.length() > in.length) { + throw new IOException("Protocol line longer than " + in.length + " bytes"); + } + fill(); + start = 0; + } + byte b = in[position++]; + if (b == '\n') { + int end = position - 1; + String line; + if (sb == null) { + line = ascii(start, end); + } else { + appendAscii(sb, start, end); + line = sb.toString(); + } + int len = line.length(); + return len > 0 && line.charAt(len - 1) == '\r' ? line.substring(0, len - 1) : line; + } + } + } + + private String ascii(int from, int to) { + char[] chars = new char[to - from]; + for (int i = from; i < to; i++) { + chars[i - from] = (char) (in[i] & 0x7f); + } + return new String(chars); + } + + private void appendAscii(StringBuilder sb, int from, int to) { + for (int i = from; i < to; i++) { + sb.append((char) (in[i] & 0x7f)); + } + } + + // ------------------------------------------------------------- writing + + private void ensure(int more) { + int need = outLength + more; + if (need > out.length) { + byte[] bigger = new byte[Math.max(need, out.length * 2)]; + System.arraycopy(out, 0, bigger, 0, outLength); + out = bigger; + } + } + + /// Stages one byte. + public void writeByte(int value) { + ensure(1); + out[outLength++] = (byte) value; + } + + /// Stages `data`. + public void writeBytes(byte[] data) { + writeBytes(data, 0, data.length); + } + + /// Stages part of `data`. + public void writeBytes(byte[] data, int offset, int length) { + ensure(length); + System.arraycopy(data, offset, out, outLength, length); + outLength += length; + } + + /// Stages a big-endian 16 bit value. + public void writeShortBE(int value) { + ensure(2); + out[outLength++] = (byte) (value >>> 8); + out[outLength++] = (byte) value; + } + + /// Stages a big-endian 32 bit value. + public void writeIntBE(int value) { + ensure(4); + out[outLength++] = (byte) (value >>> 24); + out[outLength++] = (byte) (value >>> 16); + out[outLength++] = (byte) (value >>> 8); + out[outLength++] = (byte) value; + } + + /// Stages a big-endian 64 bit value. + public void writeLongBE(long value) { + writeIntBE((int) (value >>> 32)); + writeIntBE((int) value); + } + + /// Stages the decimal digits of `value`, as RESP writes lengths. + public void writeDecimal(long value) { + if (value == 0) { + writeByte('0'); + return; + } + if (value < 0) { + writeByte('-'); + if (value == Long.MIN_VALUE) { + writeBytes(ascii("9223372036854775808")); + return; + } + value = -value; + } + int digits = 0; + for (long v = value; v > 0; v /= 10) { + digits++; + } + ensure(digits); + for (int i = outLength + digits - 1; i >= outLength; i--) { + out[i] = (byte) ('0' + (value % 10)); + value /= 10; + } + outLength += digits; + } + + /// How many bytes are staged. + public int pending() { + return outLength; + } + + /// Where the next staged byte goes, for a length that is patched in later. + public int mark() { + return outLength; + } + + /// Overwrites four staged bytes at `at` with a big-endian value: a frame + /// length known only once the frame is written. + public void patchIntBE(int at, int value) { + out[at] = (byte) (value >>> 24); + out[at + 1] = (byte) (value >>> 16); + out[at + 2] = (byte) (value >>> 8); + out[at + 3] = (byte) value; + } + + /// Copies out the staged bytes from `from` on, leaving them staged. + public byte[] copyStaged(int from) { + byte[] copy = new byte[outLength - from]; + System.arraycopy(out, from, copy, 0, copy.length); + return copy; + } + + /// Drops everything staged from `at` on. + public void truncate(int at) { + outLength = at; + } + + /// Sends everything staged in one write and clears the stage. + public void flush() throws IOException { + if (outLength > 0) { + int n = outLength; + outLength = 0; + connection.write(out, 0, n); + } + if (out.length > 262144) { + // One huge value should not pin its buffer for the connection's life. + out = new byte[1024]; + } + } + + // ------------------------------------------------------------- text + + /// `value` as UTF-8. + public static byte[] utf8(String value) { + try { + return value.getBytes("UTF-8"); + } catch (UnsupportedEncodingException err) { + throw new IllegalStateException("UTF-8 is missing", err); + } + } + + /// UTF-8 `data` as a String; null for null. + public static String fromUtf8(byte[] data) { + if (data == null) { + return null; + } + try { + return new String(data, 0, data.length, "UTF-8"); + } catch (UnsupportedEncodingException err) { + throw new IllegalStateException("UTF-8 is missing", err); + } + } + + /// An ASCII string's bytes, without consulting a charset. + public static byte[] ascii(String value) { + byte[] b = new byte[value.length()]; + for (int i = 0; i < b.length; i++) { + b[i] = (byte) value.charAt(i); + } + return b; + } +} From 1210140776704719667aacf42db7fc76e8bc3981 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:43:30 +0200 Subject: [PATCH 02/11] Backend: messaging API, generated listeners, database transport, outbox, retry and dead letters MessageTemplate and @MessageListener (with the Spring-named @KafkaListener, @RabbitListener and @JmsListener aliases) over a pluggable MessageTransport. The build discovers listeners, generates each subscription with its typed decode, and registers only the transports application.properties names. Without a broker URL a server with a database uses the db: transport (a queue table claimed with SKIP LOCKED on PostgreSQL), otherwise memory:. A send inside a transaction goes through an outbox relayed after commit. Failed deliveries retry with backoff and then land in .dlq. First cut of the Redis Streams transport, tested in the next change. Co-Authored-By: Claude Opus 5.5 --- .../maven/processors/BackendBeans.java | 242 +++++++++++- .../maven/processors/BackendSources.java | 2 +- .../maven/processors/BackendWiringWriter.java | 94 +++++ .../maven/processors/JsonCallSites.java | 12 + .../RestControllerAnnotationProcessor.java | 15 + .../maven/processors/BackendBeansTest.java | 119 ++++++ .../demo/msgcheck/com/demo/MsgCheck.java | 271 +++++++++++++ .../src/com/codename1/backend/Access.java | 5 + .../src/com/codename1/backend/Tasks.java | 16 + .../backend/annotations/JmsListener.java | 43 +++ .../backend/annotations/KafkaListener.java | 45 +++ .../backend/annotations/MessageListener.java | 67 ++++ .../backend/annotations/RabbitListener.java | 43 +++ .../backend/messaging/DatabaseTransport.java | 287 ++++++++++++++ .../backend/messaging/MemoryTransport.java | 116 ++++++ .../codename1/backend/messaging/Message.java | 152 ++++++++ .../backend/messaging/MessageHandler.java | 31 ++ .../backend/messaging/MessageTemplate.java | 110 ++++++ .../backend/messaging/MessageTransport.java | 70 ++++ .../backend/messaging/Messaging.java | 215 +++++++++++ .../backend/messaging/OutboxTransport.java | 234 ++++++++++++ .../backend/messaging/PollingTransport.java | 258 +++++++++++++ .../backend/messaging/RetryPolicy.java | 81 ++++ .../backend/messaging/Subscription.java | 30 ++ .../backend/messaging/package-info.java | 30 ++ .../messaging/redis/RedisStreamTransport.java | 357 ++++++++++++++++++ .../backend/messaging/redis/package-info.java | 25 ++ .../codename1/impl/backend/BackendAccess.java | 5 + 28 files changed, 2973 insertions(+), 2 deletions(-) create mode 100644 vm/backend/demo/msgcheck/com/demo/MsgCheck.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/JmsListener.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/KafkaListener.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/MessageListener.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/RabbitListener.java create mode 100644 vm/backend/src/com/codename1/backend/messaging/DatabaseTransport.java create mode 100644 vm/backend/src/com/codename1/backend/messaging/MemoryTransport.java create mode 100644 vm/backend/src/com/codename1/backend/messaging/Message.java create mode 100644 vm/backend/src/com/codename1/backend/messaging/MessageHandler.java create mode 100644 vm/backend/src/com/codename1/backend/messaging/MessageTemplate.java create mode 100644 vm/backend/src/com/codename1/backend/messaging/MessageTransport.java create mode 100644 vm/backend/src/com/codename1/backend/messaging/Messaging.java create mode 100644 vm/backend/src/com/codename1/backend/messaging/OutboxTransport.java create mode 100644 vm/backend/src/com/codename1/backend/messaging/PollingTransport.java create mode 100644 vm/backend/src/com/codename1/backend/messaging/RetryPolicy.java create mode 100644 vm/backend/src/com/codename1/backend/messaging/Subscription.java create mode 100644 vm/backend/src/com/codename1/backend/messaging/package-info.java create mode 100644 vm/backend/src/com/codename1/backend/messaging/redis/RedisStreamTransport.java create mode 100644 vm/backend/src/com/codename1/backend/messaging/redis/package-info.java diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java index d83e4e57aaa..114882a1e39 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java @@ -97,6 +97,15 @@ final class BackendBeans { static final String MANAGED_OPERATION = PKG + "ManagedOperation;"; static final String TIMED = PKG + "Timed;"; static final String COUNTED = PKG + "Counted;"; + static final String MESSAGE_LISTENER = PKG + "MessageListener;"; + static final String KAFKA_LISTENER = PKG + "KafkaListener;"; + static final String RABBIT_LISTENER = PKG + "RabbitListener;"; + static final String JMS_LISTENER = PKG + "JmsListener;"; + /// Every listener annotation: ours first, then Spring's names for it. + static final String[] LISTENERS = {MESSAGE_LISTENER, KAFKA_LISTENER, RABBIT_LISTENER, + JMS_LISTENER}; + static final String MESSAGE_TEMPLATE_TYPE = "com/codename1/backend/messaging/MessageTemplate"; + static final String MESSAGE_TYPE = "com/codename1/backend/messaging/Message"; static final String CACHEABLE = PKG + "Cacheable;"; static final String CACHE_PUT = PKG + "CachePut;"; static final String CACHE_EVICT = PKG + "CacheEvict;"; @@ -134,7 +143,8 @@ final class BackendBeans { PROFILE, ON_PROPERTY, ON_EXPRESSION, ON_MISSING, POST_CONSTRUCT, PRE_DESTROY, TRANSACTIONAL, ASYNC, SCHEDULED, MANAGED_RESOURCE, MANAGED_ATTRIBUTE, MANAGED_OPERATION, TIMED, COUNTED, - CACHEABLE, CACHE_PUT, CACHE_EVICT, ENABLE_CACHING, MCP_TOOL, MVC_CONTROLLER, REST_CONTROLLER, WEBSOCKET_MAPPING, PRE_AUTHORIZE, SECURED, + CACHEABLE, CACHE_PUT, CACHE_EVICT, ENABLE_CACHING, MESSAGE_LISTENER, KAFKA_LISTENER, + RABBIT_LISTENER, JMS_LISTENER, MCP_TOOL, MVC_CONTROLLER, REST_CONTROLLER, WEBSOCKET_MAPPING, PRE_AUTHORIZE, SECURED, ROLES_ALLOWED, PERMIT_ALL, DENY_ALL, BackendExceptionHandlers.CONTROLLER_ADVICE, BackendExceptionHandlers.REST_CONTROLLER_ADVICE))); @@ -265,6 +275,28 @@ static final class Job { } } + /// One `@MessageListener` method (or one under a Spring name). + static final class Listener { + final MethodInfo method; + /// Destinations, each possibly a `${setting}`. + final List destinations = new ArrayList(); + String group = ""; + int concurrency = 1; + /// How the payload reaches the method: `message`, `string`, `bytes` + /// or `json`. + String kind; + /// The parameter's Java type, for `json`. + String type; + /// The codec statements filling `target` from the tree `j`, for `json`. + String read; + /// Where it is, for messages. + String where; + + Listener(MethodInfo method) { + this.method = method; + } + } + /// One `@McpTool` method. static final class Tool { final MethodInfo method; @@ -353,6 +385,7 @@ static final class Bean { boolean controller; boolean webSocket; final List jobs = new ArrayList(); + final List listeners = new ArrayList(); /// `@EventListener` methods, each taking the ready event or nothing. final List readyListeners = new ArrayList(); final List tools = new ArrayList(); @@ -482,6 +515,8 @@ static final class Aspect { /// Eager singletons (and the prototypes they need) in construction order. final List order = new ArrayList(); boolean needsDatabase; + /// Whether a bean injects the MessageTemplate. + boolean usesMessageTemplate; boolean needsEntities; boolean needsSession; /// The `@Repository` interfaces injected anywhere, by internal name, each @@ -639,6 +674,7 @@ private void checkTestBeans() { || m.getAnnotation(TIMED) != null || m.getAnnotation(COUNTED) != null || caching(m) != null || m.getAnnotation(SCHEDULED) != null + || listenerAnnotation(m) != null || methodSecurity(m.getAnnotations(), null, null) != null) { ctx.error(cls, "@TestConfiguration " + cls.getSourceName() + "." + m.getName() + " carries an annotation the build weaves; a test " @@ -759,6 +795,26 @@ boolean hasJobs() { return false; } + boolean hasListeners() { + for (Bean b : beans) { + if (!b.listeners.isEmpty()) { + return true; + } + } + return false; + } + + /// Whether anything sends or receives messages, so the server's data source + /// must be given to the messaging transport before any bean is built. + boolean usesMessaging() { + return usesMessageTemplate || hasListeners(); + } + + /// [#messagingTransports] of this module. + List transports() { + return messagingTransports(ctx); + } + boolean hasManaged() { for (Bean b : beans) { if (b.managed != null) { @@ -1730,6 +1786,13 @@ private void collectJobs(Bean bean) { bean.readyListeners.add(m); } } + AnnotationValues l = listenerAnnotation(m); + if (l != null) { + Listener listener = listener(cls, m, l); + if (listener != null) { + bean.listeners.add(listener); + } + } AnnotationValues s = m.getAnnotation(SCHEDULED); if (s == null) { continue; @@ -1815,6 +1878,168 @@ private void collectJobs(Bean bean) { } } + /// The broker transports the module's configuration names, as the factory + /// classes the wiring registers: only those are compiled into the + /// application. A URL the build cannot read -- a `${...}` -- includes + /// every one, since any could be meant. + static List messagingTransports(ProcessorContext ctx) { + Set schemes = new LinkedHashSet(); + for (String url : RestControllerAnnotationProcessor.applicationPropertyValues(ctx, + "cn1.messaging.url")) { + if (url.indexOf("${") >= 0) { + schemes.addAll(TRANSPORTS.keySet()); + continue; + } + int colon = url.indexOf(':'); + String scheme = colon < 0 ? url : url.substring(0, colon); + if (!TRANSPORTS.containsKey(scheme) && scheme.endsWith("s")) { + scheme = scheme.substring(0, scheme.length() - 1); + } + schemes.add(scheme); + } + if (RestControllerAnnotationProcessor.applicationPropertyKnown(ctx, "spring.rabbitmq.host") + || RestControllerAnnotationProcessor.applicationPropertyKnown(ctx, + "spring.rabbitmq.addresses")) { + schemes.add("amqp"); + } + if (RestControllerAnnotationProcessor.applicationPropertyKnown(ctx, + "spring.kafka.bootstrap-servers")) { + schemes.add("kafka"); + } + List out = new ArrayList(); + for (String scheme : schemes) { + String factory = TRANSPORTS.get(scheme); + if (factory != null) { + out.add(scheme + " " + factory); + } + } + return out; + } + + /// The broker transports by scheme. `memory:` and `db:` are part of the + /// messaging package itself. + static final Map TRANSPORTS = new LinkedHashMap(); + + static { + TRANSPORTS.put("redis", + "com.codename1.backend.messaging.redis.RedisStreamTransport.Factory"); + } + + /// The listener annotation on `m`, under any of its names, or null. + static AnnotationValues listenerAnnotation(MethodInfo m) { + for (String d : LISTENERS) { + AnnotationValues a = m.getAnnotation(d); + if (a != null) { + return a; + } + } + return null; + } + + /// The listener `a` declares on `m`, or null after reporting why it cannot be one. + private Listener listener(AnnotatedClass cls, MethodInfo m, AnnotationValues a) { + String d = a.getDescriptor(); + String shown = "@" + d.substring(d.lastIndexOf('/') + 1, d.length() - 1); + String where = cls.getSourceName() + "." + m.getName(); + Listener l = new Listener(m); + l.where = shown + " " + where; + String concurrency; + if (MESSAGE_LISTENER.equals(d)) { + addAll(l.destinations, a.get("value")); + addAll(l.destinations, a.get("destinations")); + l.group = a.getStringOrDefault("group", ""); + Object c = a.get("concurrency"); + concurrency = c == null ? "1" : String.valueOf(c); + } else if (KAFKA_LISTENER.equals(d)) { + addAll(l.destinations, a.get("topics")); + l.group = a.getStringOrDefault("groupId", ""); + concurrency = a.getStringOrDefault("concurrency", ""); + } else if (RABBIT_LISTENER.equals(d)) { + addAll(l.destinations, a.get("queues")); + concurrency = a.getStringOrDefault("concurrency", ""); + } else { + String one = a.getStringOrDefault("destination", ""); + if (one.trim().length() > 0) { + l.destinations.add(one.trim()); + } + concurrency = a.getStringOrDefault("concurrency", ""); + } + if (l.destinations.isEmpty()) { + ctx.error(cls, shown + " on " + where + " names no destination."); + return null; + } + String c = concurrency.trim(); + int dash = c.indexOf('-'); + if (dash >= 0) { + // Spring's "min-max": the consumers here are a fixed number, so the + // most it would ever run. + c = c.substring(dash + 1).trim(); + } + try { + l.concurrency = c.length() == 0 ? 1 : Integer.parseInt(c); + } catch (NumberFormatException err) { + l.concurrency = -1; + } + if (l.concurrency < 1 || l.concurrency > 256) { + ctx.error(cls, shown + " on " + where + " asks for concurrency \"" + concurrency + + "\"; give a number of consumer threads from 1 to 256."); + return null; + } + Type[] args = Type.getArgumentTypes(m.getDescriptor()); + if (m.isStatic() || args.length != 1) { + ctx.error(cls, shown + " method " + where + " must be an instance method taking " + + "one parameter: the payload, or the Message."); + return null; + } + if (wovenAsync(cls, m)) { + // The consumer would acknowledge the message as soon as the stub + // queued the work, so a failure could never redeliver it. + ctx.error(cls, shown + " method " + where + " is also @Async. A listener " + + "already runs on a consumer thread, and its message is acknowledged " + + "when it returns; drop @Async and raise concurrency instead."); + return null; + } + String desc = args[0].getDescriptor(); + if (("L" + MESSAGE_TYPE + ";").equals(desc)) { + l.kind = "message"; + } else if ("Ljava/lang/String;".equals(desc)) { + l.kind = "string"; + } else if ("[B".equals(desc)) { + l.kind = "bytes"; + } else { + String sig = m.getSignature(); + String[] generic = sig == null ? null + : RestClientAnnotationProcessor.parseGenericParameterSignatures(sig, 1); + String type = RestClientAnnotationProcessor.javaTypeFor(args[0], + generic == null || generic.length != 1 ? null : generic[0]); + BackendJsonCodecs codecs = BackendJsonCodecs.of(ctx); + String why = codecs.checkRead(type); + if (why != null) { + ctx.error(cls, shown + " method " + where + " takes " + type + ", which a " + + "message cannot be read into as JSON: it holds " + why + "."); + return null; + } + l.kind = "json"; + l.type = type; + l.read = codecs.readStatements(type, "j", + "com.codename1.impl.backend.JsonCodec.Path.ROOT", "null", "-1", "0", + "target", ""); + } + return l; + } + + private static void addAll(List into, Object values) { + if (values instanceof List) { + for (Object v : (List) values) { + if (v != null && String.valueOf(v).trim().length() > 0) { + into.add(String.valueOf(v).trim()); + } + } + } else if (values instanceof String && ((String) values).trim().length() > 0) { + into.add(((String) values).trim()); + } + } + /// Whether the module's `application.properties` names a shared key/value /// store -- `cn1.kv.url`, or Spring Boot's `spring.data.redis.url` or `.host`. static boolean declaresSharedStore(ProcessorContext ctx) { @@ -2239,6 +2464,11 @@ private void resolve() { + b.scope + "-scoped; an endpoint serves many connections for the " + "life of the server, so it must be a singleton."); } + if (!b.listeners.isEmpty() && !SINGLETON.equals(b.scope)) { + ctx.error(b.cls, b.describe() + " has message listeners but is " + b.scope + + "-scoped; a listener receives messages for the life of the server, " + + "outside any request, so its bean must be a singleton."); + } if (!b.jobs.isEmpty() && (REQUEST.equals(b.scope) || SESSION.equals(b.scope))) { ctx.error(b.cls, b.describe() + " has @Scheduled methods but is " + b.scope + "-scoped; a job runs outside any request."); @@ -2273,6 +2503,7 @@ private void resolve() { boolean async = hasAsync(b); String role = b.webSocket ? "Websocket endpoint" : !b.jobs.isEmpty() ? "Bean with @Scheduled methods" + : !b.listeners.isEmpty() ? "Bean with message listeners" : b.managed != null ? "@ManagedResource bean" : async ? "Bean with @Async methods" : null; if (role == null || b.cls == null) { @@ -2289,6 +2520,7 @@ private void resolve() { } String outside = b.webSocket ? "A websocket callback runs" : !b.jobs.isEmpty() ? "A scheduled job runs" + : !b.listeners.isEmpty() ? "A message listener runs" : b.managed != null ? "A managed attribute is read" : "An @Async call runs on an executor, even one a request made,"; ctx.getLog().warn("cn1: " + role + " " + b.describe() + " reaches " + d.describe() @@ -2372,6 +2604,11 @@ private void resolvePoint(Bean owner, Point p, AnnotatedClass where) { p.builtin = "keyValue"; return; } + if (MESSAGE_TEMPLATE_TYPE.equals(type)) { + p.builtin = "messageTemplate"; + usesMessageTemplate = true; + return; + } if (DATASOURCE_TYPE.equals(type)) { p.builtin = "dataSource"; needsDatabase = true; @@ -3626,6 +3863,9 @@ private void emit() throws ProcessingException { toolCodecs |= t.returnType != null || t.paramTypes.size() > Collections.frequency(t.paramTypes, null); } + for (Listener l : b.listeners) { + toolCodecs |= l.type != null; + } if (b.managed != null) { toolCodecs |= b.managed.operationWrites.size() > Collections.frequency(b.managed.operationWrites, null); diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendSources.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendSources.java index 8e5b5a89294..3b9c30fb876 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendSources.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendSources.java @@ -236,7 +236,7 @@ private void cache(StringBuilder sb, BackendBeans.Aspect aspect, String inner, sb.append(" return result;\n"); } - private static String defaultValue(String type) { + static String defaultValue(String type) { if ("boolean".equals(type)) { return "false"; } diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendWiringWriter.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendWiringWriter.java index 4a579be7d19..88bfccb5972 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendWiringWriter.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendWiringWriter.java @@ -103,6 +103,16 @@ String write(String pkg, String name, List routers, Map webSockets(sb, sockets); started(sb); sb.append(" public void stopping() {\n"); + if (model.hasListeners()) { + // Listeners first: a message being handled may still need the + // beans, and nothing new should start while the server winds down. + sb.append(" java.util.List cn1Subscriptions;\n"); + sb.append(" synchronized (this) {\n cn1Subscriptions = subscriptions;\n") + .append(" subscriptions = new java.util.ArrayList();\n }\n"); + sb.append(" for (int i = cn1Subscriptions.size() - 1; i >= 0; i--) {\n") + .append(" ((com.codename1.backend.messaging.Subscription) " + + "cn1Subscriptions.get(i)).close();\n }\n"); + } sb.append(" if (scheduler != null) {\n scheduler.stop(0);\n }\n"); sb.append(" }\n\n"); stopped(sb); @@ -141,6 +151,9 @@ private void fields(StringBuilder sb) { sb.append(" private com.codename1.backend.orm.EntityManager entities;\n"); sb.append(" private com.codename1.impl.backend.TransactionSession transactionSession;\n"); sb.append(" private com.codename1.impl.backend.Scheduler scheduler;\n"); + if (model.hasListeners()) { + sb.append(" private java.util.List subscriptions = new java.util.ArrayList();\n"); + } for (Map.Entry r : model.repositories.entrySet()) { sb.append(" private ").append(r.getKey().replace('/', '.').replace('$', '.')) .append(' ').append(r.getValue()).append(";\n"); @@ -207,6 +220,20 @@ private void create(StringBuilder sb, List routers) { sb.append(" }\n"); sb.append(" config = environment.getConfig();\n"); sb.append(" dataSource = environment.getDataSource();\n"); + if (model.usesMessaging()) { + // Before any bean is built: one may send from its constructor, and + // the transport it opens queues in -- or keeps its outbox in -- this. + sb.append(" com.codename1.backend.messaging.Messaging.bindDataSource(dataSource);\n"); + // The brokers the configuration names, and no others: a protocol + // nothing registers is a class nothing references, and the native + // build leaves it out. + for (String t : model.transports()) { + int space = t.indexOf(' '); + sb.append(" com.codename1.backend.messaging.Messaging.register(") + .append(BackendSources.quote(t.substring(0, space))).append(", new ") + .append(t.substring(space + 1)).append("());\n"); + } + } sb.append(" entities = environment.getEntityManager();\n"); if (model.needsSession) { sb.append(" transactionSession = new com.codename1.impl.backend." @@ -580,6 +607,9 @@ String point(BackendBeans.Point p) { if ("session".equals(p.builtin)) { return "transactionSession"; } + if ("messageTemplate".equals(p.builtin)) { + return "new com.codename1.backend.messaging.MessageTemplate()"; + } if ("keyValue".equals(p.builtin)) { // The server's own store, opened on first use and closed when // the server stops. @@ -827,6 +857,9 @@ private void started(StringBuilder sb) { } sb.append(" scheduler.start();\n"); } + if (model.hasListeners()) { + listeners(sb); + } // Then the runners, by @Order, and the ready listeners after them, // as Spring Boot runs them once the context is up. List ordered = new ArrayList(model.beans); @@ -873,6 +906,67 @@ private void started(StringBuilder sb) { sb.append(" }\n\n"); } + /// Subscribes every message listener, after the scheduler and before the + /// runners: a runner may send a message a listener is waiting for. + private void listeners(StringBuilder sb) { + sb.append(" final com.codename1.backend.messaging.MessageTransport cn1Transport = " + + "com.codename1.backend.messaging.Messaging.current();\n"); + int index = 0; + for (BackendBeans.Bean b : model.beans) { + if (b.listeners.isEmpty()) { + continue; + } + String var = "consumer" + index++; + sb.append(" final ").append(typeOf(b)).append(' ').append(var).append(" = ") + .append(reference(b)).append(";\n"); + sb.append(" if (").append(var).append(" != null) {\n"); + for (BackendBeans.Listener l : b.listeners) { + for (String destination : l.destinations) { + listener(sb, l, destination, var); + } + } + sb.append(" }\n"); + } + } + + private void listener(StringBuilder sb, BackendBeans.Listener l, String destination, + String target) { + String where = BackendSources.quote(l.where); + sb.append(" {\n com.codename1.backend.messaging.Subscription s = " + + "cn1Transport.subscribe(\n " + + "com.codename1.impl.backend.Wiring.value(config, ") + .append(BackendSources.quote(destination)).append(", ").append(where).append("),\n") + .append(" com.codename1.backend.messaging.Messaging.group(config, " + + "com.codename1.impl.backend.Wiring.value(config, ") + .append(BackendSources.quote(l.group)).append(", ").append(where).append(")), ") + .append(l.concurrency).append(",\n") + .append(" new com.codename1.backend.messaging.MessageHandler() {\n") + .append(" public void handle(com.codename1.backend.messaging.Message " + + "cn1Message) throws Exception {\n"); + String call = target + "." + callName(l.method) + "("; + String in = " "; + if ("message".equals(l.kind)) { + sb.append(in).append(call).append("cn1Message);\n"); + } else if ("string".equals(l.kind)) { + sb.append(in).append(call).append("cn1Message.getText());\n"); + } else if ("bytes".equals(l.kind)) { + sb.append(in).append(call).append("cn1Message.getPayload());\n"); + } else { + String type = BackendJsonCodecs.source(l.type); + sb.append(in).append("Object j = com.codename1.backend.Json.parse(" + + "cn1Message.getText());\n"); + sb.append(in).append(type).append(" target = ") + .append(BackendSources.defaultValue(type)).append(";\n"); + for (String line : l.read.split("\n")) { + sb.append(in).append(line).append('\n'); + } + sb.append(in).append(call).append("target);\n"); + } + sb.append(" }\n });\n"); + sb.append(" synchronized (this) {\n subscriptions.add(s);\n" + + " }\n }\n"); + } + private void job(StringBuilder sb, BackendBeans.Job job, String target) { String where = BackendSources.quote("@Scheduled " + job.name); String executor = BackendSources.quote(job.executor); diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/JsonCallSites.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/JsonCallSites.java index 6bcd03bd960..578f6d2058c 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/JsonCallSites.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/JsonCallSites.java @@ -52,6 +52,7 @@ /// generate the registry, and a class some other use gave a codec works there. final class JsonCallSites { private static final String JSON = "com/codename1/backend/Json"; + private static final String TEMPLATE = "com/codename1/backend/messaging/MessageTemplate"; private JsonCallSites() { } @@ -143,6 +144,17 @@ public void visitFieldInsn(int opcode, String owner, String name, String desc) { @Override public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) { + if (opcode == Opcodes.INVOKEVIRTUAL && TEMPLATE.equals(owner) && "send".equals(name) + && ("(Ljava/lang/String;Ljava/lang/Object;)V".equals(desc) + || "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;)V".equals(desc))) { + // A payload is written by Json.write inside the template, so the + // type sent needs a codec exactly as a Json.write of it does. + if (lastLocal >= 0) { + writtenLocals.add(Integer.valueOf(lastLocal)); + } else if (last != null) { + use(last, false, "write"); + } + } if (opcode == Opcodes.INVOKESTATIC && JSON.equals(owner)) { if (("read".equals(name) || "readList".equals(name)) && "(Ljava/lang/String;Ljava/lang/Class;)".equals(params(desc)) diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java index c8ab660fb47..2b44672b107 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java @@ -3971,6 +3971,21 @@ static String applicationProperty(ProcessorContext ctx, String key) { return null; } + /// Every value `key` has across the module's `application.properties` and + /// its `application-.properties` files: one profile may name a + /// different broker than another, and the build must include each. + static List applicationPropertyValues(ProcessorContext ctx, String key) { + List out = new ArrayList(); + for (File f : applicationPropertyFiles(ctx)) { + java.util.Properties props = loadProperties(ctx, f); + String v = props == null ? null : props.getProperty(key); + if (v != null && !out.contains(v.trim())) { + out.add(v.trim()); + } + } + return out; + } + /// The module's base `application.properties`, loaded, or null when it has /// none or it can't be read. static java.util.Properties baseApplicationProperties(ProcessorContext ctx) { diff --git a/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java index 8c24f65fad7..79e34c4ca01 100644 --- a/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java @@ -44,6 +44,7 @@ import java.net.HttpURLConnection; import java.net.ServerSocket; import java.net.URL; +import java.nio.file.Files; import java.net.URLClassLoader; import java.util.ArrayList; import java.util.Arrays; @@ -1600,6 +1601,116 @@ public void cachingAnnotationsAnswerFromTheStoreAndEvict() throws Exception { } } + /// Listeners under all four annotation names, a retried message, a dead + /// letter and a send that rolls back, over `memory:` and `db:` -- the + /// database a SQLite file, or `CN1_TEST_DB_URL` (PostgreSQL, MySQL) when set. + @Test + public void messageListenersReceiveRetryAndDeadLetter() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Order", PKG + "public class Order {\n" + + " public long id;\n" + + " public String name;\n" + + "}\n"); + s.put("com.example.Inbox", PKG + "@Component public class Inbox {\n" + + " public final Map got = new ConcurrentHashMap();\n" + + " public final AtomicInteger flaky = new AtomicInteger();\n" + + " @MessageListener(value = \"orders\", concurrency = 2)\n" + + " public void order(Order o) { got.put(\"order\", o.id + o.name); }\n" + + " @KafkaListener(topics = \"raw\", groupId = \"g\")\n" + + " public void raw(String text) { got.put(\"raw\", text); }\n" + + " @RabbitListener(queues = \"${q.name}\", concurrency = \"1-3\")\n" + + " public void flaky(com.codename1.backend.messaging.Message m) {\n" + + " if (flaky.incrementAndGet() < 3) throw new IllegalStateException(\"again\");\n" + + " got.put(\"flaky\", String.valueOf(m.getAttempt()));\n" + + " }\n" + + " @JmsListener(destination = \"poison\")\n" + + " public void poison(byte[] b) { throw new IllegalArgumentException(\"boom\"); }\n" + + " @MessageListener(\"poison.dlq\")\n" + + " public void dead(com.codename1.backend.messaging.Message m) {\n" + + " got.put(\"dead\", m.getHeader(\"cn1-origin\") + \":\"\n" + + " + m.getHeader(\"cn1-error\").substring(m.getHeader(\"cn1-error\").indexOf(':') + 2));\n" + + " }\n" + + " @MessageListener(\"tx\")\n" + + " public void tx(String text) { String was = got.get(\"tx\"); got.put(\"tx\", was == null ? text : was + text); }\n" + + "}\n"); + s.put("com.example.Sender", PKG + "@Component public class Sender {\n" + + " private final com.codename1.backend.messaging.MessageTemplate t;\n" + + " public Sender(com.codename1.backend.messaging.MessageTemplate t) { this.t = t; }\n" + + " @Transactional public void ok() throws Exception { t.send(\"tx\", \"c1\"); }\n" + + " @Transactional public void bad() throws Exception {\n" + + " t.send(\"tx\", \"r1\");\n" + + " throw new IllegalStateException(\"no\");\n" + + " }\n" + + "}\n"); + s.put("com.example.Api", PKG + + "@RestController public class Api {\n" + + " private final Inbox in; private final Sender sender;\n" + + " private final com.codename1.backend.messaging.MessageTemplate t;\n" + + " public Api(Inbox in, Sender sender, com.codename1.backend.messaging.MessageTemplate t) {\n" + + " this.in = in; this.sender = sender; this.t = t;\n" + + " }\n" + + " private Order order() { Order o = new Order(); o.id = 7; o.name = \"x\"; return o; }\n" + + " @GetMapping(\"/run\") public String run() throws Exception {\n" + + " t.send(\"orders\", order());\n" + + " t.send(\"raw\", \"hello\");\n" + + " t.send(\"flaky\", \"f\");\n" + + " t.send(\"poison\", new byte[] {1});\n" + + " sender.ok();\n" + + " try { sender.bad(); } catch (IllegalStateException expected) { }\n" + + " long end = System.currentTimeMillis() + 20000;\n" + + " while (in.got.size() < 5 && System.currentTimeMillis() < end) Thread.sleep(20);\n" + + " Thread.sleep(300);\n" + + " return in.got.get(\"order\") + \" \" + in.got.get(\"raw\") + \" \"\n" + + " + in.got.get(\"flaky\") + \" \" + in.got.get(\"dead\") + \" \" + in.got.get(\"tx\")\n" + + " + \" \" + t.getClass().getSimpleName();\n" + + " }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + List runs = new ArrayList(); + File dir = Files.createTempDirectory("cn1-msg").toFile(); + runs.add(new String[] {"memory:", new File(dir, "a.db").getAbsolutePath()}); + runs.add(new String[] {"db:", new File(dir, "b.db").getAbsolutePath()}); + String external = System.getenv("CN1_TEST_DB_URL"); + if (external != null && external.length() > 0) { + runs.add(new String[] {"db:", external}); + } + // The ORM's entity registries are process wide, and a server creates every + // registered table: entities an earlier test in this JVM registered + // (SQLite collations among them) would be created here too. + clearRegistry(com.codename1.impl.orm.Models.class); + clearRegistry(com.codename1.backend.orm.EntityManager.class); + for (String[] run : runs) { + int port = freePort(); + URLClassLoader loader = new URLClassLoader(new URL[] {classes.toURI().toURL()}, + getClass().getClassLoader()); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + settings.setProperty("cn1.messaging.url", run[0]); + settings.setProperty(Config.DATASOURCE_URL, run[1]); + settings.setProperty("q.name", "flaky"); + // The generated entry point installs the codecs; this test starts the + // wiring directly, so it does what that line does. + com.codename1.impl.backend.JsonCodec.install((com.codename1.impl.backend.JsonCodec.Registry) + loader.loadClass("cn1app.JsonCodecs").newInstance()); + settings.setProperty("cn1.messaging.retry.backoff", "50"); + settings.setProperty("cn1.messaging.db.poll", "50"); + Backend backend = withApplication(Backend.builder(Config.of(settings, "dev")).quiet(), + (BackendApplication) loader.loadClass("com.example.BackendWiring") + .newInstance()).start(); + try { + if (run[1].indexOf("://") > 0) { + backend.getDataSource().execute("DELETE FROM cn1_messages", new Object[0]); + } + assertEquals(run[0] + " " + run[1], "7x hello 3 poison:boom c1 MessageTemplate", + http("GET", port, "/run")); + } finally { + backend.stop(); + com.codename1.impl.backend.JsonCodec.install(null); + } + } + } + /// Replicas over one Redis, with no database anywhere: a job whose lock /// another replica holds does not run, and a session one server stored is read /// by the next. @@ -3849,6 +3960,14 @@ private static List backendClasspath() throws Exception { return Arrays.asList(new File(url.toURI())); } + private static void clearRegistry(Class owner) throws Exception { + java.lang.reflect.Field registry = owner.getDeclaredField("REGISTRY"); + registry.setAccessible(true); + synchronized (owner) { + ((Map) registry.get(null)).clear(); + } + } + private static int freePort() throws Exception { ServerSocket s = new ServerSocket(0); try { diff --git a/vm/backend/demo/msgcheck/com/demo/MsgCheck.java b/vm/backend/demo/msgcheck/com/demo/MsgCheck.java new file mode 100644 index 00000000000..4f53160aab2 --- /dev/null +++ b/vm/backend/demo/msgcheck/com/demo/MsgCheck.java @@ -0,0 +1,271 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.demo; + +import com.codename1.backend.Config; +import com.codename1.backend.DataSource; +import com.codename1.backend.messaging.Message; +import com.codename1.backend.messaging.MessageHandler; +import com.codename1.backend.messaging.MessageTransport; +import com.codename1.backend.messaging.Messaging; +import com.codename1.backend.messaging.Subscription; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +/// Exercises the messaging transports: the in-process queue, the database +/// queue over SQLite, and -- when `CN1_MSGCHECK_DB` names one -- PostgreSQL or +/// MySQL, then whatever broker `CN1_MSGCHECK_URL` names. The same checks run on +/// every transport, on the JVM and on the native build alike. +public class MsgCheck { + private static int passed; + private static final List failures = new ArrayList(); + + public static void main(String[] args) throws Exception { + run("memory:", null); + String file = "msgcheck-" + Long.toString(System.nanoTime(), 36) + ".db"; + run("db:", file); + String db = System.getenv("CN1_MSGCHECK_DB"); + if (db != null && db.length() > 0) { + run("db:", db); + } + String url = args.length > 0 ? args[0] : System.getenv("CN1_MSGCHECK_URL"); + if (url != null && url.length() > 0) { + run(url, null); + } + new java.io.File(file).delete(); + System.out.println("passed=" + passed + " failed=" + failures.size()); + for (String f : failures) { + System.out.println("FAIL " + f); + } + System.out.println(failures.isEmpty() ? "MSGCHECK OK" : "MSGCHECK FAILED"); + System.exit(failures.isEmpty() ? 0 : 1); + } + + private static void run(String url, String database) throws Exception { + Properties p = new Properties(); + p.setProperty("cn1.messaging.url", url); + p.setProperty("cn1.messaging.retry.backoff", "20"); + p.setProperty("cn1.messaging.db.poll", "20"); + DataSource ds = database == null ? null : DataSource.open(database, 8); + String w = url + (database == null ? "" : " " + (database.indexOf("://") > 0 + ? database.substring(0, database.indexOf(':')) : "sqlite")); + MessageTransport t = Messaging.open(Config.of(p, "dev"), ds); + String q = "mc" + Long.toString(System.nanoTime(), 36); + try { + if (ds != null && database.indexOf("://") > 0) { + ds.execute("DELETE FROM cn1_messages", new Object[0]); + } + } catch (java.io.IOException noTableYet) { + // created by the first send + } + try { + order(w, t, q + "o"); + headers(w, t, q + "h"); + binary(w, t, q + "b"); + competing(w, t, q + "c"); + retry(w, t, q + "r"); + deadLetter(w, t, q + "d"); + closed(w, t, q + "x"); + } finally { + t.close(); + if (ds != null) { + ds.close(); + } + } + } + + private static void order(String w, MessageTransport t, String q) throws Exception { + for (int i = 0; i < 50; i++) { + t.send(new Message(q, ("m" + i).getBytes("UTF-8"))); + } + final List got = Collections.synchronizedList(new ArrayList()); + Subscription s = t.subscribe(q, "g", 1, new MessageHandler() { + public void handle(Message m) { + got.add(m.getText()); + } + }); + waitFor(got, 50); + s.close(); + List expected = new ArrayList(); + for (int i = 0; i < 50; i++) { + expected.add("m" + i); + } + check(w + " order", expected, new ArrayList(got)); + } + + private static void headers(String w, MessageTransport t, String q) throws Exception { + Map h = new LinkedHashMap(); + h.put("trace", "abc"); + h.put("content-type", "text/plain"); + t.send(new Message(q, "k1", "body".getBytes("UTF-8"), h)); + final List got = Collections.synchronizedList(new ArrayList()); + Subscription s = t.subscribe(q, "g", 1, new MessageHandler() { + public void handle(Message m) { + got.add(m); + } + }); + waitFor(got, 1); + s.close(); + Message m = got.isEmpty() ? null : got.get(0); + check(w + " header", "abc", m == null ? null : m.getHeader("trace")); + check(w + " content type", "text/plain", m == null ? null : m.getHeader("content-type")); + check(w + " key", "k1", m == null ? null : m.getKey()); + check(w + " text", "body", m == null ? null : m.getText()); + check(w + " first attempt", Integer.valueOf(1), m == null ? null + : Integer.valueOf(m.getAttempt())); + check(w + " destination", q, m == null ? null : m.getDestination()); + } + + private static void binary(String w, MessageTransport t, String q) throws Exception { + byte[] all = new byte[256]; + for (int i = 0; i < 256; i++) { + all[i] = (byte) i; + } + t.send(new Message(q, all)); + t.send(new Message(q, new byte[0])); + final List got = Collections.synchronizedList(new ArrayList()); + Subscription s = t.subscribe(q, "g", 1, new MessageHandler() { + public void handle(Message m) { + got.add(m.getPayload()); + } + }); + waitFor(got, 2); + s.close(); + check(w + " binary", Boolean.TRUE, Boolean.valueOf(got.size() == 2 + && java.util.Arrays.equals(all, got.get(0)))); + check(w + " empty", Integer.valueOf(0), got.size() < 2 ? null + : Integer.valueOf(got.get(1).length)); + } + + private static void competing(String w, MessageTransport t, String q) throws Exception { + final int n = 200; + for (int i = 0; i < n; i++) { + t.send(new Message(q, Integer.toString(i).getBytes("UTF-8"))); + } + final List got = Collections.synchronizedList(new ArrayList()); + final Set threads = Collections.synchronizedSet(new HashSet()); + Subscription s = t.subscribe(q, "g", 4, new MessageHandler() { + public void handle(Message m) throws Exception { + got.add(m.getText()); + threads.add(Thread.currentThread().getName()); + Thread.sleep(1); + } + }); + waitFor(got, n); + Thread.sleep(200); + s.close(); + check(w + " competing count", Integer.valueOf(n), Integer.valueOf(got.size())); + check(w + " competing distinct", Integer.valueOf(n), + Integer.valueOf(new HashSet(got).size())); + check(w + " competing threads", Boolean.TRUE, Boolean.valueOf(threads.size() > 1)); + } + + private static void retry(String w, MessageTransport t, String q) throws Exception { + t.send(new Message(q, "r".getBytes("UTF-8"))); + final List attempts = Collections.synchronizedList(new ArrayList()); + Subscription s = t.subscribe(q, "g", 1, new MessageHandler() { + public void handle(Message m) { + attempts.add(Integer.valueOf(m.getAttempt())); + if (m.getAttempt() < 3) { + throw new IllegalStateException("not yet"); + } + } + }); + waitFor(attempts, 3); + Thread.sleep(300); + s.close(); + List expected = new ArrayList(); + expected.add(Integer.valueOf(1)); + expected.add(Integer.valueOf(2)); + expected.add(Integer.valueOf(3)); + check(w + " retry attempts", expected, new ArrayList(attempts)); + } + + private static void deadLetter(String w, MessageTransport t, String q) throws Exception { + t.send(new Message(q, "poison".getBytes("UTF-8"))); + final AtomicInteger tries = new AtomicInteger(); + Subscription s = t.subscribe(q, "g", 1, new MessageHandler() { + public void handle(Message m) { + tries.incrementAndGet(); + throw new IllegalArgumentException("bad payload"); + } + }); + final List dead = Collections.synchronizedList(new ArrayList()); + Subscription d = t.subscribe(q + ".dlq", "g", 1, new MessageHandler() { + public void handle(Message m) { + dead.add(m); + } + }); + waitFor(dead, 1); + Thread.sleep(300); + s.close(); + d.close(); + Message m = dead.isEmpty() ? null : dead.get(0); + check(w + " dlq tries", Integer.valueOf(3), Integer.valueOf(tries.get())); + check(w + " dlq count", Integer.valueOf(1), Integer.valueOf(dead.size())); + check(w + " dlq origin", q, m == null ? null : m.getHeader(Message.ORIGIN)); + check(w + " dlq error", "java.lang.IllegalArgumentException: bad payload", + m == null ? null : m.getHeader(Message.ERROR)); + check(w + " dlq payload", "poison", m == null ? null : m.getText()); + } + + private static void closed(String w, MessageTransport t, String q) throws Exception { + final List got = Collections.synchronizedList(new ArrayList()); + MessageHandler h = new MessageHandler() { + public void handle(Message m) { + got.add(m.getText()); + } + }; + Subscription s = t.subscribe(q, "g", 2, h); + s.close(); + t.send(new Message(q, "later".getBytes("UTF-8"))); + Thread.sleep(300); + check(w + " closed takes nothing", Integer.valueOf(0), Integer.valueOf(got.size())); + Subscription again = t.subscribe(q, "g", 1, h); + waitFor(got, 1); + again.close(); + check(w + " kept for the next", "later", got.isEmpty() ? null : got.get(0)); + } + + private static void waitFor(List list, int size) throws InterruptedException { + long end = System.currentTimeMillis() + 20000; + while (list.size() < size && System.currentTimeMillis() < end) { + Thread.sleep(10); + } + } + + private static void check(String what, Object expected, Object actual) { + if (expected == null ? actual == null : expected.equals(actual)) { + passed++; + } else { + failures.add(what + ": expected " + expected + " but was " + actual); + } + } +} diff --git a/vm/backend/src/com/codename1/backend/Access.java b/vm/backend/src/com/codename1/backend/Access.java index e4664e716d9..1194e16bb25 100644 --- a/vm/backend/src/com/codename1/backend/Access.java +++ b/vm/backend/src/com/codename1/backend/Access.java @@ -366,4 +366,9 @@ public Object serverResource(String key, BackendAccess.ResourceFactory factory) public Config currentConfig() { return Tasks.currentConfig(); } + + @Override + public Runnable inServer(Runnable work) { + return Tasks.inServer(work); + } } diff --git a/vm/backend/src/com/codename1/backend/Tasks.java b/vm/backend/src/com/codename1/backend/Tasks.java index 960a16e14dd..b3d2bf2287a 100644 --- a/vm/backend/src/com/codename1/backend/Tasks.java +++ b/vm/backend/src/com/codename1/backend/Tasks.java @@ -153,6 +153,22 @@ static Object enter(Registry registry) { return previous; } + /// `work`, run under the calling thread's registry whatever thread runs it. + static Runnable inServer(final Runnable work) { + final Registry registry = current(); + return new Runnable() { + @Override + public void run() { + Object previous = enter(registry); + try { + work.run(); + } finally { + leave(previous); + } + } + }; + } + /// The calling thread's registry as it is, for restoring later; may be null. static Object peek() { return CURRENT.get(); diff --git a/vm/backend/src/com/codename1/backend/annotations/JmsListener.java b/vm/backend/src/com/codename1/backend/annotations/JmsListener.java new file mode 100644 index 00000000000..89c21ddbcfb --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/JmsListener.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Spring's `@JmsListener`, so a Spring listener ports by changing its import: +/// the same as [MessageListener], over whichever transport is configured. +/// `destination` is the queue; `concurrency` is a number of consumer threads, +/// or `min-max`, of which the maximum is used. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface JmsListener { + /// The queue. + String destination() default ""; + /// Consumer threads, as `n` or `min-max`. + String concurrency() default ""; + /// Accepted and ignored: a listener's identity is its method. + String id() default ""; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/KafkaListener.java b/vm/backend/src/com/codename1/backend/annotations/KafkaListener.java new file mode 100644 index 00000000000..86c05993646 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/KafkaListener.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Spring Kafka's `@KafkaListener`, so a Spring listener ports by changing its +/// import: the same as [MessageListener], over whichever transport is +/// configured. `topics` are the destinations and `groupId` the group; +/// `concurrency` is a number of consumer threads. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface KafkaListener { + /// The topics. + String[] topics() default {}; + /// The consumer group. + String groupId() default ""; + /// Consumer threads, as a number. + String concurrency() default ""; + /// Accepted and ignored: a listener's identity is its method. + String id() default ""; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/MessageListener.java b/vm/backend/src/com/codename1/backend/annotations/MessageListener.java new file mode 100644 index 00000000000..7048e3115cc --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/MessageListener.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Receives the messages of a destination: the method runs once per message, +/// on a consumer thread of the server, and the message is acknowledged when it +/// returns. +/// +/// ```java +/// @Component +/// public class Shipping { +/// @MessageListener(value = "orders.placed", group = "shipping", concurrency = 4) +/// public void placed(Order order) { ... } +/// } +/// ``` +/// +/// The method takes one parameter: the payload, read as JSON by a codec the +/// build generates for its type -- or a `String`, a `byte[]`, or the whole +/// [com.codename1.backend.messaging.Message]. A method that throws has the +/// message delivered again after a backoff, and after +/// `cn1.messaging.retry.max-attempts` deliveries it goes to +/// `.dlq`. Delivery is at least once, so a listener should be +/// safe to run twice for one message. +/// +/// The build finds every listener and writes the code that subscribes it when +/// the server starts and stops it when the server stops; there is no +/// reflection. A destination may name a setting, `${orders.topic}`, which is +/// read from the configuration when the server starts. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface MessageListener { + /// The destinations: queues, topics or streams. At least one. + String[] value() default {}; + /// The same as [#value]. + String[] destinations() default {}; + /// The consumer group; subscriptions of one group share the messages, and + /// each group gets every one. Empty means `spring.application.name`, or + /// `default` without one. + String group() default ""; + /// Consumer threads. + int concurrency() default 1; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/RabbitListener.java b/vm/backend/src/com/codename1/backend/annotations/RabbitListener.java new file mode 100644 index 00000000000..0ca42ce7450 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/RabbitListener.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Spring AMQP's `@RabbitListener`, so a Spring listener ports by changing its +/// import: the same as [MessageListener], over whichever transport is +/// configured. `queues` are the destinations; `concurrency` is a number of +/// consumer threads, or `min-max`, of which the maximum is used. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface RabbitListener { + /// The queues. + String[] queues() default {}; + /// Consumer threads, as `n` or `min-max`. + String concurrency() default ""; + /// Accepted and ignored: a listener's identity is its method. + String id() default ""; +} diff --git a/vm/backend/src/com/codename1/backend/messaging/DatabaseTransport.java b/vm/backend/src/com/codename1/backend/messaging/DatabaseTransport.java new file mode 100644 index 00000000000..ae7e09d387c --- /dev/null +++ b/vm/backend/src/com/codename1/backend/messaging/DatabaseTransport.java @@ -0,0 +1,287 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.messaging; + +import com.codename1.backend.Base64; +import com.codename1.backend.DataSource; +import com.codename1.backend.Json; +import com.codename1.backend.sql.Dialect; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; + +/// Queues in the application's own database: `cn1.messaging.url=db:`, and +/// the default whenever the server has a data source. Needs nothing but the +/// database the application already has, and a message sent inside a +/// transaction is part of it -- it is seen only if the transaction commits, and +/// never seen if it rolls back. +/// +/// Every message is a row of `cn1_messages`, created on first use. A consumer +/// claims a row by moving its `available_at` past a lease +/// (`cn1.messaging.lease`, 60 seconds), handles it, and deletes it; a +/// consumer that dies mid-message leaves the row to come back when the lease +/// runs out. The claim is a compare-and-set on that column, so it is safe on +/// every engine; PostgreSQL claims a batch in one statement with +/// `FOR UPDATE SKIP LOCKED`. +/// +/// Consumers poll every `cn1.messaging.db.poll` milliseconds (500) while their +/// destination is empty, and at once after a send from the same process. +/// Times are the processes' own clocks, so replicas should run NTP. +final class DatabaseTransport extends PollingTransport { + /// The table, unquoted so every engine folds it the same way. + static final String TABLE = "cn1_messages"; + + private final DataSource db; + private final long lease; + private final long pollInterval; + private final Object wake = new Object(); + private final AtomicLong claims = new AtomicLong(); + /// Sends from this process, so a waiting consumer knows one came. + private final AtomicLong sends = new AtomicLong(); + private final String node; + private boolean created; + + DatabaseTransport(DataSource db, RetryPolicy retry, long leaseMillis, long pollMillis) { + super(retry); + this.db = db; + this.lease = Math.max(1000, leaseMillis); + this.pollInterval = Math.max(10, pollMillis); + this.node = Long.toString(System.currentTimeMillis(), 36) + "-" + + Integer.toString(System.identityHashCode(this), 36); + } + + @Override + public String getName() { + return "db"; + } + + @Override + public boolean isTransactional() { + return true; + } + + private void ensureTable() throws IOException { + synchronized (this) { + if (created) { + return; + } + } + Dialect d = db.dialect(); + db.execute("CREATE TABLE IF NOT EXISTS " + TABLE + " (id " + + d.generatedKeyColumn(Dialect.BIGINT) + + ", destination VARCHAR(255) NOT NULL, msg_key VARCHAR(255), headers " + + d.columnType(Dialect.TEXT) + ", payload " + d.columnType(Dialect.TEXT) + + ", attempt " + d.columnType(Dialect.INTEGER) + " NOT NULL" + + ", available_at " + d.columnType(Dialect.BIGINT) + " NOT NULL" + + ", claim VARCHAR(64))", new Object[0]); + try { + db.execute("CREATE INDEX cn1_messages_ready ON " + TABLE + + " (destination, available_at)", new Object[0]); + } catch (IOException exists) { // NOPMD EmptyCatchBlock - the index is there already + } + synchronized (this) { + created = true; + } + } + + @Override + public void send(Message message) throws IOException { + ensureTable(); + Map headers = new LinkedHashMap( + message.headersForTransport()); + int attempt = message.getAttempt(); + headers.remove(Message.ATTEMPT); + long availableAt = System.currentTimeMillis(); + String nb = headers.remove(Message.NOT_BEFORE); + if (nb != null) { + try { + availableAt = Math.max(availableAt, Long.parseLong(nb)); + } catch (NumberFormatException ignored) { // NOPMD EmptyCatchBlock - sent now + } + } + long id = db.insert("INSERT INTO " + TABLE + " (destination, msg_key, headers, payload," + + " attempt, available_at) VALUES (?, ?, ?, ?, ?, ?)", + new Object[]{message.getDestination(), message.getKey(), + headers.isEmpty() ? null : Json.write(headers), + Base64.encode(message.getPayload()), Integer.valueOf(attempt), + Long.valueOf(availableAt)}, "id"); + message.setId(Long.toString(id)); + sends.incrementAndGet(); + synchronized (wake) { + wake.notifyAll(); + } + } + + @Override + protected List poll(String destination, String group, int max, long waitMillis) + throws IOException { + ensureTable(); + long deadline = System.currentTimeMillis() + waitMillis; + while (true) { + long seen = sends.get(); + List got = claim(destination, max); + long now = System.currentTimeMillis(); + if (!got.isEmpty() || now >= deadline) { + return got; + } + synchronized (wake) { + try { + if (sends.get() == seen) { + wake.wait(Math.max(1, Math.min(pollInterval, deadline - now))); + } + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + return got; + } + } + } + } + + private List claim(String destination, int max) throws IOException { + long now = System.currentTimeMillis(); + String token = node + "-" + claims.incrementAndGet(); + Long until = Long.valueOf(now + lease); + List rows; + if (Dialect.POSTGRES.getName().equals(db.dialect().getName())) { + rows = db.query("UPDATE " + TABLE + " SET available_at = ?, claim = ? WHERE id IN" + + " (SELECT id FROM " + TABLE + " WHERE destination = ? AND available_at <= ?" + + " ORDER BY id LIMIT " + max + " FOR UPDATE SKIP LOCKED)" + + " RETURNING id, msg_key, headers, payload, attempt", + new Object[]{until, token, destination, Long.valueOf(now)}); + } else { + List candidates = db.query("SELECT id FROM " + TABLE + + " WHERE destination = ? AND available_at <= ? ORDER BY id " + + db.dialect().limit(max * 4, 0), + new Object[]{destination, Long.valueOf(now)}); + List mine = new ArrayList(); + for (Iterator it = candidates.iterator(); it.hasNext() && mine.size() < max;) { + Object id = ((Map) it.next()).get("id"); + if (db.execute("UPDATE " + TABLE + " SET available_at = ?, claim = ?" + + " WHERE id = ? AND available_at <= ?", + new Object[]{until, token, id, Long.valueOf(now)}) == 1) { + mine.add(id); + } + } + rows = new ArrayList(); + for (Object id : mine) { + Map row = db.queryOne("SELECT id, msg_key, headers, payload, attempt FROM " + + TABLE + " WHERE id = ? AND claim = ?", new Object[]{id, token}); + if (row != null) { + rows.add(row); + } + } + } + List out = new ArrayList(rows.size()); + for (Object o : rows) { + Map row = (Map) o; + out.add(delivery(destination, row, token)); + } + return out; + } + + private static Delivery delivery(String destination, Map row, String token) + throws IOException { + Map headers = new LinkedHashMap(); + Object h = row.get("headers"); + if (h instanceof String) { + Map parsed = Json.parseObject((String) h); + for (Object e : parsed.entrySet()) { + Map.Entry entry = (Map.Entry) e; + if (entry.getValue() != null) { + headers.put(String.valueOf(entry.getKey()), String.valueOf(entry.getValue())); + } + } + } + Object attempt = row.get("attempt"); + headers.put(Message.ATTEMPT, attempt == null ? "1" : String.valueOf( + attempt instanceof Number ? ((Number) attempt).intValue() : 1)); + Object payload = row.get("payload"); + Object key = row.get("msg_key"); + Message m = new Message(destination, key instanceof String ? (String) key : null, + payload instanceof String ? Base64.decode((String) payload) : new byte[0], + headers); + Object id = row.get("id"); + m.setId(String.valueOf(id)); + return new Delivery(m, new Object[]{id, token}); + } + + @Override + protected void ack(String destination, String group, Delivery delivery) throws IOException { + Object[] h = (Object[]) delivery.handle; + db.execute("DELETE FROM " + TABLE + " WHERE id = ? AND claim = ?", new Object[]{h[0], h[1]}); + } + + @Override + protected void redeliver(String destination, String group, Delivery delivery, int attempt, + long notBefore) throws IOException { + Object[] h = (Object[]) delivery.handle; + db.execute("UPDATE " + TABLE + " SET attempt = ?, available_at = ?, claim = NULL" + + " WHERE id = ? AND claim = ?", + new Object[]{Integer.valueOf(attempt), Long.valueOf(notBefore), h[0], h[1]}); + } + + @Override + protected void deadLetter(String destination, String group, Delivery delivery, String error) + throws IOException { + Object[] h = (Object[]) delivery.handle; + Map headers = new LinkedHashMap( + delivery.message.headersForTransport()); + headers.remove(Message.ATTEMPT); + headers.put(Message.ERROR, error); + headers.put(Message.ORIGIN, destination); + // One statement moves the row, so a crash cannot leave the message both + // in its queue and dead-lettered. + db.execute("UPDATE " + TABLE + " SET destination = ?, headers = ?, attempt = 1," + + " available_at = ?, claim = NULL WHERE id = ? AND claim = ?", + new Object[]{RetryPolicy.deadLetter(destination), Json.write(headers), + Long.valueOf(System.currentTimeMillis()), h[0], h[1]}); + } + + /// Messages waiting in `destination`, ready or not. + int depth(String destination) throws IOException { + ensureTable(); + Map row = db.queryOne("SELECT COUNT(*) AS n FROM " + TABLE + " WHERE destination = ?", + new Object[]{destination}); + Object n = row == null ? null : row.get("n"); + return n instanceof Number ? ((Number) n).intValue() : 0; + } + + @Override + public boolean isHealthy() { + try { + ensureTable(); + return true; + } catch (IOException err) { + return false; + } + } + + @Override + public void close() { + stopSubscriptions(); + } +} diff --git a/vm/backend/src/com/codename1/backend/messaging/MemoryTransport.java b/vm/backend/src/com/codename1/backend/messaging/MemoryTransport.java new file mode 100644 index 00000000000..2e9c503b45d --- /dev/null +++ b/vm/backend/src/com/codename1/backend/messaging/MemoryTransport.java @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.messaging; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +/// Queues inside this process: `cn1.messaging.url=memory:`. What development +/// and tests run on when there is no database to queue in. Nothing survives a +/// restart, and another process sees nothing. +final class MemoryTransport extends PollingTransport { + private final Map> queues = + new HashMap>(); + private long ids; + + MemoryTransport(RetryPolicy retry) { + super(retry); + } + + @Override + public String getName() { + return "memory"; + } + + @Override + public void send(Message message) { + synchronized (queues) { + LinkedList q = queues.get(message.getDestination()); + if (q == null) { + q = new LinkedList(); + queues.put(message.getDestination(), q); + } + message.setId(Long.toString(++ids)); + q.addLast(message); + queues.notifyAll(); + } + } + + @Override + protected List poll(String destination, String group, int max, long waitMillis) + throws IOException { + long deadline = System.currentTimeMillis() + waitMillis; + List out = new ArrayList(); + synchronized (queues) { + while (true) { + long now = System.currentTimeMillis(); + long soonest = deadline; + LinkedList q = queues.get(destination); + if (q != null) { + java.util.Iterator it = q.iterator(); + while (it.hasNext() && out.size() < max) { + Message m = it.next(); + if (notYet(m, now)) { + soonest = Math.min(soonest, + Long.parseLong(m.getHeader(Message.NOT_BEFORE))); + continue; + } + it.remove(); + out.add(new Delivery(m, null)); + } + } + if (!out.isEmpty() || now >= deadline) { + return out; + } + try { + queues.wait(Math.max(1, soonest - now)); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + return out; + } + } + } + } + + @Override + protected void ack(String destination, String group, Delivery delivery) { + // Taken off the queue when polled. + } + + /// Messages waiting in `destination`, for tests and the dev tools. + int depth(String destination) { + synchronized (queues) { + LinkedList q = queues.get(destination); + return q == null ? 0 : q.size(); + } + } + + @Override + public void close() { + stopSubscriptions(); + } +} diff --git a/vm/backend/src/com/codename1/backend/messaging/Message.java b/vm/backend/src/com/codename1/backend/messaging/Message.java new file mode 100644 index 00000000000..401634fcf45 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/messaging/Message.java @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.messaging; + +import com.codename1.impl.backend.WireBuffer; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/// One message: a destination, an optional key, headers, and the payload as +/// bytes. What a listener is handed when it asks for more than the payload, and +/// what [MessageTemplate#send(Message)] sends as it is. +/// +/// The key is what a partitioned transport (Kafka) orders by; the others carry it +/// as a header. Header names starting with `cn1-` are the runtime's own: the +/// delivery attempt, the error a dead-lettered message failed with, and the +/// destination it came from. +public final class Message { + /// The delivery attempt, counted from 1. + public static final String ATTEMPT = "cn1-attempt"; + /// The text of the failure that dead-lettered a message. + public static final String ERROR = "cn1-error"; + /// The destination a dead-lettered message was sent to first. + public static final String ORIGIN = "cn1-origin"; + /// The time, in epoch milliseconds, before which a retried message is not + /// handled again. + public static final String NOT_BEFORE = "cn1-not-before"; + /// The content type of the payload: `application/json`, `text/plain` or + /// `application/octet-stream`. + public static final String CONTENT_TYPE = "content-type"; + + private final String destination; + private final String key; + private final byte[] payload; + private final Map headers; + private String id; + + /// A message to `destination` with no key. + public Message(String destination, byte[] payload) { + this(destination, null, payload, null); + } + + /// A message to `destination`; `key` and `headers` may be null. + public Message(String destination, String key, byte[] payload, Map headers) { + if (destination == null || destination.length() == 0) { + throw new IllegalArgumentException("A message needs a destination"); + } + this.destination = destination; + this.key = key; + this.payload = payload == null ? new byte[0] : payload; + this.headers = headers == null ? new LinkedHashMap() + : new LinkedHashMap(headers); + } + + /// Where it is sent: a queue, a topic or a stream, whichever the transport has. + public String getDestination() { + return destination; + } + + /// The ordering key, or null. + public String getKey() { + return key; + } + + /// The payload. Not copied; do not change it. + public byte[] getPayload() { + return payload; + } + + /// The payload as UTF-8 text. + public String getText() { + return WireBuffer.fromUtf8(payload); + } + + /// The headers, which a listener may read but not change. + public Map getHeaders() { + return Collections.unmodifiableMap(headers); + } + + /// One header, or null. + public String getHeader(String name) { + return headers.get(name); + } + + /// Which delivery of this message this is, counted from 1. + public int getAttempt() { + String a = headers.get(ATTEMPT); + if (a == null) { + return 1; + } + try { + return Math.max(1, Integer.parseInt(a)); + } catch (NumberFormatException err) { + return 1; + } + } + + /// The transport's identifier of this delivery, or null before it is sent. + public String getId() { + return id; + } + + void setId(String id) { + this.id = id; + } + + /// A copy with `name` set to `value`, or removed when `value` is null. + public Message withHeader(String name, String value) { + Map h = new LinkedHashMap(headers); + if (value == null) { + h.remove(name); + } else { + h.put(name, value); + } + return new Message(destination, key, payload, h); + } + + /// A copy sent to `to` instead. + public Message to(String to) { + return new Message(to, key, payload, headers); + } + + Map headersForTransport() { + return headers; + } + + @Override + public String toString() { + return "Message[" + destination + (key == null ? "" : " key=" + key) + + " " + payload.length + " bytes]"; + } +} diff --git a/vm/backend/src/com/codename1/backend/messaging/MessageHandler.java b/vm/backend/src/com/codename1/backend/messaging/MessageHandler.java new file mode 100644 index 00000000000..609e01bb27d --- /dev/null +++ b/vm/backend/src/com/codename1/backend/messaging/MessageHandler.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.messaging; + +/// What a subscription calls for each message. Returning acknowledges it; +/// throwing has it delivered again later, until the retries run out and it goes +/// to the dead-letter destination. +public interface MessageHandler { + /// Handles one message. + void handle(Message message) throws Exception; +} diff --git a/vm/backend/src/com/codename1/backend/messaging/MessageTemplate.java b/vm/backend/src/com/codename1/backend/messaging/MessageTemplate.java new file mode 100644 index 00000000000..25a27735d84 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/messaging/MessageTemplate.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.messaging; + +import com.codename1.backend.Json; +import com.codename1.backend.Transactions; +import com.codename1.impl.backend.WireBuffer; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; + +/// Sends messages through the server's transport: what an application injects +/// to publish, as it would Spring's `KafkaTemplate` or `RabbitTemplate`. +/// +/// ```java +/// @Service +/// public class Orders { +/// private final MessageTemplate messages; +/// public Orders(MessageTemplate messages) { this.messages = messages; } +/// +/// @Transactional +/// public void place(Order order) { +/// repository.save(order); +/// messages.send("orders.placed", order); +/// } +/// } +/// ``` +/// +/// A payload is sent as it is when it is a `byte[]`, as UTF-8 when it is a +/// `String`, and otherwise as JSON written by the codec the build generated +/// for its type. +/// +/// Inside a database transaction a send happens only if the transaction +/// commits. The `db:` transport writes the message in the transaction itself; +/// a broker transport writes it to an outbox table in the transaction and +/// relays it after the commit, so a rollback sends nothing and a crash between +/// the commit and the relay sends it when the server comes back. `memory:` +/// holds the message until the commit. +public final class MessageTemplate { + /// Sends `payload` to `destination`. + public void send(String destination, Object payload) throws IOException { + send(destination, null, payload); + } + + /// Sends `payload` to `destination` with an ordering `key`, which may be null. + public void send(String destination, String key, Object payload) throws IOException { + Map headers = new LinkedHashMap(); + byte[] body = encode(payload, headers); + send(new Message(destination, key, body, headers)); + } + + /// Sends `message` as it is. + public void send(final Message message) throws IOException { + final MessageTransport t = Messaging.current(); // NOPMD CloseResource - the server's transport, closed when it stops + if (t.isTransactional() || !Transactions.isActive()) { + t.send(message); + return; + } + if (t instanceof OutboxTransport) { + ((OutboxTransport) t).stage(message); + return; + } + Transactions.registerSynchronization(new Transactions.Synchronization() { + @Override + public void afterCommit() { + try { + t.send(message); + } catch (IOException err) { + System.err.println("cn1: a message to " + message.getDestination() + + " could not be sent after its transaction committed: " + + err.getMessage()); + } + } + }); + } + + /// The bytes `payload` is sent as, recording its content type in `headers`. + static byte[] encode(Object payload, Map headers) { + if (payload instanceof byte[]) { + headers.put(Message.CONTENT_TYPE, "application/octet-stream"); + return (byte[]) payload; + } + if (payload instanceof String) { + headers.put(Message.CONTENT_TYPE, "text/plain"); + return WireBuffer.utf8((String) payload); + } + headers.put(Message.CONTENT_TYPE, "application/json"); + return WireBuffer.utf8(Json.write(payload)); + } +} diff --git a/vm/backend/src/com/codename1/backend/messaging/MessageTransport.java b/vm/backend/src/com/codename1/backend/messaging/MessageTransport.java new file mode 100644 index 00000000000..8a886c9d4be --- /dev/null +++ b/vm/backend/src/com/codename1/backend/messaging/MessageTransport.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.messaging; + +import java.io.Closeable; +import java.io.IOException; + +/// A broker connection: what sends messages and runs subscriptions. One per +/// server, opened by [Messaging#current()] from the configuration; application +/// code sends through [MessageTemplate] and receives through `@MessageListener` +/// methods rather than calling this directly. +/// +/// Delivery is at least once. A handler that returns acknowledges its message; +/// one that throws has it delivered again after a backoff, and after +/// `cn1.messaging.retry.max-attempts` deliveries it goes to the destination's +/// dead-letter destination, `.dlq`, with the failure in its +/// [Message#ERROR] header. +public abstract class MessageTransport implements Closeable { + /// The scheme this transport was opened for: `memory`, `db`, `redis`, + /// `amqp`, `kafka`. + public abstract String getName(); + + /// Sends `message`. With a database transport inside a transaction the + /// message is written in that transaction and is seen only if it commits. + public abstract void send(Message message) throws IOException; + + /// Starts `concurrency` consumers of `destination` in `group`. + /// + /// - `group`: subscriptions of one group share the messages between them, + /// and each group sees every message -- on a transport with groups (Redis + /// Streams, Kafka, AMQP through one queue per group). On `memory:` and + /// `db:` a destination is a queue and every subscription competes for it. + public abstract Subscription subscribe(String destination, String group, int concurrency, + MessageHandler handler) throws IOException; + + /// Whether a send is part of the calling thread's database transaction by + /// itself, so it needs no outbox. + public boolean isTransactional() { + return false; + } + + /// Whether the broker answers, for the health endpoint. Never throws. + public boolean isHealthy() { + return true; + } + + /// Stops every subscription and closes the connection. Never throws. + @Override + public abstract void close(); +} diff --git a/vm/backend/src/com/codename1/backend/messaging/Messaging.java b/vm/backend/src/com/codename1/backend/messaging/Messaging.java new file mode 100644 index 00000000000..a2f96958f0b --- /dev/null +++ b/vm/backend/src/com/codename1/backend/messaging/Messaging.java @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.messaging; + +import com.codename1.backend.Config; +import com.codename1.backend.DataSource; +import com.codename1.impl.backend.BackendAccess; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +/// Opens the [MessageTransport] a configuration names, and finds the one of +/// the calling thread's server. +/// +/// | Setting | Default | | +/// |---|---|---| +/// | `cn1.messaging.url` | see below | `memory:`, `db:`, `redis://...`, `amqp://...`, `kafka://...` | +/// | `cn1.messaging.lease` | 60000 | milliseconds before a message a consumer took and never settled is delivered again | +/// | `cn1.messaging.timeout` | 5000 | milliseconds a broker connection or reply may take | +/// | `cn1.messaging.db.poll` | 500 | milliseconds between polls of an empty `db:` queue | +/// +/// The retry settings are [RetryPolicy]'s. Without `cn1.messaging.url`, +/// Spring Boot's `spring.rabbitmq.host` (or `.addresses`) selects AMQP and +/// `spring.kafka.bootstrap-servers` Kafka; otherwise a server with a data +/// source queues in its database and one without queues in memory. +/// +/// A broker transport is compiled into an application only when its +/// configuration names it: the build registers that one transport's factory +/// with [#register], so the protocols an application does not use add nothing +/// to its size. +public final class Messaging { + private static final String RESOURCE = "cn1.messaging"; + private static final String DATA_SOURCE = "cn1.messaging.ds"; + private static final Map FACTORIES = + new HashMap(); + + private Messaging() { + } + + /// Opens a broker transport for a URL of its scheme. + public interface TransportFactory { + /// Opens the transport `url` names; the caller closes it. + MessageTransport open(String url, Config config, RetryPolicy retry) throws IOException; + } + + /// Makes `scheme` (`redis`, `amqp`, `kafka`, ...) open through `factory`. + /// Called by the generated wiring, never by application code. + public static void register(String scheme, TransportFactory factory) { + synchronized (FACTORIES) { + FACTORIES.put(scheme, factory); + } + } + + private static final class DataSourceHolder { + DataSource dataSource; + } + + private static DataSourceHolder holder() throws IOException { + return (DataSourceHolder) BackendAccess.get().serverResource(DATA_SOURCE, + new BackendAccess.ResourceFactory() { + @Override + public Object open(Config config) { + return new DataSourceHolder(); + } + }); + } + + /// Gives the calling thread's server's transport its data source: the + /// `db:` queue and the outbox live there. Called by the generated wiring + /// before any bean can send. + public static void bindDataSource(DataSource dataSource) throws IOException { + DataSourceHolder h = holder(); + synchronized (h) { + h.dataSource = dataSource; + } + } + + /// The data source of the calling thread's server, or null. + static DataSource dataSource() throws IOException { + DataSourceHolder h = holder(); + synchronized (h) { + return h.dataSource; + } + } + + /// The transport of the calling thread's server: opened from its + /// configuration the first time it is asked for, closed when the server + /// stops. + public static MessageTransport current() throws IOException { + final DataSource ds = dataSource(); + return (MessageTransport) BackendAccess.get().serverResource(RESOURCE, + new BackendAccess.ResourceFactory() { + @Override + public Object open(Config config) throws IOException { + return Messaging.open(config, ds); + } + }); + } + + /// The consumer group a listener declared, or -- when it declared none -- + /// the application's name (`spring.application.name`, then + /// `cn1.application.name`), or `default`. + public static String group(Config config, String declared) throws IOException { + if (declared != null && declared.trim().length() > 0) { + return declared.trim(); + } + if (config != null) { + String name = config.get("spring.application.name"); + if (name == null || name.trim().length() == 0) { + name = config.get("cn1.application.name"); + } + if (name != null && name.trim().length() > 0) { + return name.trim(); + } + } + return "default"; + } + + /// The URL `config` names, or null when it names none. + public static String url(Config config) throws IOException { + if (config == null) { + return null; + } + String url = config.get("cn1.messaging.url"); + if (url != null && url.trim().length() > 0) { + return url.trim(); + } + String addresses = config.get("spring.rabbitmq.addresses"); + if (addresses != null && addresses.trim().length() > 0) { + String a = addresses.trim(); + return a.indexOf("://") >= 0 ? a : "amqp://" + a; + } + String host = config.get("spring.rabbitmq.host"); + if (host != null && host.trim().length() > 0) { + StringBuilder sb = new StringBuilder("amqp://"); + String user = config.get("spring.rabbitmq.username"); + String password = config.get("spring.rabbitmq.password"); + if (user != null) { + sb.append(user).append(':').append(password == null ? "" : password).append('@'); + } + sb.append(host.trim()).append(':').append(config.getInt("spring.rabbitmq.port", 5672)); + String vhost = config.get("spring.rabbitmq.virtual-host"); + if (vhost != null && vhost.length() > 0 && !"/".equals(vhost)) { + sb.append('/').append(vhost); + } + return sb.toString(); + } + String kafka = config.get("spring.kafka.bootstrap-servers"); + if (kafka != null && kafka.trim().length() > 0) { + return "kafka://" + kafka.trim(); + } + return null; + } + + /// Opens the transport `config` names; the caller closes it. + /// + /// - `dataSource`: the server's, or null; where `db:` queues and where an + /// outbox is kept + public static MessageTransport open(Config config, DataSource dataSource) throws IOException { + String url = url(config); + RetryPolicy retry = RetryPolicy.of(config); + if (url == null) { + url = dataSource != null ? "db:" : "memory:"; + } + if (url.startsWith("memory:")) { + return new MemoryTransport(retry); + } + if (url.startsWith("db:")) { + if (dataSource == null) { + throw new IOException("cn1.messaging.url=db: needs a data source; set " + + "cn1.datasource.url or use memory: for development"); + } + return new DatabaseTransport(dataSource, retry, + config == null ? 60000 : config.getInt("cn1.messaging.lease", 60000), + config == null ? 500 : config.getInt("cn1.messaging.db.poll", 500)); + } + int colon = url.indexOf(':'); + String scheme = colon < 0 ? url : url.substring(0, colon); + TransportFactory f; + synchronized (FACTORIES) { + f = FACTORIES.get(scheme); + if (f == null && scheme.endsWith("s")) { + // amqps, rediss: the TLS form of the same transport. + f = FACTORIES.get(scheme.substring(0, scheme.length() - 1)); + } + } + if (f == null) { + throw new IOException("cn1.messaging.url \"" + url + "\" names a transport this " + + "application was not built with; set cn1.messaging.url in " + + "application.properties so the build includes it"); + } + MessageTransport t = f.open(url, config, retry); // NOPMD CloseResource - returned, or wrapped and returned + return dataSource == null ? t : new OutboxTransport(t, dataSource); + } +} diff --git a/vm/backend/src/com/codename1/backend/messaging/OutboxTransport.java b/vm/backend/src/com/codename1/backend/messaging/OutboxTransport.java new file mode 100644 index 00000000000..b7f62e8f99c --- /dev/null +++ b/vm/backend/src/com/codename1/backend/messaging/OutboxTransport.java @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.messaging; + +import com.codename1.backend.Base64; +import com.codename1.backend.DataSource; +import com.codename1.backend.Json; +import com.codename1.backend.Transactions; +import com.codename1.backend.sql.Dialect; +import com.codename1.impl.backend.BackendAccess; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +/// A broker transport with a transactional outbox in front of it. +/// +/// A send inside a database transaction is written to `cn1_outbox` in that +/// transaction and relayed to the broker once it commits -- the transactional +/// outbox pattern, so a rollback sends nothing and a commit never loses the +/// message. A row whose relay did not happen (the process died between the +/// commit and the send) is sent by the sweep every server runs, after +/// `cn1.messaging.outbox.sweep` milliseconds (10000); a message can therefore +/// be sent twice, never zero times, which at-least-once delivery allows. +final class OutboxTransport extends MessageTransport { + static final String TABLE = "cn1_outbox"; + + private final MessageTransport broker; + private final DataSource db; + private final long sweepAfter; + private final AtomicBoolean running = new AtomicBoolean(true); + private final Object pause = new Object(); + private boolean created; + private Thread sweeper; + + OutboxTransport(MessageTransport broker, DataSource db) { + this(broker, db, 10000); + } + + OutboxTransport(MessageTransport broker, DataSource db, long sweepAfterMillis) { + this.broker = broker; + this.db = db; + this.sweepAfter = Math.max(100, sweepAfterMillis); + } + + @Override + public String getName() { + return broker.getName(); + } + + @Override + public void send(Message message) throws IOException { + broker.send(message); + } + + @Override + public Subscription subscribe(String destination, String group, int concurrency, + MessageHandler handler) throws IOException { + startSweeper(); + return broker.subscribe(destination, group, concurrency, handler); + } + + @Override + public boolean isHealthy() { + return broker.isHealthy(); + } + + private void ensureTable() throws IOException { + synchronized (this) { + if (created) { + return; + } + } + Dialect d = db.dialect(); + db.execute("CREATE TABLE IF NOT EXISTS " + TABLE + " (id " + + d.generatedKeyColumn(Dialect.BIGINT) + + ", destination VARCHAR(255) NOT NULL, msg_key VARCHAR(255), headers " + + d.columnType(Dialect.TEXT) + ", payload " + d.columnType(Dialect.TEXT) + + ", created_at " + d.columnType(Dialect.BIGINT) + " NOT NULL)", new Object[0]); + synchronized (this) { + created = true; + } + } + + /// Writes `message` to the outbox in the calling thread's transaction and + /// relays it once that commits. + void stage(final Message message) throws IOException { + ensureTable(); + startSweeper(); + final long id = db.insert("INSERT INTO " + TABLE + " (destination, msg_key, headers," + + " payload, created_at) VALUES (?, ?, ?, ?, ?)", + new Object[]{message.getDestination(), message.getKey(), + Json.write(message.headersForTransport()), + Base64.encode(message.getPayload()), + Long.valueOf(System.currentTimeMillis())}, "id"); + Transactions.registerSynchronization(new Transactions.Synchronization() { + @Override + public void afterCommit() { + relay(Long.valueOf(id), message); + } + }); + } + + /// Sends one outbox row and deletes it. Claims it first, so the sweep of + /// another process and this commit cannot both send it. + private boolean relay(Object id, Message message) { + try { + if (db.execute("DELETE FROM " + TABLE + " WHERE id = ?", new Object[]{id}) != 1) { + return false; + } + } catch (IOException err) { + System.err.println("cn1: the outbox could not be updated; the sweep sends the " + + "message later: " + err.getMessage()); + return false; + } + try { + broker.send(message); + return true; + } catch (IOException err) { + // The row is gone, so put the message back for the sweep. + try { + db.execute("INSERT INTO " + TABLE + " (destination, msg_key, headers, payload," + + " created_at) VALUES (?, ?, ?, ?, ?)", + new Object[]{message.getDestination(), message.getKey(), + Json.write(message.headersForTransport()), + Base64.encode(message.getPayload()), Long.valueOf(0)}); + } catch (IOException again) { + System.err.println("cn1: a message to " + message.getDestination() + + " was lost: the broker refused it (" + err.getMessage() + + ") and the outbox could not keep it (" + again.getMessage() + ")"); + } + return false; + } + } + + /// Relays every row older than the sweep age; answers how many it sent. + int sweep() throws IOException { + ensureTable(); + List rows = db.query("SELECT id, destination, msg_key, headers, payload FROM " + TABLE + + " WHERE created_at <= ? ORDER BY id " + db.dialect().limit(100, 0), + new Object[]{Long.valueOf(System.currentTimeMillis() - sweepAfter)}); + int sent = 0; + for (Object o : rows) { + Map row = (Map) o; + Map headers = new LinkedHashMap(); + Object h = row.get("headers"); + if (h instanceof String) { + for (Object e : Json.parseObject((String) h).entrySet()) { + Map.Entry entry = (Map.Entry) e; + if (entry.getValue() != null) { + headers.put(String.valueOf(entry.getKey()), + String.valueOf(entry.getValue())); + } + } + } + Object key = row.get("msg_key"); + Object payload = row.get("payload"); + Message m = new Message(String.valueOf(row.get("destination")), + key instanceof String ? (String) key : null, + payload instanceof String ? Base64.decode((String) payload) : new byte[0], + headers); + if (relay(row.get("id"), m)) { + sent++; + } + } + return sent; + } + + private void startSweeper() { + synchronized (this) { + if (sweeper != null || !running.get()) { + return; + } + sweeper = new Thread(BackendAccess.get().inServer(new Runnable() { + @Override + public void run() { + sweepLoop(); + } + }), "cn1-outbox"); + sweeper.setDaemon(true); + sweeper.start(); + } + } + + private void sweepLoop() { + while (running.get()) { + try { + sweep(); + } catch (IOException err) { + System.err.println("cn1: the outbox sweep failed: " + err.getMessage()); + } + synchronized (pause) { + try { + if (running.get()) { + pause.wait(sweepAfter); + } + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + return; + } + } + } + } + + @Override + public void close() { + running.set(false); + synchronized (pause) { + pause.notifyAll(); + } + broker.close(); + } +} diff --git a/vm/backend/src/com/codename1/backend/messaging/PollingTransport.java b/vm/backend/src/com/codename1/backend/messaging/PollingTransport.java new file mode 100644 index 00000000000..851ec74a882 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/messaging/PollingTransport.java @@ -0,0 +1,258 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.messaging; + +import com.codename1.impl.backend.BackendAccess; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +/// A transport whose consumers ask the broker for messages in a loop: the +/// database, Redis Streams, Kafka. It runs each subscription's consumer threads, +/// and turns a handler's failure into a later delivery or a dead letter, so a +/// subclass only says how to poll, acknowledge and reschedule. +/// +/// Consumer threads run as part of the server that subscribed, so a handler +/// reaches that server's beans, data source and key/value store. +public abstract class PollingTransport extends MessageTransport { + /// How long a consumer waits after the broker failed before asking again. + private static final long FAILURE_PAUSE = 1000; + + /// The retry policy of every subscription. + protected final RetryPolicy retry; + private final List subscriptions = new ArrayList(); + private final AtomicBoolean closed = new AtomicBoolean(); + + /// A transport retrying by `retry`. + protected PollingTransport(RetryPolicy retry) { + this.retry = retry; + } + + /// One message as polled, with whatever the broker needs to acknowledge it. + protected static final class Delivery { + /// The message. + public final Message message; + /// The broker's handle: a row id, a stream entry id, an offset. + public final Object handle; + + /// A delivery of `message` acknowledged through `handle`. + public Delivery(Message message, Object handle) { + this.message = message; + this.handle = handle; + } + } + + /// Up to `max` messages of `destination` for `group`, waiting up to + /// `waitMillis` for the first; an empty list when none came. + protected abstract List poll(String destination, String group, int max, + long waitMillis) throws IOException; + + /// Acknowledges a handled delivery. + protected abstract void ack(String destination, String group, Delivery delivery) + throws IOException; + + /// Has `delivery` delivered again, as `attempt`, no sooner than + /// `notBefore`. The default sends a copy carrying the attempt and the time + /// in its headers and acknowledges the original; a consumer that polls the + /// copy early puts it back with [#notYet]. + protected void redeliver(String destination, String group, Delivery delivery, int attempt, + long notBefore) throws IOException { + send(delivery.message.withHeader(Message.ATTEMPT, Integer.toString(attempt)) + .withHeader(Message.NOT_BEFORE, Long.toString(notBefore))); + ack(destination, group, delivery); + } + + /// Sends `delivery` to the dead-letter destination and acknowledges it. + protected void deadLetter(String destination, String group, Delivery delivery, String error) + throws IOException { + send(delivery.message.withHeader(Message.ERROR, error) + .withHeader(Message.ORIGIN, destination) + .withHeader(Message.NOT_BEFORE, null) + .to(RetryPolicy.deadLetter(destination))); + ack(destination, group, delivery); + } + + /// `message`, given the identifier the broker knows it by. + protected static Message identified(Message message, String id) { + message.setId(id); + return message; + } + + /// Whether `message` carries a retry time still in the future. + protected static boolean notYet(Message message, long now) { + String nb = message.getHeader(Message.NOT_BEFORE); + if (nb == null) { + return false; + } + try { + return Long.parseLong(nb) > now; + } catch (NumberFormatException err) { + return false; + } + } + + @Override + public Subscription subscribe(String destination, String group, int concurrency, + MessageHandler handler) throws IOException { + if (closed.get()) { + throw new IOException("The " + getName() + " transport is closed"); + } + Consumers c = new Consumers(destination, group == null ? "default" : group, + Math.max(1, concurrency), handler); + synchronized (subscriptions) { + subscriptions.add(c); + } + c.start(); + return c; + } + + /// Stops every subscription, waiting for the messages being handled. + protected void stopSubscriptions() { + closed.set(true); + List all; + synchronized (subscriptions) { + all = new ArrayList(subscriptions); + subscriptions.clear(); + } + for (Consumers c : all) { + c.close(); + } + } + + /// Hands one delivery to `handler` and settles it. Never throws. + final void dispatch(String destination, String group, MessageHandler handler, Delivery d) { + try { + handler.handle(d.message); + } catch (Throwable err) { // NOPMD AvoidCatchingThrowable - any failure is a redelivery + settleFailure(destination, group, d, err); + return; + } + try { + ack(destination, group, d); + } catch (IOException err) { + System.err.println("cn1: a handled message of " + destination + + " could not be acknowledged and will be delivered again: " + + err.getMessage()); + } + } + + private void settleFailure(String destination, String group, Delivery d, Throwable err) { + String text = err.getClass().getName() + (err.getMessage() == null ? "" + : ": " + err.getMessage()); + int attempt = d.message.getAttempt(); + try { + if (attempt >= retry.getMaxAttempts()) { + System.err.println("cn1: a message of " + destination + " failed " + attempt + + " times and goes to " + RetryPolicy.deadLetter(destination) + ": " + + text); + deadLetter(destination, group, d, text); + } else { + redeliver(destination, group, d, attempt + 1, + System.currentTimeMillis() + retry.delayAfter(attempt)); + } + } catch (IOException io) { + System.err.println("cn1: a failed message of " + destination + + " could not be rescheduled and will be delivered again: " + + io.getMessage()); + } + } + + /// The consumer threads of one subscription. + private final class Consumers implements Subscription { + private final String destination; + private final String group; + private final MessageHandler handler; + private final Thread[] threads; + private final AtomicBoolean running = new AtomicBoolean(true); + + Consumers(String destination, String group, int concurrency, MessageHandler handler) { + this.destination = destination; + this.group = group; + this.handler = handler; + this.threads = new Thread[concurrency]; + Runnable loop = BackendAccess.get().inServer(new Runnable() { + @Override + public void run() { + consume(); + } + }); + for (int i = 0; i < concurrency; i++) { + threads[i] = new Thread(loop, "cn1-consumer-" + destination + "-" + i); + threads[i].setDaemon(true); + } + } + + void start() { + for (Thread t : threads) { + t.start(); + } + } + + void consume() { + while (running.get()) { + List batch; + try { + batch = poll(destination, group, 1, 1000); + } catch (IOException err) { + if (!running.get()) { + return; + } + System.err.println("cn1: polling " + destination + " failed: " + + err.getMessage()); + pause(FAILURE_PAUSE); + continue; + } + for (Delivery d : batch) { + dispatch(destination, group, handler, d); + } + } + } + + private void pause(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + } + } + + @Override + public void close() { + if (!running.getAndSet(false)) { + return; + } + synchronized (subscriptions) { + subscriptions.remove(this); + } + for (Thread t : threads) { + try { + t.join(5000); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + return; + } + } + } + } +} diff --git a/vm/backend/src/com/codename1/backend/messaging/RetryPolicy.java b/vm/backend/src/com/codename1/backend/messaging/RetryPolicy.java new file mode 100644 index 00000000000..169a29ef352 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/messaging/RetryPolicy.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.messaging; + +import com.codename1.backend.Config; +import java.io.IOException; + +/// How often a failed message is delivered again, and how long apart. +/// +/// | Setting | Default | | +/// |---|---|---| +/// | `cn1.messaging.retry.max-attempts` | 3 | deliveries before the dead-letter destination | +/// | `cn1.messaging.retry.backoff` | 1000 | milliseconds before the second delivery | +/// | `cn1.messaging.retry.multiplier` | 2 | how much longer each wait is than the last | +/// | `cn1.messaging.retry.max-backoff` | 30000 | the longest wait | +public final class RetryPolicy { + private final int maxAttempts; + private final long backoff; + private final int multiplier; + private final long maxBackoff; + + /// A policy of `maxAttempts` deliveries, waiting `backoffMillis` and then + /// `multiplier` times longer each time, up to `maxBackoffMillis`. + public RetryPolicy(int maxAttempts, long backoffMillis, int multiplier, long maxBackoffMillis) { + this.maxAttempts = Math.max(1, maxAttempts); + this.backoff = Math.max(0, backoffMillis); + this.multiplier = Math.max(1, multiplier); + this.maxBackoff = Math.max(this.backoff, maxBackoffMillis); + } + + /// The policy `config` describes. + public static RetryPolicy of(Config config) throws IOException { + if (config == null) { + return new RetryPolicy(3, 1000, 2, 30000); + } + return new RetryPolicy(config.getInt("cn1.messaging.retry.max-attempts", 3), + config.getInt("cn1.messaging.retry.backoff", 1000), + config.getInt("cn1.messaging.retry.multiplier", 2), + config.getInt("cn1.messaging.retry.max-backoff", 30000)); + } + + /// Deliveries before a message is dead-lettered. + public int getMaxAttempts() { + return maxAttempts; + } + + /// Milliseconds to wait before delivery `attempt + 1`, after delivery + /// `attempt` failed. + public long delayAfter(int attempt) { + long d = backoff; + for (int i = 1; i < attempt && d < maxBackoff; i++) { + d *= multiplier; + } + return Math.min(d, maxBackoff); + } + + /// The dead-letter destination of `destination`. + public static String deadLetter(String destination) { + return destination + ".dlq"; + } +} diff --git a/vm/backend/src/com/codename1/backend/messaging/Subscription.java b/vm/backend/src/com/codename1/backend/messaging/Subscription.java new file mode 100644 index 00000000000..9e76ae49291 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/messaging/Subscription.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.messaging; + +/// A running subscription. Closing it stops taking messages and waits for the +/// ones being handled. +public interface Subscription { + /// Stops the subscription. Never throws. + void close(); +} diff --git a/vm/backend/src/com/codename1/backend/messaging/package-info.java b/vm/backend/src/com/codename1/backend/messaging/package-info.java new file mode 100644 index 00000000000..b358751ff00 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/messaging/package-info.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +/// Messaging: sending through [com.codename1.backend.messaging.MessageTemplate] +/// and receiving in `@MessageListener` methods, over a queue in the +/// application's own database, Redis Streams, RabbitMQ or Kafka -- whichever +/// [com.codename1.backend.messaging.Messaging] is configured with. +/// +/// Listeners are found and wired at build time: no reflection, no proxies, and +/// a protocol the configuration does not name is not in the application. +package com.codename1.backend.messaging; diff --git a/vm/backend/src/com/codename1/backend/messaging/redis/RedisStreamTransport.java b/vm/backend/src/com/codename1/backend/messaging/redis/RedisStreamTransport.java new file mode 100644 index 00000000000..b9ea026ed81 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/messaging/redis/RedisStreamTransport.java @@ -0,0 +1,357 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.messaging.redis; + +import com.codename1.backend.Config; +import com.codename1.backend.Json; +import com.codename1.backend.kv.RedisConnection; +import com.codename1.backend.kv.RedisException; +import com.codename1.backend.kv.RedisKeyValueStore; +import com.codename1.backend.messaging.Message; +import com.codename1.backend.messaging.MessageTransport; +import com.codename1.backend.messaging.Messaging; +import com.codename1.backend.messaging.PollingTransport; +import com.codename1.backend.messaging.RetryPolicy; +import com.codename1.impl.backend.WireBuffer; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/// Redis Streams: `cn1.messaging.url=redis://...` (or `rediss://`). Works with +/// Redis 6.2 or newer, Valkey and KeyDB. +/// +/// A destination is a stream and a group a consumer group, created on first +/// use: each group gets every message, and the consumers of one group share +/// them. A message is an entry with its payload, key and headers as fields; +/// acknowledging it is `XACK`. A consumer that dies leaves its messages +/// pending, and another consumer of the group claims them once they have been +/// idle for `cn1.messaging.lease` milliseconds (60000) -- counting that as +/// another delivery, so a message that crashes its consumer is dead-lettered +/// like one that throws. +/// +/// A failed message stays pending, and its next delivery waits in a sorted +/// set per destination and group until it is due; only the group whose +/// listener failed sees it again. +/// +/// Streams are capped at about `cn1.messaging.redis.max-length` entries +/// (100000, trimmed with `MAXLEN ~`), so a destination nobody reads cannot +/// grow without bound. +public final class RedisStreamTransport extends PollingTransport { + private static final byte[] P = WireBuffer.utf8("p"); + private static final byte[] H = WireBuffer.utf8("h"); + private static final byte[] K = WireBuffer.utf8("k"); + + private final RedisKeyValueStore redis; + private final long lease; + private final long maxLength; + private final String node; + private final Set groups = new HashSet(); + private final List blocking = new ArrayList(); + private final ThreadLocal mine = new ThreadLocal(); + + /// Opens the transport for `redis://` and `rediss://` URLs; the generated + /// wiring registers it when the configuration names one. + public static final class Factory implements Messaging.TransportFactory { + @Override + public MessageTransport open(String url, Config config, RetryPolicy retry) + throws IOException { + int timeout = config == null ? 5000 : config.getInt("cn1.messaging.timeout", 5000); + return new RedisStreamTransport(new RedisKeyValueStore(url, timeout, timeout, 16), + retry, + config == null ? 60000 : config.getInt("cn1.messaging.lease", 60000), + config == null ? 100000 + : config.getInt("cn1.messaging.redis.max-length", 100000)); + } + } + + /// A transport over `redis`, which it closes. + public RedisStreamTransport(RedisKeyValueStore redis, RetryPolicy retry, long leaseMillis, + long maxLength) { + super(retry); + this.redis = redis; + // Longer than the longest retry wait, so a message waiting for its + // retry is never taken for an abandoned one. + this.lease = Math.max(Math.max(1000, leaseMillis), retry.delayAfter(64) + 5000); + this.maxLength = maxLength; + this.node = Long.toString(System.currentTimeMillis(), 36) + "-" + + Integer.toString(System.identityHashCode(this), 36); + } + + @Override + public String getName() { + return "redis"; + } + + @Override + public void send(Message message) throws IOException { + Map headers = new LinkedHashMap(message.getHeaders()); + headers.remove(Message.ATTEMPT); + headers.remove(Message.NOT_BEFORE); + List args = new ArrayList(); + args.add(WireBuffer.utf8("XADD")); + args.add(WireBuffer.utf8(message.getDestination())); + if (maxLength > 0) { + args.add(WireBuffer.utf8("MAXLEN")); + args.add(WireBuffer.utf8("~")); + args.add(WireBuffer.utf8(Long.toString(maxLength))); + } + args.add(WireBuffer.utf8("*")); + args.add(P); + args.add(message.getPayload()); + if (!headers.isEmpty()) { + args.add(H); + args.add(WireBuffer.utf8(Json.write(headers))); + } + if (message.getKey() != null) { + args.add(K); + args.add(WireBuffer.utf8(message.getKey())); + } + Object id = redis.call(args.toArray(new byte[args.size()][])); + identified(message, text(id)); + } + + private void ensureGroup(String destination, String group) throws IOException { + String key = destination + "\n" + group; + synchronized (groups) { + if (groups.contains(key)) { + return; + } + } + try { + // From the start of the stream: a group created after messages + // were sent still receives them, as a queue would. + redis.call("XGROUP", "CREATE", destination, group, "0", "MKSTREAM"); + } catch (RedisException err) { + if (err.getMessage() == null || !err.getMessage().startsWith("BUSYGROUP")) { + throw err; + } + } + synchronized (groups) { + groups.add(key); + } + } + + private String consumer() { + return node + "-" + Thread.currentThread().getName(); + } + + private static String retryKey(String destination, String group) { + return "cn1:msg:retry:" + destination + ":" + group; + } + + @Override + protected List poll(String destination, String group, int max, long waitMillis) + throws IOException { + ensureGroup(destination, group); + List out = new ArrayList(); + try { + due(destination, group, max, out); + if (out.size() < max) { + abandoned(destination, group, max - out.size(), out); + } + if (out.isEmpty()) { + read(destination, group, max, waitMillis, out); + } + } catch (RedisException err) { + if (err.getMessage() != null && err.getMessage().startsWith("NOGROUP")) { + // The stream was deleted under us: create it again next time. + synchronized (groups) { + groups.remove(destination + "\n" + group); + } + return out; + } + throw err; + } + return out; + } + + /// Retries whose time has come. + private void due(String destination, String group, int max, List out) + throws IOException { + String key = retryKey(destination, group); + Object r = redis.call("ZRANGEBYSCORE", key, "-inf", + Long.toString(System.currentTimeMillis()), "LIMIT", "0", Integer.toString(max)); + if (!(r instanceof Object[])) { + return; + } + for (Object member : (Object[]) r) { + String m = text(member); + Object removed = redis.call("ZREM", key, m); + if (!(removed instanceof Long) || ((Long) removed).longValue() != 1) { + continue; // another consumer took it + } + int bar = m.lastIndexOf('|'); + String id = m.substring(0, bar); + int attempt = Integer.parseInt(m.substring(bar + 1)); + Object claimed = redis.call("XCLAIM", destination, group, consumer(), "0", id); + entries(destination, claimed, attempt, out); + } + } + + /// Messages a dead consumer left pending. + private void abandoned(String destination, String group, int max, List out) + throws IOException { + Object r = redis.call("XPENDING", destination, group, "IDLE", Long.toString(lease), "-", + "+", Integer.toString(max)); + if (!(r instanceof Object[])) { + return; + } + for (Object o : (Object[]) r) { + if (!(o instanceof Object[]) || ((Object[]) o).length < 4) { + continue; + } + Object[] p = (Object[]) o; + String id = text(p[0]); + long deliveries = p[3] instanceof Long ? ((Long) p[3]).longValue() : 1; + // Claimed only if still idle that long, so two consumers cannot + // both take it. + Object claimed = redis.call("XCLAIM", destination, group, consumer(), + Long.toString(lease), id); + entries(destination, claimed, (int) Math.min(Integer.MAX_VALUE, deliveries + 1), out); + } + } + + /// New messages, waiting up to `waitMillis` for one. + private void read(String destination, String group, int max, long waitMillis, + List out) throws IOException { + RedisConnection c = mine.get(); // NOPMD CloseResource - this thread's, closed with the transport + if (c == null || c.isBroken()) { + if (c != null) { + forget(c); + } + c = redis.dedicated(); + synchronized (blocking) { + blocking.add(c); + } + mine.set(c); + } + Object r; + try { + r = c.call("XREADGROUP", "GROUP", group, consumer(), "COUNT", Integer.toString(max), + "BLOCK", Long.toString(Math.max(1, waitMillis)), "STREAMS", destination, ">"); + } catch (RedisException err) { + throw err; + } catch (IOException err) { + forget(c); + mine.remove(); + throw err; + } + if (!(r instanceof Object[])) { + return; + } + for (Object stream : (Object[]) r) { + if (stream instanceof Object[] && ((Object[]) stream).length == 2) { + entries(destination, ((Object[]) stream)[1], 1, out); + } + } + } + + private void forget(RedisConnection c) { + synchronized (blocking) { + blocking.remove(c); + } + c.close(); + } + + /// Adds the entries of an `XREADGROUP` or `XCLAIM` reply to `out`. + private static void entries(String destination, Object reply, int attempt, + List out) throws IOException { + if (!(reply instanceof Object[])) { + return; + } + for (Object e : (Object[]) reply) { + if (!(e instanceof Object[]) || ((Object[]) e).length != 2 + || !(((Object[]) e)[1] instanceof Object[])) { + continue; // deleted, or trimmed away + } + String id = text(((Object[]) e)[0]); + Object[] fields = (Object[]) ((Object[]) e)[1]; + byte[] payload = new byte[0]; + String key = null; + Map headers = new LinkedHashMap(); + for (int i = 0; i + 1 < fields.length; i += 2) { + String f = text(fields[i]); + if ("p".equals(f)) { + payload = fields[i + 1] instanceof byte[] ? (byte[]) fields[i + 1] : payload; + } else if ("k".equals(f)) { + key = text(fields[i + 1]); + } else if ("h".equals(f)) { + for (Object o : Json.parseObject(text(fields[i + 1])).entrySet()) { + Map.Entry entry = (Map.Entry) o; + if (entry.getValue() != null) { + headers.put(String.valueOf(entry.getKey()), + String.valueOf(entry.getValue())); + } + } + } + } + headers.put(Message.ATTEMPT, Integer.toString(Math.max(1, attempt))); + out.add(new Delivery(identified(new Message(destination, key, payload, headers), id), + id)); + } + } + + @Override + protected void ack(String destination, String group, Delivery delivery) throws IOException { + redis.call("XACK", destination, group, (String) delivery.handle); + } + + @Override + protected void redeliver(String destination, String group, Delivery delivery, int attempt, + long notBefore) throws IOException { + // Left pending; the sorted set says when it is due, and for which group. + redis.call("ZADD", retryKey(destination, group), Long.toString(notBefore), + delivery.handle + "|" + attempt); + } + + private static String text(Object reply) { + if (reply instanceof byte[]) { + return WireBuffer.fromUtf8((byte[]) reply); + } + return reply == null ? null : reply.toString(); + } + + @Override + public boolean isHealthy() { + return redis.ping(); + } + + @Override + public void close() { + // Consumers end within one blocking read of being told to. + stopSubscriptions(); + List all; + synchronized (blocking) { + all = new ArrayList(blocking); + blocking.clear(); + } + for (RedisConnection c : all) { + c.close(); + } + redis.close(); + } +} diff --git a/vm/backend/src/com/codename1/backend/messaging/redis/package-info.java b/vm/backend/src/com/codename1/backend/messaging/redis/package-info.java new file mode 100644 index 00000000000..ce56a32229b --- /dev/null +++ b/vm/backend/src/com/codename1/backend/messaging/redis/package-info.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +/// The Redis Streams transport. Compiled into an application only when its +/// configuration names a `redis://` messaging URL. +package com.codename1.backend.messaging.redis; diff --git a/vm/backend/src/com/codename1/impl/backend/BackendAccess.java b/vm/backend/src/com/codename1/impl/backend/BackendAccess.java index dd91b3f6c48..efff72c5f7a 100644 --- a/vm/backend/src/com/codename1/impl/backend/BackendAccess.java +++ b/vm/backend/src/com/codename1/impl/backend/BackendAccess.java @@ -290,4 +290,9 @@ public abstract Object serverResource(String key, ResourceFactory factory) /// The calling thread's server's configuration, or null outside any server. public abstract Config currentConfig(); + + /// `work`, made to run as part of the calling thread's server on whatever + /// thread later runs it -- a messaging consumer's own thread -- so its + /// resources, beans and configuration are that server's. + public abstract Runnable inServer(Runnable work); } From 60008e3c8fa45e554ea9881e1d9dadbb53a9f62d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:07:26 +0200 Subject: [PATCH 03/11] Backend: Redis Streams and AMQP 0-9-1 messaging transports redis:// sends with XADD and consumes through XREADGROUP consumer groups, reclaiming abandoned messages with XPENDING/XCLAIM after the lease and holding retries in a sorted set. amqp:// is a client for RabbitMQ written on Tcp: durable queues, persistent publishes with publisher confirms, one consuming connection per subscription with a prefetch equal to its concurrency, retries parked in TTL queues that dead-letter back, and reconnection after the broker restarts. Fixes found on the way: - the outbox did database work inside the commit callback, which the transaction manager refuses; the outbox thread now relays, sending before it deletes so a crash re-sends instead of losing the message, and a sweep claims a row before sending it - a module of message listeners alone produced no server - the consumer loop logged a broker outage once a second Tested on the JVM and natively against Redis 7, Valkey and RabbitMQ 3.13 in podman, and through the generated wiring for every transport. Co-Authored-By: Claude Opus 5.5 --- .../maven/processors/BackendBeans.java | 1 + .../RestControllerAnnotationProcessor.java | 4 +- .../maven/processors/BackendBeansTest.java | 40 ++ .../demo/msgcheck/com/demo/MsgCheck.java | 5 + .../backend/messaging/OutboxTransport.java | 78 ++- .../backend/messaging/PollingTransport.java | 13 +- .../messaging/amqp/AmqpConnection.java | 661 ++++++++++++++++++ .../backend/messaging/amqp/AmqpTransport.java | 492 +++++++++++++ .../backend/messaging/amqp/package-info.java | 27 + 9 files changed, 1287 insertions(+), 34 deletions(-) create mode 100644 vm/backend/src/com/codename1/backend/messaging/amqp/AmqpConnection.java create mode 100644 vm/backend/src/com/codename1/backend/messaging/amqp/AmqpTransport.java create mode 100644 vm/backend/src/com/codename1/backend/messaging/amqp/package-info.java diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java index 114882a1e39..48119957330 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java @@ -1923,6 +1923,7 @@ static List messagingTransports(ProcessorContext ctx) { static { TRANSPORTS.put("redis", "com.codename1.backend.messaging.redis.RedisStreamTransport.Factory"); + TRANSPORTS.put("amqp", "com.codename1.backend.messaging.amqp.AmqpTransport.Factory"); } /// The listener annotation on `m`, under any of its names, or null. diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java index 2b44672b107..3265f74d830 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java @@ -1767,8 +1767,10 @@ public void finish(ProcessorContext ctx) throws ProcessingException { // a server the runtime builds a router for. boolean staticFiles = settings.values.containsKey("cn1.static.root") || applicationPropertyKnown(ctx, "cn1.static.root"); + // A worker that only consumes messages is a server too: nothing else + // would start its listeners. if (controllers.isEmpty() && webSockets.isEmpty() && !beans.hasJobs() - && !beans.hasTools() && !beans.hasManaged() && !settings.management + && !beans.hasListeners() && !beans.hasTools() && !beans.hasManaged() && !settings.management && !staticFiles) { // NOTHING LEFT, so a marker from an earlier build has to go. Maven // keeps target/classes across a build without clean, and returning diff --git a/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java index 79e34c4ca01..e4731e8a1cf 100644 --- a/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java @@ -1604,6 +1604,34 @@ public void cachingAnnotationsAnswerFromTheStoreAndEvict() throws Exception { /// Listeners under all four annotation names, a retried message, a dead /// letter and a send that rolls back, over `memory:` and `db:` -- the /// database a SQLite file, or `CN1_TEST_DB_URL` (PostgreSQL, MySQL) when set. + @Test + public void messagingRegistersOnlyTheTransportsTheConfigurationNames() throws Exception { + String[][] cases = { + {"", ""}, + {"cn1.messaging.url=redis://cache:6379\n", "redis"}, + {"cn1.messaging.url=amqps://u:p@rabbit/%2F\n", "amqp"}, + {"spring.rabbitmq.host=rabbit\n", "amqp"}, + {"cn1.messaging.url=${BROKER}\n", "redis amqp"}, + }; + // A module of listeners alone is a server: it is the only thing that + // starts them. + for (String[] c : cases) { + Map s = new LinkedHashMap(); + s.put("com.example.Inbox", PKG + "@Component public class Inbox {\n" + + " @MessageListener(\"q\") public void on(String text) { }\n" + + "}\n"); + File classes = compile(s); + writeProperties(classes, "application.properties", c[0]); + assertNoErrors(process(classes)); + String wiring = new String(Files.readAllBytes(new File(classes, + "com/example/BackendWiring.class").toPath()), "ISO-8859-1"); + assertEquals(c[0], c[1].indexOf("redis") >= 0, + wiring.indexOf("messaging/redis/RedisStreamTransport") >= 0); + assertEquals(c[0], c[1].indexOf("amqp") >= 0, + wiring.indexOf("messaging/amqp/AmqpTransport") >= 0); + } + } + @Test public void messageListenersReceiveRetryAndDeadLetter() throws Exception { Map s = new LinkedHashMap(); @@ -1666,9 +1694,21 @@ public void messageListenersReceiveRetryAndDeadLetter() throws Exception { + " }\n" + "}\n"); File classes = compile(s); + // A placeholder URL: the build cannot tell which broker, so it registers + // every transport, and each run below picks one. + writeProperties(classes, "application.properties", + "cn1.messaging.url=${CN1_MESSAGING_URL:memory:}\n"); assertNoErrors(process(classes)); List runs = new ArrayList(); File dir = Files.createTempDirectory("cn1-msg").toFile(); + String redis = System.getenv("CN1_TEST_REDIS"); + if (redis != null && redis.length() > 0) { + runs.add(new String[] {redis, new File(dir, "c.db").getAbsolutePath()}); + } + String amqp = System.getenv("CN1_TEST_AMQP"); + if (amqp != null && amqp.length() > 0) { + runs.add(new String[] {amqp, new File(dir, "d.db").getAbsolutePath()}); + } runs.add(new String[] {"memory:", new File(dir, "a.db").getAbsolutePath()}); runs.add(new String[] {"db:", new File(dir, "b.db").getAbsolutePath()}); String external = System.getenv("CN1_TEST_DB_URL"); diff --git a/vm/backend/demo/msgcheck/com/demo/MsgCheck.java b/vm/backend/demo/msgcheck/com/demo/MsgCheck.java index 4f53160aab2..f53f2c6f57b 100644 --- a/vm/backend/demo/msgcheck/com/demo/MsgCheck.java +++ b/vm/backend/demo/msgcheck/com/demo/MsgCheck.java @@ -28,6 +28,8 @@ import com.codename1.backend.messaging.MessageHandler; import com.codename1.backend.messaging.MessageTransport; import com.codename1.backend.messaging.Messaging; +import com.codename1.backend.messaging.amqp.AmqpTransport; +import com.codename1.backend.messaging.redis.RedisStreamTransport; import com.codename1.backend.messaging.Subscription; import java.util.ArrayList; import java.util.Collections; @@ -48,6 +50,9 @@ public class MsgCheck { private static final List failures = new ArrayList(); public static void main(String[] args) throws Exception { + // What the generated wiring does for a scheme application.properties names. + Messaging.register("redis", new RedisStreamTransport.Factory()); + Messaging.register("amqp", new AmqpTransport.Factory()); run("memory:", null); String file = "msgcheck-" + Long.toString(System.nanoTime(), 36) + ".db"; run("db:", file); diff --git a/vm/backend/src/com/codename1/backend/messaging/OutboxTransport.java b/vm/backend/src/com/codename1/backend/messaging/OutboxTransport.java index b7f62e8f99c..cad72f271d2 100644 --- a/vm/backend/src/com/codename1/backend/messaging/OutboxTransport.java +++ b/vm/backend/src/com/codename1/backend/messaging/OutboxTransport.java @@ -29,6 +29,7 @@ import com.codename1.backend.sql.Dialect; import com.codename1.impl.backend.BackendAccess; import java.io.IOException; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -53,6 +54,10 @@ final class OutboxTransport extends MessageTransport { private final Object pause = new Object(); private boolean created; private Thread sweeper; + /// Rows whose transaction committed, waiting for the outbox thread: a + /// commit callback may do no database work of its own, and the delete + /// that follows the send is database work. + private final List committed = new ArrayList(); OutboxTransport(MessageTransport broker, DataSource db) { this(broker, db, 10000); @@ -117,52 +122,50 @@ void stage(final Message message) throws IOException { Transactions.registerSynchronization(new Transactions.Synchronization() { @Override public void afterCommit() { - relay(Long.valueOf(id), message); + synchronized (pause) { + committed.add(new Object[] {Long.valueOf(id), message}); + pause.notifyAll(); + } } }); } - /// Sends one outbox row and deletes it. Claims it first, so the sweep of - /// another process and this commit cannot both send it. + /// Sends one outbox row, then deletes it. A process that dies between the + /// two leaves the row for a sweep to send again: twice, never zero times. private boolean relay(Object id, Message message) { try { - if (db.execute("DELETE FROM " + TABLE + " WHERE id = ?", new Object[]{id}) != 1) { - return false; - } + broker.send(message); } catch (IOException err) { - System.err.println("cn1: the outbox could not be updated; the sweep sends the " - + "message later: " + err.getMessage()); + System.err.println("cn1: a message to " + message.getDestination() + " stays in " + + "the outbox until the broker takes it: " + err.getMessage()); return false; } try { - broker.send(message); - return true; + db.execute("DELETE FROM " + TABLE + " WHERE id = ?", new Object[]{id}); } catch (IOException err) { - // The row is gone, so put the message back for the sweep. - try { - db.execute("INSERT INTO " + TABLE + " (destination, msg_key, headers, payload," - + " created_at) VALUES (?, ?, ?, ?, ?)", - new Object[]{message.getDestination(), message.getKey(), - Json.write(message.headersForTransport()), - Base64.encode(message.getPayload()), Long.valueOf(0)}); - } catch (IOException again) { - System.err.println("cn1: a message to " + message.getDestination() - + " was lost: the broker refused it (" + err.getMessage() - + ") and the outbox could not keep it (" + again.getMessage() + ")"); - } - return false; + System.err.println("cn1: a sent message stays in the outbox and will be sent " + + "again: " + err.getMessage()); } + return true; } /// Relays every row older than the sweep age; answers how many it sent. int sweep() throws IOException { ensureTable(); - List rows = db.query("SELECT id, destination, msg_key, headers, payload FROM " + TABLE + long now = System.currentTimeMillis(); + List rows = db.query("SELECT id, destination, msg_key, headers, payload, created_at FROM " + + TABLE + " WHERE created_at <= ? ORDER BY id " + db.dialect().limit(100, 0), - new Object[]{Long.valueOf(System.currentTimeMillis() - sweepAfter)}); + new Object[]{Long.valueOf(now - sweepAfter)}); int sent = 0; for (Object o : rows) { Map row = (Map) o; + // Claimed by moving its time forward, so the sweep of another + // process leaves it alone for the next sweep age. + if (db.execute("UPDATE " + TABLE + " SET created_at = ? WHERE id = ? AND created_at = ?", + new Object[]{Long.valueOf(now), row.get("id"), row.get("created_at")}) != 1) { + continue; + } Map headers = new LinkedHashMap(); Object h = row.get("headers"); if (h instanceof String) { @@ -204,16 +207,29 @@ public void run() { } private void sweepLoop() { + long nextSweep = 0; while (running.get()) { - try { - sweep(); - } catch (IOException err) { - System.err.println("cn1: the outbox sweep failed: " + err.getMessage()); + List batch; + synchronized (pause) { + batch = new ArrayList(committed); + committed.clear(); + } + for (Object[] c : batch) { + relay(c[0], (Message) c[1]); + } + if (System.currentTimeMillis() >= nextSweep) { + try { + sweep(); + } catch (IOException err) { + System.err.println("cn1: the outbox sweep failed: " + err.getMessage()); + } + nextSweep = System.currentTimeMillis() + sweepAfter; } synchronized (pause) { try { - if (running.get()) { - pause.wait(sweepAfter); + long left = nextSweep - System.currentTimeMillis(); + if (running.get() && committed.isEmpty() && left > 0) { + pause.wait(left); } } catch (InterruptedException err) { Thread.currentThread().interrupt(); diff --git a/vm/backend/src/com/codename1/backend/messaging/PollingTransport.java b/vm/backend/src/com/codename1/backend/messaging/PollingTransport.java index 851ec74a882..234111f9a04 100644 --- a/vm/backend/src/com/codename1/backend/messaging/PollingTransport.java +++ b/vm/backend/src/com/codename1/backend/messaging/PollingTransport.java @@ -210,6 +210,7 @@ void start() { } void consume() { + boolean failing = false; while (running.get()) { List batch; try { @@ -218,11 +219,19 @@ void consume() { if (!running.get()) { return; } - System.err.println("cn1: polling " + destination + " failed: " - + err.getMessage()); + if (!failing) { + // Once per outage, not once a second for as long as it lasts. + System.err.println("cn1: polling " + destination + " failed, retrying: " + + err.getMessage()); + failing = true; + } pause(FAILURE_PAUSE); continue; } + if (failing) { + System.err.println("cn1: polling " + destination + " works again"); + failing = false; + } for (Delivery d : batch) { dispatch(destination, group, handler, d); } diff --git a/vm/backend/src/com/codename1/backend/messaging/amqp/AmqpConnection.java b/vm/backend/src/com/codename1/backend/messaging/amqp/AmqpConnection.java new file mode 100644 index 00000000000..a048a16ad2e --- /dev/null +++ b/vm/backend/src/com/codename1/backend/messaging/amqp/AmqpConnection.java @@ -0,0 +1,661 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.messaging.amqp; + +import com.codename1.backend.Tcp; +import com.codename1.impl.backend.WireBuffer; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +/// One AMQP 0-9-1 connection with one channel: the framing, the handshake and +/// the few methods the transport uses. +/// +/// Writes are serialized on the connection, so consumer threads acknowledge on +/// a connection whose frames a reader thread is reading. Heartbeats are turned +/// off in the tune: a consumer can be busy in a handler far longer than any +/// heartbeat interval, and only a thread of its own could answer for it. A +/// connection lost under a consumer is found by its reader instead, and the +/// broker puts back every message that was not acknowledged. +final class AmqpConnection { + static final int METHOD = 1; + static final int HEADER = 2; + static final int BODY = 3; + static final int HEARTBEAT = 8; + private static final int FRAME_END = 0xCE; + private static final int CHANNEL = 1; + /// The largest frame this side asks for; the broker may ask for less. + private static final int MAX_FRAME = 131072; + + static final int CONNECTION = 10; + static final int CHANNEL_CLASS = 20; + static final int QUEUE = 50; + static final int BASIC = 60; + static final int CONFIRM = 85; + + private final WireBuffer wire; + private final Object writeLock = new Object(); + private int frameMax = MAX_FRAME; + /// Set by whichever thread finds the connection gone; read by the others. + private final AtomicBoolean broken = new AtomicBoolean(); + + private AmqpConnection(WireBuffer wire) { + this.wire = wire; + } + + /// An open connection with channel 1 open, to the broker `url` names: + /// `amqp[s]://user:password@host:port/vhost`, the virtual host + /// percent-encoded (`%2F` is `/`, the default). + static AmqpConnection open(String url, int timeoutMillis) throws IOException { + Address a = Address.parse(url); + Tcp tcp = Tcp.connect(a.host, a.port, timeoutMillis); + boolean opened = false; + try { + if (a.tls) { + tcp.startTls(a.host); + } + AmqpConnection c = new AmqpConnection(new WireBuffer(tcp)); + c.handshake(a); + opened = true; + return c; + } finally { + if (!opened) { + tcp.close(); + } + } + } + + private void handshake(Address a) throws IOException { + byte[] header = {'A', 'M', 'Q', 'P', 0, 0, 9, 1}; + wire.writeBytes(header); + wire.flush(); + In start = expect(CONNECTION, 10); + start.octet(); + start.octet(); + start.skipTable(); + String mechanisms = WireBuffer.fromUtf8(start.longstr()); + if (mechanisms.indexOf("PLAIN") < 0) { + throw new IOException("The AMQP broker offers no PLAIN login (" + mechanisms + ")"); + } + Out ok = new Out(); + Map props = new LinkedHashMap(); + props.put("product", "Codename One"); + ok.table(props); + ok.shortstr("PLAIN"); + ok.longstr(WireBuffer.utf8("\u0000" + a.user + "\u0000" + a.password)); + ok.shortstr("en_US"); + method(CONNECTION, 11, ok); + wire.flush(); + In tune = expect(CONNECTION, 30, "log in"); + int channelMax = tune.shortU(); + int serverFrame = tune.int32(); + tune.shortU(); + frameMax = serverFrame == 0 ? MAX_FRAME : Math.min(serverFrame, MAX_FRAME); + Out tuned = new Out(); + tuned.short16(channelMax); + tuned.int32(frameMax); + tuned.short16(0); + method(CONNECTION, 31, tuned); + Out open = new Out(); + open.shortstr(a.vhost); + open.shortstr(""); + open.octet(0); + method(CONNECTION, 40, open); + wire.flush(); + expect(CONNECTION, 41, "open virtual host " + a.vhost); + Out channel = new Out(); + channel.shortstr(""); + rpc(CHANNEL_CLASS, 10, channel, 11); + } + + boolean isBroken() { + return broken.get(); + } + + /// Writes one method frame; the caller flushes. + void method(int classId, int methodId, Out args) throws IOException { + synchronized (writeLock) { + frame(METHOD, classId, methodId, args); + } + } + + private void frame(int type, int classId, int methodId, Out args) { + int length = 4 + args.length; + wire.writeByte(type); + wire.writeShortBE(type == METHOD && classId == CONNECTION ? 0 : CHANNEL); + wire.writeIntBE(length); + wire.writeShortBE(classId); + wire.writeShortBE(methodId); + wire.writeBytes(args.data, 0, args.length); + wire.writeByte(FRAME_END); + } + + /// Sends a method and waits for its reply on a connection no reader + /// thread reads; a close from the broker is thrown with its reason. + In rpc(int classId, int methodId, Out args, int replyId) throws IOException { + synchronized (writeLock) { + frame(METHOD, classId, methodId, args); + flushLocked(); + } + return expect(classId, replyId, describe(classId, methodId)); + } + + private void flushLocked() throws IOException { + try { + wire.flush(); + } catch (IOException err) { + broken.set(true); + throw err; + } + } + + /// Publishes `body` to the default exchange, routed to `queue`. + void publish(String queue, String contentType, Map headers, byte[] body) + throws IOException { + Out args = new Out(); + args.short16(0); + args.shortstr(""); + args.shortstr(queue); + args.octet(0); + Out props = new Out(); + props.short16(BASIC); + props.short16(0); + props.int64(body.length); + int flags = (1 << 13) | (1 << 12); + if (contentType != null) { + flags |= 1 << 15; + } + props.short16(flags); + if (contentType != null) { + props.shortstr(contentType); + } + props.table(headers); + // Persistent, so a durable queue keeps it across a broker restart. + props.octet(2); + synchronized (writeLock) { + frame(METHOD, BASIC, 40, args); + wire.writeByte(HEADER); + wire.writeShortBE(CHANNEL); + wire.writeIntBE(props.length); + wire.writeBytes(props.data, 0, props.length); + wire.writeByte(FRAME_END); + int max = frameMax - 8; + for (int at = 0; at < body.length; at += max) { + int n = Math.min(max, body.length - at); + wire.writeByte(BODY); + wire.writeShortBE(CHANNEL); + wire.writeIntBE(n); + wire.writeBytes(body, at, n); + wire.writeByte(FRAME_END); + } + flushLocked(); + } + } + + /// Acknowledges one delivery. + void ack(long deliveryTag) throws IOException { + Out args = new Out(); + args.int64(deliveryTag); + args.octet(0); + synchronized (writeLock) { + frame(METHOD, BASIC, 80, args); + flushLocked(); + } + } + + /// Declares the durable queue `name` with `arguments` (null for none). + void declare(String name, Map arguments) throws IOException { + Out args = new Out(); + args.short16(0); + args.shortstr(name); + args.octet(1 << 1); + args.table(arguments); + rpc(QUEUE, 10, args, 11); + } + + /// One frame. Only one thread reads a connection. + Frame read() throws IOException { + try { + int type; + byte[] payload; + do { + type = wire.read(); + // Every frame this side reads is on channel 0 or its one channel. + wire.readShortBE(); + payload = wire.readFully(wire.readIntBE()); + if (wire.read() != FRAME_END) { + throw new IOException("The AMQP broker sent a malformed frame"); + } + } while (type == HEARTBEAT); + return new Frame(type, payload); + } catch (IOException err) { + broken.set(true); + throw err; + } + } + + private In expect(int classId, int methodId) throws IOException { + return expect(classId, methodId, describe(classId, methodId)); + } + + /// The next method frame, which must be `classId.methodId`; a close from + /// the broker is thrown with its reason, and anything else is a protocol + /// error. + private In expect(int classId, int methodId, String doing) throws IOException { + while (true) { + Frame f = read(); + if (f.type != METHOD) { + continue; + } + In in = new In(f.payload); + int c = in.shortU(); + int m = in.shortU(); + if (c == classId && m == methodId) { + return in; + } + closed(in, c, m, doing); + if (c == BASIC && (m == 80 || m == 120)) { + // A confirm arriving behind a reply is the caller's to read. + continue; + } + broken.set(true); + throw new IOException("The AMQP broker answered " + c + "." + m + " to " + doing); + } + } + + /// Throws the broker's reason when `c.m` is a channel or connection close, + /// answering it first. + void closed(In in, int c, int m, String doing) throws IOException { + if (c == CONNECTION && m == 50 || c == CHANNEL_CLASS && m == 40) { + int code = in.shortU(); + String text = in.shortstr(); + broken.set(true); + try { + synchronized (writeLock) { + frame(METHOD, c, m + 1, new Out()); + wire.flush(); + } + } catch (IOException ignored) { + // Closing anyway. + } + throw new IOException("The AMQP broker refused to " + doing + ": " + code + " " + + text); + } + } + + /// Waits for the broker's confirm of the last publish. + void awaitConfirm() throws IOException { + while (true) { + Frame f = read(); + if (f.type != METHOD) { + continue; + } + In in = new In(f.payload); + int c = in.shortU(); + int m = in.shortU(); + if (c == BASIC && m == 80) { + return; + } + if (c == BASIC && m == 120) { + throw new IOException("The AMQP broker did not accept the message"); + } + closed(in, c, m, "publish"); + } + } + + void close() { + broken.set(true); + try { + Out args = new Out(); + args.short16(200); + args.shortstr("bye"); + args.short16(0); + args.short16(0); + synchronized (writeLock) { + frame(METHOD, CONNECTION, 50, args); + wire.flush(); + } + } catch (IOException ignored) { + // The socket is closed below either way. + } + wire.getConnection().close(); + } + + private static String describe(int classId, int methodId) { + switch (classId * 1000 + methodId) { + case CONNECTION * 1000 + 10: + return "start a connection"; + case CHANNEL_CLASS * 1000 + 10: + return "open a channel"; + case QUEUE * 1000 + 10: + return "declare a queue"; + case BASIC * 1000 + 10: + return "set the prefetch"; + case BASIC * 1000 + 20: + return "consume"; + case CONFIRM * 1000 + 10: + return "turn on publisher confirms"; + default: + return "run " + classId + "." + methodId; + } + } + + /// A frame read off the connection. + static final class Frame { + final int type; + final byte[] payload; + + Frame(int type, byte[] payload) { + this.type = type; + this.payload = payload; + } + } + + /// Method arguments being written. + static final class Out { + byte[] data = new byte[128]; + int length; + + private void room(int n) { + if (length + n > data.length) { + byte[] bigger = new byte[Math.max(data.length * 2, length + n)]; + System.arraycopy(data, 0, bigger, 0, length); + data = bigger; + } + } + + void octet(int v) { + room(1); + data[length++] = (byte) v; + } + + void short16(int v) { + octet(v >> 8); + octet(v); + } + + void int32(int v) { + short16(v >>> 16); + short16(v); + } + + void int64(long v) { + int32((int) (v >>> 32)); + int32((int) v); + } + + void bytes(byte[] b) { + room(b.length); + System.arraycopy(b, 0, data, length, b.length); + length += b.length; + } + + void shortstr(String s) { + byte[] b = WireBuffer.utf8(s); + if (b.length > 255) { + throw new IllegalArgumentException("An AMQP name is at most 255 bytes: " + s); + } + octet(b.length); + bytes(b); + } + + void longstr(byte[] b) { + int32(b.length); + bytes(b); + } + + /// A field table of string, int, long and boolean values. + void table(Map values) { + int at = length; + int32(0); + if (values != null) { + for (Map.Entry e : values.entrySet()) { + Object v = e.getValue(); + if (v == null) { + continue; + } + shortstr(e.getKey()); + if (v instanceof Integer) { + octet('I'); + int32(((Integer) v).intValue()); + } else if (v instanceof Long) { + octet('l'); + int64(((Long) v).longValue()); + } else if (v instanceof Boolean) { + octet('t'); + octet(((Boolean) v).booleanValue() ? 1 : 0); + } else { + octet('S'); + longstr(WireBuffer.utf8(v.toString())); + } + } + } + int size = length - at - 4; + data[at] = (byte) (size >>> 24); + data[at + 1] = (byte) (size >>> 16); + data[at + 2] = (byte) (size >>> 8); + data[at + 3] = (byte) size; + } + } + + /// Method arguments being read. + static final class In { + private final byte[] b; + private int p; + + In(byte[] b) { + this.b = b; + } + + private void need(int n) throws IOException { + if (n < 0 || p + n > b.length) { + throw new IOException("The AMQP broker sent a truncated frame"); + } + } + + int octet() throws IOException { + need(1); + return b[p++] & 0xff; + } + + int shortU() throws IOException { + return (octet() << 8) | octet(); + } + + int int32() throws IOException { + return (shortU() << 16) | shortU(); + } + + long int64() throws IOException { + return ((long) int32() << 32) | (int32() & 0xffffffffL); + } + + private byte[] take(int n) throws IOException { + need(n); + byte[] out = new byte[n]; + System.arraycopy(b, p, out, 0, n); + p += n; + return out; + } + + String shortstr() throws IOException { + return WireBuffer.fromUtf8(take(octet())); + } + + byte[] longstr() throws IOException { + return take(int32()); + } + + void skipTable() throws IOException { + int n = int32(); + need(n); + p += n; + } + + /// A field table with every value as text; binary, array and nested + /// table values are left out. + Map table() throws IOException { + int n = int32(); + need(n); + int end = p + n; + Map out = new LinkedHashMap(); + while (p < end) { + String key = shortstr(); + String value = value(); + if (value != null) { + out.put(key, value); + } + } + return out; + } + + private String value() throws IOException { + int type = octet(); + switch (type) { + case 't': + return octet() != 0 ? "true" : "false"; + case 'b': + return Integer.toString((byte) octet()); + case 'B': + return Integer.toString(octet()); + case 's': + return Integer.toString((short) shortU()); + case 'u': + return Integer.toString(shortU()); + case 'I': + return Integer.toString(int32()); + case 'i': + return Long.toString(int32() & 0xffffffffL); + case 'l': + case 'L': + case 'T': + return Long.toString(int64()); + case 'f': + need(4); + p += 4; + return null; + case 'd': + need(8); + p += 8; + return null; + case 'D': + need(5); + p += 5; + return null; + case 'S': + return WireBuffer.fromUtf8(longstr()); + case 'x': + case 'A': + case 'F': + skipTable(); + return null; + case 'V': + return null; + default: + throw new IOException("The AMQP broker sent a field of unknown type " + type); + } + } + } + + /// The parts of an AMQP URL. + static final class Address { + boolean tls; + String host = "localhost"; + int port; + String user = "guest"; + String password = "guest"; + String vhost = "/"; + + static Address parse(String url) throws IOException { + Address a = new Address(); + int scheme = url.indexOf("://"); + if (scheme < 0) { + throw new IOException("Not an AMQP URL: " + url); + } + String s = url.substring(0, scheme); + a.tls = "amqps".equalsIgnoreCase(s); + if (!a.tls && !"amqp".equalsIgnoreCase(s)) { + throw new IOException("Not an AMQP URL: " + url); + } + String rest = url.substring(scheme + 3); + int q = rest.indexOf('?'); + if (q >= 0) { + rest = rest.substring(0, q); + } + int slash = rest.indexOf('/'); + if (slash >= 0) { + String path = rest.substring(slash + 1); + if (path.length() > 0) { + a.vhost = decode(path); + } + rest = rest.substring(0, slash); + } + int at = rest.lastIndexOf('@'); + if (at >= 0) { + String credentials = rest.substring(0, at); + rest = rest.substring(at + 1); + int colon = credentials.indexOf(':'); + a.user = decode(colon < 0 ? credentials : credentials.substring(0, colon)); + a.password = colon < 0 ? "" : decode(credentials.substring(colon + 1)); + } + int colon = rest.lastIndexOf(':'); + a.port = a.tls ? 5671 : 5672; + if (colon > 0 && rest.indexOf(']') < colon) { + try { + a.port = Integer.parseInt(rest.substring(colon + 1)); + } catch (NumberFormatException err) { + throw new IOException("Not a port in " + url, err); + } + rest = rest.substring(0, colon); + } + if (rest.length() > 0) { + a.host = rest.startsWith("[") && rest.endsWith("]") + ? rest.substring(1, rest.length() - 1) : rest; + } + return a; + } + + /// Percent-decodes ASCII escapes. + private static String decode(String s) { + if (s.indexOf('%') < 0) { + return s; + } + StringBuilder sb = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '%' && i + 2 < s.length()) { + int hi = Character.digit(s.charAt(i + 1), 16); + int lo = Character.digit(s.charAt(i + 2), 16); + if (hi >= 0 && lo >= 0) { + sb.append((char) (hi * 16 + lo)); + i += 2; + continue; + } + } + sb.append(c); + } + return sb.toString(); + } + } +} diff --git a/vm/backend/src/com/codename1/backend/messaging/amqp/AmqpTransport.java b/vm/backend/src/com/codename1/backend/messaging/amqp/AmqpTransport.java new file mode 100644 index 00000000000..044565d2029 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/messaging/amqp/AmqpTransport.java @@ -0,0 +1,492 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.messaging.amqp; + +import com.codename1.backend.Config; +import com.codename1.backend.messaging.Message; +import com.codename1.backend.messaging.MessageHandler; +import com.codename1.backend.messaging.MessageTransport; +import com.codename1.backend.messaging.Messaging; +import com.codename1.backend.messaging.PollingTransport; +import com.codename1.backend.messaging.RetryPolicy; +import com.codename1.backend.messaging.Subscription; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +/// Messaging over RabbitMQ, or any AMQP 0-9-1 broker, with no client library. +/// +/// ```properties +/// cn1.messaging.url=amqp://app:secret@rabbit:5672/%2F +/// ``` +/// +/// A destination is a durable queue of that name, declared on first use and +/// published to through the default exchange, persistent and with publisher +/// confirms: [#send] returns once the broker has the message. Every listener +/// of a queue competes for its messages, whatever its group says -- a fan-out +/// to several groups is an exchange the application binds itself. +/// +/// A subscription is one connection consuming with a prefetch equal to its +/// concurrency, so the broker holds back what no consumer thread is free to +/// take; one reader thread receives deliveries and the consumer threads +/// acknowledge each after its handler returns. A message whose handler failed +/// waits in `.retry.`, a queue whose time to live dead-letters it +/// back to the original, so a retry occupies no consumer while it waits. One +/// that fails every attempt goes to `.dlq`. +public final class AmqpTransport extends PollingTransport { + private static final String KEY = "cn1-key"; + private final String url; + private final int timeout; + private final Object publishLock = new Object(); + private AmqpConnection publisher; + private final Set declared = new HashSet(); + private final Map consumers = new HashMap(); + private final AtomicBoolean closed = new AtomicBoolean(); + + /// Registers this transport with [Messaging]; the generated wiring does it. + public static final class Factory implements Messaging.TransportFactory { + @Override + public MessageTransport open(String url, Config config, RetryPolicy retry) + throws IOException { + return new AmqpTransport(url, retry, + config == null ? 5000 : config.getInt("cn1.messaging.timeout", 5000)); + } + } + + /// A transport to the broker `url` names; it connects on first use. + public AmqpTransport(String url, RetryPolicy retry, int timeoutMillis) { + super(retry); + this.url = url; + this.timeout = timeoutMillis; + } + + @Override + public String getName() { + return "amqp"; + } + + // ------------------------------------------------------------- sending + + @Override + public void send(Message message) throws IOException { + publish(message, message.getDestination(), null); + } + + private void publish(Message message, String queue, Map queueArguments) + throws IOException { + Map headers = new LinkedHashMap(); + headers.putAll(message.getHeaders()); + String contentType = message.getHeader(Message.CONTENT_TYPE); + headers.remove(Message.CONTENT_TYPE); + if (message.getKey() != null) { + headers.put(KEY, message.getKey()); + } + synchronized (publishLock) { + boolean fresh = publisher == null; + try { + publishOnce(queue, queueArguments, contentType, headers, message.getPayload()); + } catch (IOException err) { + dropPublisher(); + if (fresh || closed.get()) { + throw err; + } + // The connection was idle and the broker or the network dropped + // it; once more on a new one. + publishOnce(queue, queueArguments, contentType, headers, message.getPayload()); + } + } + } + + private void publishOnce(String queue, Map queueArguments, String contentType, + Map headers, byte[] body) throws IOException { + if (closed.get()) { + throw new IOException("The amqp transport is closed"); + } + if (publisher == null) { + AmqpConnection c = AmqpConnection.open(url, timeout); + try { + AmqpConnection.Out select = new AmqpConnection.Out(); + select.octet(0); + c.rpc(AmqpConnection.CONFIRM, 10, select, 11); + } catch (IOException err) { + c.close(); + throw err; + } + publisher = c; + declared.clear(); + } + // A retry queue is declared on every use: a declare is what keeps its + // expiry from deleting it while messages still wait in it. + if (queueArguments != null || declared.add(queue)) { + publisher.declare(queue, queueArguments); + } + publisher.publish(queue, contentType, headers, body); + publisher.awaitConfirm(); + } + + private void dropPublisher() { + if (publisher != null) { + publisher.close(); + publisher = null; + } + } + + @Override + protected void redeliver(String destination, String group, Delivery delivery, int attempt, + long notBefore) throws IOException { + long delay = Math.max(1, notBefore - System.currentTimeMillis()); + Map args = new LinkedHashMap(); + args.put("x-message-ttl", Long.valueOf(delay)); + args.put("x-dead-letter-exchange", ""); + args.put("x-dead-letter-routing-key", destination); + // Longer than any message in it can wait, so it is deleted only once + // empty and unused. + args.put("x-expires", Long.valueOf(delay * 2 + 60000)); + publish(delivery.message.withHeader(Message.ATTEMPT, Integer.toString(attempt)), + destination + ".retry." + delay, args); + ack(destination, group, delivery); + } + + // ----------------------------------------------------------- receiving + + private static String key(String destination, String group) { + return destination + "\n" + group; + } + + @Override + public Subscription subscribe(String destination, String group, int concurrency, + MessageHandler handler) throws IOException { + final String k = key(destination, group == null ? "default" : group); + final Consumer c; + synchronized (consumers) { + Consumer existing = consumers.get(k); + c = existing == null ? new Consumer(destination) : existing; + c.prefetch += Math.max(1, concurrency); + c.users++; + if (existing == null) { + consumers.put(k, c); + } + } + try { + c.connect(); + } catch (IOException err) { + release(k, c, concurrency); + throw err; + } + final Subscription inner; + try { + inner = super.subscribe(destination, group, concurrency, handler); + } catch (IOException err) { + release(k, c, concurrency); + throw err; + } + final int n = Math.max(1, concurrency); + return new Subscription() { + private final AtomicBoolean done = new AtomicBoolean(); + + @Override + public void close() { + if (!done.getAndSet(true)) { + inner.close(); + release(k, c, n); + } + } + }; + } + + private void release(String k, Consumer c, int concurrency) { + boolean last; + synchronized (consumers) { + c.users--; + c.prefetch -= Math.max(1, concurrency); + last = c.users <= 0; + if (last) { + consumers.remove(k); + } + } + if (last) { + c.close(); + } + } + + @Override + protected List poll(String destination, String group, int max, long waitMillis) + throws IOException { + Consumer c; + synchronized (consumers) { + c = consumers.get(key(destination, group)); + } + if (c == null) { + throw new IOException("Not subscribed to " + destination); + } + return c.take(max, waitMillis); + } + + @Override + protected void ack(String destination, String group, Delivery delivery) throws IOException { + Tag tag = (Tag) delivery.handle; + if (tag.link.isBroken()) { + // The broker puts back what a lost connection had not acknowledged, + // so this message comes again. + return; + } + tag.link.ack(tag.deliveryTag); + } + + /// A delivery tag and the connection it belongs to. + private static final class Tag { + final AmqpConnection link; + final long deliveryTag; + + Tag(AmqpConnection link, long deliveryTag) { + this.link = link; + this.deliveryTag = deliveryTag; + } + } + + /// The connection consuming one queue for one group, and what its reader + /// thread has received. + private final class Consumer { + private final String queue; + int prefetch; + int users; + private final LinkedList received = new LinkedList(); + private AmqpConnection link; + private int applied; + private boolean stopped; + + Consumer(String queue) { + this.queue = queue; + } + + /// Connects, or applies a new prefetch to the connection there is. + synchronized void connect() throws IOException { + if (stopped) { + throw new IOException("The amqp transport is closed"); + } + if (link != null && !link.isBroken()) { + if (applied != prefetch) { + qos(link); + } + return; + } + received.clear(); + AmqpConnection c = AmqpConnection.open(url, timeout); + try { + c.declare(queue, null); + qos(c); + AmqpConnection.Out consume = new AmqpConnection.Out(); + consume.short16(0); + consume.shortstr(queue); + consume.shortstr(""); + consume.octet(0); + consume.table(null); + c.rpc(AmqpConnection.BASIC, 20, consume, 21); + } catch (IOException err) { + c.close(); + throw err; + } + link = c; + final AmqpConnection reading = c; + Thread reader = new Thread(new Runnable() { + @Override + public void run() { + readLoop(reading); + } + }, "cn1-amqp-" + queue); + reader.setDaemon(true); + reader.start(); + } + + private void qos(AmqpConnection c) throws IOException { + AmqpConnection.Out qos = new AmqpConnection.Out(); + qos.int32(0); + qos.short16(Math.min(prefetch, 65535)); + qos.octet(0); + c.rpc(AmqpConnection.BASIC, 10, qos, 11); + applied = prefetch; + } + + /// Receives the deliveries of `c` until it is lost or closed. + void readLoop(AmqpConnection c) { + try { + while (true) { + Delivery d = readDelivery(c); + if (d != null) { + synchronized (this) { + received.add(d); + notifyAll(); + } + } + } + } catch (IOException err) { + synchronized (this) { + if (!stopped && link == c) { // NOPMD CompareObjectsWithEquals - identity + System.err.println("cn1: the AMQP connection consuming " + queue + + " was lost: " + err.getMessage()); + } + notifyAll(); + } + c.close(); + } + } + + /// The next delivery, or null after a frame that was not one. + private Delivery readDelivery(AmqpConnection c) throws IOException { + AmqpConnection.Frame f = c.read(); + if (f.type != AmqpConnection.METHOD) { + return null; + } + AmqpConnection.In in = new AmqpConnection.In(f.payload); + int cls = in.shortU(); + int m = in.shortU(); + if (cls == AmqpConnection.BASIC && m == 30) { + // The broker cancelled the consumer: the queue was deleted. + throw new IOException("The broker cancelled the consumer of " + queue); + } + if (cls != AmqpConnection.BASIC || m != 60) { + c.closed(in, cls, m, "deliver from " + queue); + return null; + } + in.shortstr(); + long deliveryTag = in.int64(); + AmqpConnection.Frame header = c.read(); + if (header.type != AmqpConnection.HEADER) { + throw new IOException("The AMQP broker sent a delivery with no header"); + } + AmqpConnection.In h = new AmqpConnection.In(header.payload); + h.shortU(); + h.shortU(); + long size = h.int64(); + if (size > Integer.MAX_VALUE - 8) { + throw new IOException("A message of " + size + " bytes is too large"); + } + int flags = h.shortU(); + String contentType = null; + Map headers = new LinkedHashMap(); + if ((flags & (1 << 15)) != 0) { + contentType = h.shortstr(); + } + if ((flags & (1 << 14)) != 0) { + h.shortstr(); + } + if ((flags & (1 << 13)) != 0) { + headers = h.table(); + } + byte[] body = new byte[(int) size]; + int at = 0; + while (at < body.length) { + AmqpConnection.Frame part = c.read(); + if (part.type != AmqpConnection.BODY || at + part.payload.length > body.length) { + throw new IOException("The AMQP broker sent a malformed message body"); + } + System.arraycopy(part.payload, 0, body, at, part.payload.length); + at += part.payload.length; + } + String key = headers.remove(KEY); + if (contentType != null && !headers.containsKey(Message.CONTENT_TYPE)) { + headers.put(Message.CONTENT_TYPE, contentType); + } + Message message = identified(new Message(queue, key, body, headers), + Long.toString(deliveryTag)); + return new Delivery(message, new Tag(c, deliveryTag)); + } + + /// Up to `max` deliveries, waiting up to `waitMillis` for the first; + /// reconnects first when the connection was lost. + List take(int max, long waitMillis) throws IOException { + synchronized (this) { + if (!stopped && (link == null || link.isBroken())) { + connect(); + } + long until = System.currentTimeMillis() + waitMillis; + while (received.isEmpty() && !stopped && link != null && !link.isBroken()) { + long left = until - System.currentTimeMillis(); + if (left <= 0) { + break; + } + try { + wait(left); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + break; + } + } + List out = new ArrayList(); + while (!received.isEmpty() && out.size() < max) { + out.add(received.removeFirst()); + } + return out; + } + } + + void close() { + AmqpConnection c; + synchronized (this) { + stopped = true; + c = link; + received.clear(); + notifyAll(); + } + if (c != null) { + c.close(); + } + } + } + + @Override + public boolean isHealthy() { + if (closed.get()) { + return false; + } + synchronized (publishLock) { + return publisher == null || !publisher.isBroken(); + } + } + + @Override + public void close() { + if (closed.getAndSet(true)) { + return; + } + stopSubscriptions(); + List all; + synchronized (consumers) { + all = new ArrayList(consumers.values()); + consumers.clear(); + } + for (Consumer c : all) { + c.close(); + } + synchronized (publishLock) { + dropPublisher(); + } + } +} diff --git a/vm/backend/src/com/codename1/backend/messaging/amqp/package-info.java b/vm/backend/src/com/codename1/backend/messaging/amqp/package-info.java new file mode 100644 index 00000000000..9fde7e0fff8 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/messaging/amqp/package-info.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +/// The AMQP 0-9-1 transport, for RabbitMQ. Compiled into an application only when +/// its configuration names an `amqp://` or `amqps://` messaging URL, or Spring +/// Boot's `spring.rabbitmq.host`. +package com.codename1.backend.messaging.amqp; From 9915b92b55b186d96e2ff0eb75fc075d7940ddac Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:29:19 +0200 Subject: [PATCH 04/11] Backend: Kafka messaging transport A Kafka client written on the backend's own Tcp layer: produce with acks=all and Kafka's murmur2 partitioner, consumer groups with range assignment computed from every member's subscription, per-partition ordering, offsets committed after the handler returns, non-blocking retry topics and a dead-letter topic. Co-Authored-By: Claude Opus 5.5 --- .../maven/processors/BackendBeans.java | 1 + .../maven/processors/BackendBeansTest.java | 4 + .../demo/msgcheck/com/demo/MsgCheck.java | 16 +- .../messaging/kafka/KafkaConnection.java | 532 +++++++ .../messaging/kafka/KafkaTransport.java | 1252 +++++++++++++++++ .../backend/messaging/kafka/package-info.java | 27 + 6 files changed, 1827 insertions(+), 5 deletions(-) create mode 100644 vm/backend/src/com/codename1/backend/messaging/kafka/KafkaConnection.java create mode 100644 vm/backend/src/com/codename1/backend/messaging/kafka/KafkaTransport.java create mode 100644 vm/backend/src/com/codename1/backend/messaging/kafka/package-info.java diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java index 48119957330..dbc6612a4fe 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java @@ -1924,6 +1924,7 @@ static List messagingTransports(ProcessorContext ctx) { TRANSPORTS.put("redis", "com.codename1.backend.messaging.redis.RedisStreamTransport.Factory"); TRANSPORTS.put("amqp", "com.codename1.backend.messaging.amqp.AmqpTransport.Factory"); + TRANSPORTS.put("kafka", "com.codename1.backend.messaging.kafka.KafkaTransport.Factory"); } /// The listener annotation on `m`, under any of its names, or null. diff --git a/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java index e4731e8a1cf..69fa9e3e682 100644 --- a/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java @@ -1709,6 +1709,10 @@ public void messageListenersReceiveRetryAndDeadLetter() throws Exception { if (amqp != null && amqp.length() > 0) { runs.add(new String[] {amqp, new File(dir, "d.db").getAbsolutePath()}); } + String kafka = System.getenv("CN1_TEST_KAFKA"); + if (kafka != null && kafka.length() > 0) { + runs.add(new String[] {kafka, new File(dir, "e.db").getAbsolutePath()}); + } runs.add(new String[] {"memory:", new File(dir, "a.db").getAbsolutePath()}); runs.add(new String[] {"db:", new File(dir, "b.db").getAbsolutePath()}); String external = System.getenv("CN1_TEST_DB_URL"); diff --git a/vm/backend/demo/msgcheck/com/demo/MsgCheck.java b/vm/backend/demo/msgcheck/com/demo/MsgCheck.java index f53f2c6f57b..b6de5e4d265 100644 --- a/vm/backend/demo/msgcheck/com/demo/MsgCheck.java +++ b/vm/backend/demo/msgcheck/com/demo/MsgCheck.java @@ -29,6 +29,7 @@ import com.codename1.backend.messaging.MessageTransport; import com.codename1.backend.messaging.Messaging; import com.codename1.backend.messaging.amqp.AmqpTransport; +import com.codename1.backend.messaging.kafka.KafkaTransport; import com.codename1.backend.messaging.redis.RedisStreamTransport; import com.codename1.backend.messaging.Subscription; import java.util.ArrayList; @@ -53,6 +54,7 @@ public static void main(String[] args) throws Exception { // What the generated wiring does for a scheme application.properties names. Messaging.register("redis", new RedisStreamTransport.Factory()); Messaging.register("amqp", new AmqpTransport.Factory()); + Messaging.register("kafka", new KafkaTransport.Factory()); run("memory:", null); String file = "msgcheck-" + Long.toString(System.nanoTime(), 36) + ".db"; run("db:", file); @@ -107,8 +109,10 @@ private static void run(String url, String database) throws Exception { } private static void order(String w, MessageTransport t, String q) throws Exception { + // One key: Kafka orders a partition, and spreads unkeyed records over all. for (int i = 0; i < 50; i++) { - t.send(new Message(q, ("m" + i).getBytes("UTF-8"))); + t.send(new Message(q, "k", ("m" + i).getBytes("UTF-8"), + new LinkedHashMap())); } final List got = Collections.synchronizedList(new ArrayList()); Subscription s = t.subscribe(q, "g", 1, new MessageHandler() { @@ -163,10 +167,12 @@ public void handle(Message m) { }); waitFor(got, 2); s.close(); - check(w + " binary", Boolean.TRUE, Boolean.valueOf(got.size() == 2 - && java.util.Arrays.equals(all, got.get(0)))); - check(w + " empty", Integer.valueOf(0), got.size() < 2 ? null - : Integer.valueOf(got.get(1).length)); + // Two partitions need not deliver in send order: tell them apart by size. + byte[] big = got.size() < 2 ? null : got.get(0).length > 0 ? got.get(0) : got.get(1); + byte[] small = got.size() < 2 ? null : got.get(0).length > 0 ? got.get(1) : got.get(0); + check(w + " binary", Boolean.TRUE, Boolean.valueOf(java.util.Arrays.equals(all, big))); + check(w + " empty", Integer.valueOf(0), small == null ? null + : Integer.valueOf(small.length)); } private static void competing(String w, MessageTransport t, String q) throws Exception { diff --git a/vm/backend/src/com/codename1/backend/messaging/kafka/KafkaConnection.java b/vm/backend/src/com/codename1/backend/messaging/kafka/KafkaConnection.java new file mode 100644 index 00000000000..8a6cea80fba --- /dev/null +++ b/vm/backend/src/com/codename1/backend/messaging/kafka/KafkaConnection.java @@ -0,0 +1,532 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.messaging.kafka; + +import com.codename1.backend.Tcp; +import com.codename1.impl.backend.WireBuffer; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.Map; +import java.util.zip.GZIPInputStream; + +/// One connection to a Kafka broker: requests in the non-flexible encoding +/// of the versions this transport uses, answered in order. Every version used +/// is one Kafka 2.1 through 4.x accept, so no version negotiation is needed. +final class KafkaConnection { + static final int PRODUCE = 0; + static final int FETCH = 1; + static final int LIST_OFFSETS = 2; + static final int METADATA = 3; + static final int OFFSET_COMMIT = 8; + static final int OFFSET_FETCH = 9; + static final int FIND_COORDINATOR = 10; + static final int JOIN_GROUP = 11; + static final int HEARTBEAT = 12; + static final int LEAVE_GROUP = 13; + static final int SYNC_GROUP = 14; + + private static final byte[] CLIENT_ID = WireBuffer.utf8("cn1"); + private final WireBuffer wire; + private final String address; + private int correlation; + + private KafkaConnection(WireBuffer wire, String address) { + this.wire = wire; + this.address = address; + } + + static KafkaConnection open(String host, int port, boolean tls, int timeoutMillis) + throws IOException { + Tcp tcp = Tcp.connect(host, port, timeoutMillis); + boolean opened = false; + try { + if (tls) { + tcp.startTls(host); + } + KafkaConnection c = new KafkaConnection(new WireBuffer(tcp, 65536), host + ":" + port); + opened = true; + return c; + } finally { + if (!opened) { + tcp.close(); + } + } + } + + String address() { + return address; + } + + /// Sends one request and reads its response, positioned after the header. + synchronized In call(int apiKey, int version, Out body) throws IOException { + int id = ++correlation; + wire.writeIntBE(2 + 2 + 4 + 2 + CLIENT_ID.length + body.length); + wire.writeShortBE(apiKey); + wire.writeShortBE(version); + wire.writeIntBE(id); + wire.writeShortBE(CLIENT_ID.length); + wire.writeBytes(CLIENT_ID); + wire.writeBytes(body.data, 0, body.length); + wire.flush(); + int length = wire.readIntBE(); + if (length < 4) { + throw new IOException("Kafka at " + address + " sent a malformed response"); + } + In in = new In(wire.readFully(length)); + if (in.int32() != id) { + throw new IOException("Kafka at " + address + " answered out of order"); + } + return in; + } + + void close() { + wire.getConnection().close(); + } + + /// The message of a Kafka error code, for the ones this transport meets. + static String error(int code) { + switch (code) { + case 1: + return "OFFSET_OUT_OF_RANGE"; + case 2: + return "CORRUPT_MESSAGE"; + case 3: + return "UNKNOWN_TOPIC_OR_PARTITION"; + case 5: + return "LEADER_NOT_AVAILABLE"; + case 6: + return "NOT_LEADER_OR_FOLLOWER"; + case 7: + return "REQUEST_TIMED_OUT"; + case 10: + return "MESSAGE_TOO_LARGE"; + case 14: + return "COORDINATOR_LOAD_IN_PROGRESS"; + case 15: + return "COORDINATOR_NOT_AVAILABLE"; + case 16: + return "NOT_COORDINATOR"; + case 17: + return "INVALID_TOPIC_EXCEPTION"; + case 22: + return "ILLEGAL_GENERATION"; + case 25: + return "UNKNOWN_MEMBER_ID"; + case 27: + return "REBALANCE_IN_PROGRESS"; + case 29: + return "TOPIC_AUTHORIZATION_FAILED"; + case 30: + return "GROUP_AUTHORIZATION_FAILED"; + default: + return "error " + code; + } + } + + // --------------------------------------------------------------- records + + /// A record read from a batch. + static final class Record { + final long offset; + final byte[] key; + final byte[] value; + final Map headers; + + Record(long offset, byte[] key, byte[] value, Map headers) { + this.offset = offset; + this.key = key; + this.value = value; + this.headers = headers; + } + } + + /// One record batch (magic 2, uncompressed) holding one record. + static byte[] batch(byte[] key, byte[] value, Map headers, long now) { + Out record = new Out(); + record.int8(0); + record.varlong(0); + record.varint(0); + record.varbytes(key); + record.varbytes(value); + record.varint(headers.size()); + for (Map.Entry h : headers.entrySet()) { + record.varbytes(WireBuffer.utf8(h.getKey())); + record.varbytes(h.getValue()); + } + Out tail = new Out(); + tail.int16(0); + tail.int32(0); + tail.int64(now); + tail.int64(now); + tail.int64(-1); + tail.int16(-1); + tail.int32(-1); + tail.int32(1); + tail.varint(record.length); + tail.raw(record.data, 0, record.length); + Out batch = new Out(); + batch.int64(0); + batch.int32(4 + 1 + 4 + tail.length); + batch.int32(-1); + batch.int8(2); + batch.int32(Crc32c.of(tail.data, 0, tail.length)); + batch.raw(tail.data, 0, tail.length); + byte[] out = new byte[batch.length]; + System.arraycopy(batch.data, 0, out, 0, batch.length); + return out; + } + + /// The records of `set` at or after `from`, in offset order. A batch cut + /// off at the end of the set -- the fetch size ended inside it -- is left + /// for the next fetch; control batches carry no records of the + /// application's. + static void records(byte[] set, long from, List out) throws IOException { + In in = new In(set); + while (in.remaining() >= 12) { + long base = in.int64(); + int length = in.int32(); + if (length < 0 || length > in.remaining()) { + return; + } + In batch = new In(in.take(length)); + batch.int32(); + int magic = batch.int8(); + if (magic != 2) { + throw new IOException("Kafka sent a message set of format " + magic + + ", which predates Kafka 0.11; upgrade the topic's format"); + } + batch.int32(); + int attributes = batch.int16(); + batch.int32(); + batch.int64(); + batch.int64(); + batch.int64(); + batch.int16(); + batch.int32(); + int count = batch.int32(); + if ((attributes & 0x20) != 0) { + continue; + } + int compression = attributes & 7; + In records = batch; + if (compression == 1) { + records = new In(gunzip(batch.take(batch.remaining()))); + } else if (compression != 0) { + throw new IOException("Kafka sent a batch compressed with " + + (compression == 2 ? "snappy" : compression == 3 ? "lz4" : "zstd") + + "; only gzip and uncompressed batches can be read"); + } + for (int i = 0; i < count; i++) { + int size = records.varint(); + In r = new In(records.take(size)); + r.int8(); + r.varlong(); + long offset = base + r.varint(); + byte[] key = r.varbytes(); + byte[] value = r.varbytes(); + int n = r.varint(); + Map headers = new java.util.LinkedHashMap(); + for (int h = 0; h < n; h++) { + byte[] name = r.varbytes(); + byte[] v = r.varbytes(); + if (name != null) { + headers.put(WireBuffer.fromUtf8(name), v); + } + } + if (offset >= from) { + out.add(new Record(offset, key, value, headers)); + } + } + } + } + + private static byte[] gunzip(byte[] data) throws IOException { + InputStream in = new GZIPInputStream(new ByteArrayInputStream(data)); + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(data.length * 4); + byte[] buffer = new byte[8192]; + int n; + while ((n = in.read(buffer)) > 0) { + out.write(buffer, 0, n); + } + return out.toByteArray(); + } finally { + in.close(); + } + } + + /// The partition Kafka's own default partitioner picks for `key`, so a + /// keyed message lands where a Java client would have put it. + static int partition(byte[] key, int partitions) { + return (murmur2(key) & 0x7fffffff) % partitions; + } + + static int murmur2(byte[] data) { + int length = data.length; + final int m = 0x5bd1e995; + int h = 0x9747b28c ^ length; + int words = length / 4; + for (int i = 0; i < words; i++) { + int at = i * 4; + int k = (data[at] & 0xff) + ((data[at + 1] & 0xff) << 8) + + ((data[at + 2] & 0xff) << 16) + ((data[at + 3] & 0xff) << 24); + k *= m; + k ^= k >>> 24; + k *= m; + h *= m; + h ^= k; + } + int tail = length & ~3; + int left = length % 4; + if (left == 3) { + h ^= (data[tail + 2] & 0xff) << 16; + } + if (left >= 2) { + h ^= (data[tail + 1] & 0xff) << 8; + } + if (left >= 1) { + h ^= data[tail] & 0xff; + h *= m; + } + h ^= h >>> 13; + h *= m; + h ^= h >>> 15; + return h; + } + + /// CRC-32C, the checksum of a record batch. + static final class Crc32c { + private static final int[] TABLE = new int[256]; + + static { + for (int i = 0; i < 256; i++) { + int c = i; + for (int k = 0; k < 8; k++) { + c = (c & 1) != 0 ? (c >>> 1) ^ 0x82F63B78 : c >>> 1; + } + TABLE[i] = c; + } + } + + private Crc32c() { + } + + static int of(byte[] data, int offset, int length) { + int c = 0xffffffff; + for (int i = offset; i < offset + length; i++) { + c = TABLE[(c ^ data[i]) & 0xff] ^ (c >>> 8); + } + return ~c; + } + } + + // ------------------------------------------------------------- encoding + + /// A request body being written. + static final class Out { + byte[] data = new byte[256]; + int length; + + private void room(int n) { + if (length + n > data.length) { + byte[] bigger = new byte[Math.max(data.length * 2, length + n)]; + System.arraycopy(data, 0, bigger, 0, length); + data = bigger; + } + } + + Out int8(int v) { + room(1); + data[length++] = (byte) v; + return this; + } + + Out int16(int v) { + int8(v >> 8); + return int8(v); + } + + Out int32(int v) { + int16(v >> 16); + return int16(v); + } + + Out int64(long v) { + int32((int) (v >>> 32)); + return int32((int) v); + } + + Out bool(boolean v) { + return int8(v ? 1 : 0); + } + + Out raw(byte[] b, int offset, int n) { + room(n); + System.arraycopy(b, offset, data, length, n); + length += n; + return this; + } + + /// A string with an int16 length; null is length -1. + Out string(String s) { + if (s == null) { + return int16(-1); + } + byte[] b = WireBuffer.utf8(s); + int16(b.length); + return raw(b, 0, b.length); + } + + /// Bytes with an int32 length; null is length -1. + Out bytes(byte[] b) { + if (b == null) { + return int32(-1); + } + int32(b.length); + return raw(b, 0, b.length); + } + + Out bytes(Out b) { + int32(b.length); + return raw(b.data, 0, b.length); + } + + Out varint(int v) { + return varlong(v); + } + + /// A zigzag varint. + Out varlong(long v) { + long z = (v << 1) ^ (v >> 63); + while ((z & ~0x7fL) != 0) { + int8((int) ((z & 0x7f) | 0x80)); + z >>>= 7; + } + return int8((int) z); + } + + Out varbytes(byte[] b) { + if (b == null) { + return varint(-1); + } + varint(b.length); + return raw(b, 0, b.length); + } + } + + /// A response being read. + static final class In { + private final byte[] b; + private int p; + + In(byte[] b) { + this.b = b; + } + + int remaining() { + return b.length - p; + } + + private void need(int n) throws IOException { + if (n < 0 || p + n > b.length) { + throw new IOException("Kafka sent a truncated response"); + } + } + + int int8() throws IOException { + need(1); + return b[p++]; + } + + int int16() throws IOException { + need(2); + int v = (short) (((b[p] & 0xff) << 8) | (b[p + 1] & 0xff)); + p += 2; + return v; + } + + int int32() throws IOException { + need(4); + int v = ((b[p] & 0xff) << 24) | ((b[p + 1] & 0xff) << 16) + | ((b[p + 2] & 0xff) << 8) | (b[p + 3] & 0xff); + p += 4; + return v; + } + + long int64() throws IOException { + long hi = int32(); + return (hi << 32) | (int32() & 0xffffffffL); + } + + boolean bool() throws IOException { + return int8() != 0; + } + + byte[] take(int n) throws IOException { + need(n); + byte[] out = new byte[n]; + System.arraycopy(b, p, out, 0, n); + p += n; + return out; + } + + String string() throws IOException { + int n = int16(); + return n < 0 ? null : WireBuffer.fromUtf8(take(n)); + } + + byte[] bytes() throws IOException { + int n = int32(); + return n < 0 ? null : take(n); + } + + long varlong() throws IOException { + long z = 0; + int shift = 0; + while (true) { + int v = int8(); + z |= (long) (v & 0x7f) << shift; + if ((v & 0x80) == 0) { + break; + } + shift += 7; + if (shift > 63) { + throw new IOException("Kafka sent a malformed varint"); + } + } + return (z >>> 1) ^ -(z & 1); + } + + int varint() throws IOException { + return (int) varlong(); + } + + byte[] varbytes() throws IOException { + int n = varint(); + return n < 0 ? null : take(n); + } + } +} diff --git a/vm/backend/src/com/codename1/backend/messaging/kafka/KafkaTransport.java b/vm/backend/src/com/codename1/backend/messaging/kafka/KafkaTransport.java new file mode 100644 index 00000000000..40103960887 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/messaging/kafka/KafkaTransport.java @@ -0,0 +1,1252 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.messaging.kafka; + +import com.codename1.backend.Config; +import com.codename1.backend.messaging.Message; +import com.codename1.backend.messaging.MessageHandler; +import com.codename1.backend.messaging.MessageTransport; +import com.codename1.backend.messaging.Messaging; +import com.codename1.backend.messaging.PollingTransport; +import com.codename1.backend.messaging.RetryPolicy; +import com.codename1.backend.messaging.Subscription; +import com.codename1.impl.backend.WireBuffer; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +/// Messaging over Apache Kafka, with no client library. +/// +/// ```properties +/// cn1.messaging.url=kafka://broker1:9092,broker2:9092 +/// ``` +/// +/// A destination is a topic, created by the broker on first use when it allows +/// that (`auto.create.topics.enable`, its default). A send is one record, +/// acknowledged by every in-sync replica (`acks=all`); a keyed one goes to the +/// partition Kafka's own partitioner would choose, so records of one key stay +/// in order, and an unkeyed one goes round the partitions. +/// +/// A listener's group is a Kafka consumer group: each group sees every record, +/// and within one the partitions are shared out among its members by range, +/// as the Java client's default does. Records of one partition are handled one +/// at a time, in order, and the group's offset moves past a record only after +/// its handler returned; concurrency beyond the number of partitions a member +/// holds therefore waits. Offsets are committed within a second of a +/// handler finishing, and before the member gives its partitions up. +/// +/// A record whose handler failed goes to `.retry.`, which the +/// group also consumes: a partition of it waits until its first record is due, +/// so retries never hold up the topic itself -- what Spring Kafka calls a +/// non-blocking retry. One that fails every attempt goes to `.dlq`. +/// +/// A new group starts at the earliest offset, so nothing sent before a +/// listener first started is skipped; `cn1.messaging.kafka.auto-offset-reset` +/// (or Spring Boot's `spring.kafka.consumer.auto-offset-reset`) set to `latest` +/// starts at the end instead. Records compressed with gzip can be read; +/// snappy, lz4 and zstd cannot, and a topic holding them fails its consumer. +/// Transactions and SASL are not supported. +public final class KafkaTransport extends PollingTransport { + private static final int SESSION_TIMEOUT = 30000; + private static final int REBALANCE_TIMEOUT = 60000; + private static final long HEARTBEAT_EVERY = 3000; + private static final long COMMIT_EVERY = 1000; + private static final int FETCH_WAIT = 500; + + private final List bootstrap; + private final boolean tls; + private final int timeout; + private final boolean earliest; + private final Object metaLock = new Object(); + private final Map brokers = new HashMap(); + private final Map leaders = new HashMap(); + private final Map producers = new HashMap(); + private KafkaConnection control; + private final Map groups = new HashMap(); + private final AtomicInteger next = new AtomicInteger(); + private final AtomicBoolean closed = new AtomicBoolean(); + + /// Registers this transport with [Messaging]; the generated wiring does it. + public static final class Factory implements Messaging.TransportFactory { + @Override + public MessageTransport open(String url, Config config, RetryPolicy retry) + throws IOException { + String reset = config == null ? null + : config.get("cn1.messaging.kafka.auto-offset-reset"); + if (reset == null && config != null) { + reset = config.get("spring.kafka.consumer.auto-offset-reset"); + } + return new KafkaTransport(url, retry, + config == null ? 5000 : config.getInt("cn1.messaging.timeout", 5000), + reset == null || !"latest".equalsIgnoreCase(reset.trim())); + } + } + + /// A transport to the brokers `url` lists (`kafka://host:port,...`, or + /// `kafkas://` for TLS); it connects on first use. + public KafkaTransport(String url, RetryPolicy retry, int timeoutMillis, boolean earliest) + throws IOException { + super(retry); + int scheme = url.indexOf("://"); + String s = scheme < 0 ? "" : url.substring(0, scheme); + if (!"kafka".equalsIgnoreCase(s) && !"kafkas".equalsIgnoreCase(s)) { + throw new IOException("Not a Kafka URL: " + url); + } + this.tls = "kafkas".equalsIgnoreCase(s); + String rest = url.substring(scheme + 3); + int slash = rest.indexOf('/'); + if (slash >= 0) { + rest = rest.substring(0, slash); + } + bootstrap = new ArrayList(); + int from = 0; + while (from <= rest.length()) { + int comma = rest.indexOf(',', from); + int end = comma < 0 ? rest.length() : comma; + String h = rest.substring(from, end).trim(); + from = end + 1; + if (h.length() == 0) { + continue; + } + int colon = h.lastIndexOf(':'); + bootstrap.add(colon > 0 ? new String[] {h.substring(0, colon), h.substring(colon + 1)} + : new String[] {h, "9092"}); + } + if (bootstrap.isEmpty()) { + throw new IOException("The Kafka URL names no broker: " + url); + } + this.timeout = timeoutMillis; + this.earliest = earliest; + } + + @Override + public String getName() { + return "kafka"; + } + + // ------------------------------------------------------------- metadata + + private KafkaConnection connect(String host, String port) throws IOException { + try { + return KafkaConnection.open(host, Integer.parseInt(port), tls, timeout); + } catch (NumberFormatException err) { + throw new IOException("Not a port: " + port, err); + } + } + + /// A connection for metadata, to any broker that answers. + private KafkaConnection control() throws IOException { + if (control != null) { + return control; + } + IOException last = null; + List candidates = new ArrayList(brokers.values()); + candidates.addAll(bootstrap); + for (String[] b : candidates) { + try { + control = connect(b[0], b[1]); + return control; + } catch (IOException err) { + last = err; + } + } + throw last == null ? new IOException("No Kafka broker answered") : last; + } + + /// The leader of each partition of `topic`, asking the cluster when it is + /// not known or `refresh` says the answer went stale. Waits while the + /// broker creates the topic. + int[] leaders(String topic, boolean refresh) throws IOException { + synchronized (metaLock) { + int[] known = leaders.get(topic); + if (known != null && !refresh) { + return known; + } + long until = System.currentTimeMillis() + Math.max(timeout, 10000); + String problem = null; + while (System.currentTimeMillis() < until) { + KafkaConnection.In in; + try { + KafkaConnection.Out req = new KafkaConnection.Out(); + req.int32(1).string(topic).bool(true); + in = control().call(KafkaConnection.METADATA, 4, req); + } catch (IOException err) { + dropControl(); + problem = err.getMessage(); + pause(200); + continue; + } + in.int32(); + int nb = in.int32(); + for (int i = 0; i < nb; i++) { + int node = in.int32(); + String host = in.string(); + int port = in.int32(); + in.string(); + brokers.put(Integer.valueOf(node), new String[] {host, Integer.toString(port)}); + } + in.string(); + in.int32(); + int nt = in.int32(); + int[] found = null; + for (int t = 0; t < nt; t++) { + int error = in.int16(); + String name = in.string(); + in.bool(); + int np = in.int32(); + int[] parts = new int[np]; + boolean ready = error == 0 && np > 0; + for (int p = 0; p < np; p++) { + in.int16(); + int index = in.int32(); + int leader = in.int32(); + skipInts(in); + skipInts(in); + if (index >= 0 && index < np) { + parts[index] = leader; + } + if (leader < 0) { + ready = false; + } + } + if (topic.equals(name)) { + if (ready) { + found = parts; + } else { + problem = KafkaConnection.error(error); + } + } + } + if (found != null) { + leaders.put(topic, found); + return found; + } + // A topic being created has no leader for a moment. + pause(100); + } + throw new IOException("Kafka has no partitions for " + topic + + (problem == null ? "" : ": " + problem)); + } + } + + private static void skipInts(KafkaConnection.In in) throws IOException { + int n = in.int32(); + for (int i = 0; i < n; i++) { + in.int32(); + } + } + + private void dropControl() { + if (control != null) { + control.close(); + control = null; + } + } + + String[] broker(int node) throws IOException { + synchronized (metaLock) { + String[] b = brokers.get(Integer.valueOf(node)); + if (b == null) { + throw new IOException("Kafka named an unknown broker " + node); + } + return b; + } + } + + static void pause(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + } + } + + // ------------------------------------------------------------- sending + + @Override + public void send(Message message) throws IOException { + produce(message.getDestination(), message); + } + + private void produce(String topic, Message message) throws IOException { + if (closed.get()) { + throw new IOException("The kafka transport is closed"); + } + Map headers = new LinkedHashMap(); + for (Map.Entry h : message.getHeaders().entrySet()) { + if (h.getValue() != null) { + headers.put(h.getKey(), WireBuffer.utf8(h.getValue())); + } + } + byte[] key = message.getKey() == null ? null : WireBuffer.utf8(message.getKey()); + byte[] batch = KafkaConnection.batch(key, message.getPayload(), headers, + System.currentTimeMillis()); + IOException last = null; + for (int attempt = 0; attempt < 3; attempt++) { + int[] parts = leaders(topic, attempt > 0); + int partition = key != null ? KafkaConnection.partition(key, parts.length) + : (next.getAndIncrement() & 0x7fffffff) % parts.length; + KafkaConnection c; + try { + c = producer(parts[partition]); + } catch (IOException err) { + last = err; + continue; + } + KafkaConnection.Out req = new KafkaConnection.Out(); + req.string(null).int16(-1).int32(timeout); + req.int32(1).string(topic).int32(1).int32(partition).bytes(batch); + int error; + try { + KafkaConnection.In in = c.call(KafkaConnection.PRODUCE, 3, req); + in.int32(); + in.string(); + in.int32(); + in.int32(); + error = in.int16(); + } catch (IOException err) { + dropProducer(parts[partition], c); + last = err; + continue; + } + if (error == 0) { + return; + } + last = new IOException("Kafka refused a record for " + topic + ": " + + KafkaConnection.error(error)); + if (error != 3 && error != 5 && error != 6 && error != 7) { + break; + } + } + throw last; + } + + private KafkaConnection producer(int node) throws IOException { + synchronized (producers) { + KafkaConnection c = producers.get(Integer.valueOf(node)); + if (c == null) { + String[] b = broker(node); + c = connect(b[0], b[1]); + producers.put(Integer.valueOf(node), c); + } + return c; + } + } + + private void dropProducer(int node, KafkaConnection c) { + synchronized (producers) { + if (producers.get(Integer.valueOf(node)) == c) { // NOPMD CompareObjectsWithEquals - identity + producers.remove(Integer.valueOf(node)); + } + } + c.close(); + } + + @Override + protected void redeliver(String destination, String group, Delivery delivery, int attempt, + long notBefore) throws IOException { + produce(retryTopic(destination, group), + delivery.message.withHeader(Message.ATTEMPT, Integer.toString(attempt)) + .withHeader(Message.NOT_BEFORE, Long.toString(notBefore))); + ack(destination, group, delivery); + } + + /// The topic a group's failed records wait in. Kafka allows letters, + /// digits, `.`, `_` and `-` in a name; anything else in the group becomes + /// `_`. + static String retryTopic(String destination, String group) { + StringBuilder sb = new StringBuilder(destination).append(".retry."); + for (int i = 0; i < group.length(); i++) { + char c = group.charAt(i); + boolean ok = c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' + || c == '.' || c == '_' || c == '-'; + sb.append(ok ? c : '_'); + } + return sb.toString(); + } + + // ----------------------------------------------------------- receiving + + private static String key(String destination, String group) { + return destination + "\n" + group; + } + + @Override + public Subscription subscribe(String destination, String group, int concurrency, + MessageHandler handler) throws IOException { + final String g = group == null ? "default" : group; + final String k = key(destination, g); + final Group state; + synchronized (groups) { + Group existing = groups.get(k); + state = existing == null ? new Group(destination, g) : existing; + state.users++; + if (existing == null) { + groups.put(k, state); + state.start(); + } + } + final Subscription inner; + try { + inner = super.subscribe(destination, g, concurrency, handler); + } catch (IOException err) { + release(k, state); + throw err; + } + return new Subscription() { + private final AtomicBoolean done = new AtomicBoolean(); + + @Override + public void close() { + if (!done.getAndSet(true)) { + inner.close(); + release(k, state); + } + } + }; + } + + private void release(String k, Group state) { + boolean last; + synchronized (groups) { + state.users--; + last = state.users <= 0; + if (last) { + groups.remove(k); + } + } + if (last) { + state.stop(); + } + } + + @Override + protected List poll(String destination, String group, int max, long waitMillis) + throws IOException { + Group g; + synchronized (groups) { + g = groups.get(key(destination, group)); + } + if (g == null) { + throw new IOException("Not subscribed to " + destination); + } + return g.take(waitMillis); + } + + @Override + protected void ack(String destination, String group, Delivery delivery) { + Tag tag = (Tag) delivery.handle; + tag.group.acked(tag); + } + + /// A delivered record: its partition's state, as it was when delivered. + private static final class Tag { + final Group group; + final Part part; + final long offset; + + Tag(Group group, Part part, long offset) { + this.group = group; + this.part = part; + this.offset = offset; + } + } + + /// One assigned partition. + private static final class Part { + final String topic; + final int partition; + final boolean retry; + long fetchOffset; + long commitOffset = -1; + long committed = -1; + boolean inFlight; + final LinkedList buffer = new LinkedList(); + + Part(String topic, int partition, boolean retry) { + this.topic = topic; + this.partition = partition; + this.retry = retry; + } + + String name() { + return topic + "-" + partition; + } + } + + /// One consumer group membership: the thread that keeps it, fetches and + /// commits, and the records it fetched for the consumer threads. + private final class Group implements Runnable { + final String topic; + final String group; + final String retry; + int users; + private final AtomicBoolean running = new AtomicBoolean(true); + private Thread thread; + private KafkaConnection coordinator; + private final Map fetchers = new HashMap(); + private String memberId = ""; + private int generation = -1; + private boolean rejoin = true; + /// Guarded by this group's monitor. + private Map parts = new LinkedHashMap(); + private int turn; + /// Set with the monitor held, so a waiter sees why it was woken. + private boolean stopped; + private long lastHeartbeat; + private long lastCommit; + + Group(String topic, String group) { + this.topic = topic; + this.group = group; + this.retry = retryTopic(topic, group); + } + + void start() { + thread = new Thread(this, "cn1-kafka-" + topic + "-" + group); + thread.setDaemon(true); + thread.start(); + } + + void stop() { + synchronized (this) { + running.set(false); + stopped = true; + notifyAll(); + } + if (thread != null) { + try { + thread.join(5000); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + } + } + } + + @Override + public void run() { + boolean failing = false; + while (running.get()) { + try { + if (rejoin) { + join(); + } + long now = System.currentTimeMillis(); + if (now - lastHeartbeat >= HEARTBEAT_EVERY) { + heartbeat(); + } + if (now - lastCommit >= COMMIT_EVERY) { + commit(); + } + if (!rejoin) { + fetch(); + } + if (failing) { + System.err.println("cn1: the Kafka consumer of " + topic + " for " + + group + " works again"); + failing = false; + } + } catch (IOException err) { + if (!running.get()) { + break; + } + if (!failing) { + System.err.println("cn1: the Kafka consumer of " + topic + " for " + group + + " failed, retrying: " + err.getMessage()); + failing = true; + } + dropConnections(); + rejoin = true; + pause(1000); + } + } + try { + commit(); + leave(); + } catch (IOException err) { + // The broker ends the membership when the session times out. + } + dropConnections(); + } + + private void dropConnections() { + if (coordinator != null) { + coordinator.close(); + coordinator = null; + } + for (KafkaConnection c : fetchers.values()) { + c.close(); + } + fetchers.clear(); + } + + private KafkaConnection coordinator() throws IOException { + if (coordinator != null) { + return coordinator; + } + long until = System.currentTimeMillis() + Math.max(timeout, 10000); + while (true) { + KafkaConnection.Out req = new KafkaConnection.Out(); + req.string(group).int8(0); + KafkaConnection.In in; + synchronized (metaLock) { + try { + in = control().call(KafkaConnection.FIND_COORDINATOR, 1, req); + } catch (IOException err) { + dropControl(); + throw err; + } + } + in.int32(); + int error = in.int16(); + in.string(); + in.int32(); + String host = in.string(); + int port = in.int32(); + if (error == 0) { + coordinator = connect(host, Integer.toString(port)); + return coordinator; + } + if (error != 14 && error != 15 || System.currentTimeMillis() > until) { + throw new IOException("Kafka found no coordinator for group " + group + ": " + + KafkaConnection.error(error)); + } + pause(200); + } + } + + /// Joins the group, assigns the partitions when this member leads it, + /// and takes up the partitions it was given. + private void join() throws IOException { + commit(); + while (running.get() && rejoin) { + KafkaConnection.Out subscription = new KafkaConnection.Out(); + subscription.int16(0).int32(2).string(topic).string(retry).bytes((byte[]) null); + KafkaConnection.Out req = new KafkaConnection.Out(); + req.string(group).int32(SESSION_TIMEOUT).int32(REBALANCE_TIMEOUT) + .string(memberId).string("consumer").int32(1).string("range") + .bytes(subscription); + KafkaConnection.In in = callCoordinator(KafkaConnection.JOIN_GROUP, 2, req); + in.int32(); + int error = in.int16(); + int gen = in.int32(); + in.string(); + String leader = in.string(); + String me = in.string(); + if (error == 25) { + memberId = ""; + continue; + } + if (error == 27 || error == 79) { + continue; + } + if (lostCoordinator(error)) { + continue; + } + if (error != 0) { + throw new IOException("Kafka refused to let this server join group " + group + + ": " + KafkaConnection.error(error)); + } + memberId = me; + Map> members = new HashMap>(); + int n = in.int32(); + for (int i = 0; i < n; i++) { + String member = in.string(); + members.put(member, subscription(in.bytes())); + } + KafkaConnection.Out sync = new KafkaConnection.Out(); + sync.string(group).int32(gen).string(memberId); + if (me.equals(leader)) { + assign(sync, members); + } else { + sync.int32(0); + } + KafkaConnection.In synced = callCoordinator(KafkaConnection.SYNC_GROUP, 1, sync); + synced.int32(); + error = synced.int16(); + byte[] assignment = synced.bytes(); + if (error == 27) { + continue; + } + if (error == 25) { + memberId = ""; + continue; + } + if (lostCoordinator(error)) { + continue; + } + if (error != 0) { + throw new IOException("Kafka refused the assignment of group " + group + ": " + + KafkaConnection.error(error)); + } + generation = gen; + takeUp(assignment); + rejoin = false; + lastHeartbeat = System.currentTimeMillis(); + } + } + + /// True when `error` says the coordinator moved or is still loading: + /// forgets it, so the next call looks it up again. + private boolean lostCoordinator(int error) { + if (error != 14 && error != 15 && error != 16) { + return false; + } + if (coordinator != null) { + coordinator.close(); + coordinator = null; + } + pause(200); + return true; + } + + /// The topics a member's subscription metadata names. + private List subscription(byte[] metadata) throws IOException { + List topics = new ArrayList(); + if (metadata == null || metadata.length < 6) { + return topics; + } + KafkaConnection.In in = new KafkaConnection.In(metadata); + in.int16(); + int n = in.int32(); + for (int i = 0; i < n; i++) { + topics.add(in.string()); + } + return topics; + } + + /// Range assignment, as Kafka's own assignor does it: topic by topic, + /// over the members that subscribe to that topic, in name order. A + /// group id may be shared by listeners of different topics, in this + /// server or another. + private void assign(KafkaConnection.Out sync, Map> members) + throws IOException { + List names = new ArrayList(members.keySet()); + Collections.sort(names); + List topics = new ArrayList(); + for (String member : names) { + for (String t : members.get(member)) { + if (!topics.contains(t)) { + topics.add(t); + } + } + } + Map>> given = + new HashMap>>(); + for (String member : names) { + given.put(member, new LinkedHashMap>()); + } + for (String t : topics) { + List takers = new ArrayList(); + for (String member : names) { + if (members.get(member).contains(t)) { + takers.add(member); + } + } + int partitions = leaders(t, true).length; + int m = takers.size(); + int each = partitions / m; + int extra = partitions % m; + for (int i = 0; i < m; i++) { + int start = i * each + Math.min(i, extra); + int length = each + (i < extra ? 1 : 0); + List list = new ArrayList(); + for (int p = 0; p < length; p++) { + list.add(Integer.valueOf(start + p)); + } + given.get(takers.get(i)).put(t, list); + } + } + sync.int32(names.size()); + for (String member : names) { + KafkaConnection.Out a = new KafkaConnection.Out(); + Map> plan = given.get(member); + a.int16(0).int32(plan.size()); + for (Map.Entry> e : plan.entrySet()) { + a.string(e.getKey()).int32(e.getValue().size()); + for (Integer p : e.getValue()) { + a.int32(p.intValue()); + } + } + a.bytes((byte[]) null); + sync.string(member).bytes(a); + } + } + + /// Keeps the partitions this member still holds as they are, and + /// starts each new one at its committed offset. + private void takeUp(byte[] assignment) throws IOException { + Map taken = new LinkedHashMap(); + if (assignment != null && assignment.length > 0) { + KafkaConnection.In in = new KafkaConnection.In(assignment); + in.int16(); + int nt = in.int32(); + for (int t = 0; t < nt; t++) { + String name = in.string(); + int np = in.int32(); + for (int p = 0; p < np; p++) { + int index = in.int32(); + Part part = new Part(name, index, !topic.equals(name)); + taken.put(part.name(), part); + } + } + } + List fresh = new ArrayList(); + synchronized (this) { + for (Map.Entry e : taken.entrySet()) { + Part kept = parts.get(e.getKey()); + if (kept != null) { + e.setValue(kept); + } else { + fresh.add(e.getValue()); + } + } + } + offsets(fresh); + synchronized (this) { + parts = taken; + notifyAll(); + } + } + + /// The committed offset of each of `fresh`, or where the reset policy + /// starts a partition the group never committed. + private void offsets(List fresh) throws IOException { + if (fresh.isEmpty()) { + return; + } + Map> byTopic = byTopic(fresh); + KafkaConnection.Out req = new KafkaConnection.Out(); + req.string(group).int32(byTopic.size()); + for (Map.Entry> e : byTopic.entrySet()) { + req.string(e.getKey()).int32(e.getValue().size()); + for (Part p : e.getValue()) { + req.int32(p.partition); + } + } + KafkaConnection.In in = callCoordinator(KafkaConnection.OFFSET_FETCH, 1, req); + List unset = new ArrayList(); + int nt = in.int32(); + for (int t = 0; t < nt; t++) { + String name = in.string(); + int np = in.int32(); + for (int i = 0; i < np; i++) { + int index = in.int32(); + long offset = in.int64(); + in.string(); + in.int16(); + for (Part p : fresh) { + if (p.topic.equals(name) && p.partition == index) { + if (offset >= 0) { + p.fetchOffset = offset; + p.committed = offset; + p.commitOffset = offset; + } else { + unset.add(p); + } + } + } + } + } + for (Part p : unset) { + reset(p); + } + } + + /// Starts `p` at the earliest or the latest offset. + private void reset(Part p) throws IOException { + int leader = leaders(p.topic, false)[p.partition]; + KafkaConnection.Out req = new KafkaConnection.Out(); + req.int32(-1).int32(1).string(p.topic).int32(1).int32(p.partition) + .int64(earliest || p.retry ? -2L : -1L); + KafkaConnection.In in = fetcher(leader).call(KafkaConnection.LIST_OFFSETS, 1, req); + in.int32(); + in.string(); + in.int32(); + in.int32(); + int error = in.int16(); + in.int64(); + long offset = in.int64(); + if (error != 0) { + leaders(p.topic, true); + throw new IOException("Kafka has no offset for " + p.name() + ": " + + KafkaConnection.error(error)); + } + p.fetchOffset = offset; + } + + private KafkaConnection.In callCoordinator(int api, int version, KafkaConnection.Out req) + throws IOException { + try { + return coordinator().call(api, version, req); + } catch (IOException err) { + if (coordinator != null) { + coordinator.close(); + coordinator = null; + } + throw err; + } + } + + private void heartbeat() throws IOException { + KafkaConnection.Out req = new KafkaConnection.Out(); + req.string(group).int32(generation).string(memberId); + KafkaConnection.In in = callCoordinator(KafkaConnection.HEARTBEAT, 1, req); + in.int32(); + int error = in.int16(); + lastHeartbeat = System.currentTimeMillis(); + if (error == 27 || error == 22 || error == 25) { + if (error == 25) { + memberId = ""; + } + rejoin = true; + } else if (error == 16 || error == 15) { + coordinator.close(); + coordinator = null; + } else if (error != 0) { + throw new IOException("Kafka refused the heartbeat of group " + group + ": " + + KafkaConnection.error(error)); + } + } + + /// Commits the offset past every record handled since the last commit. + private void commit() throws IOException { + lastCommit = System.currentTimeMillis(); + List dirty = new ArrayList(); + List at = new ArrayList(); + synchronized (this) { + for (Part p : parts.values()) { + if (p.commitOffset > p.committed) { + dirty.add(p); + at.add(Long.valueOf(p.commitOffset)); + } + } + } + if (dirty.isEmpty() || generation < 0) { + return; + } + Map> byTopic = byTopic(dirty); + KafkaConnection.Out req = new KafkaConnection.Out(); + req.string(group).int32(generation).string(memberId).int64(-1) + .int32(byTopic.size()); + for (Map.Entry> e : byTopic.entrySet()) { + req.string(e.getKey()).int32(e.getValue().size()); + for (Part p : e.getValue()) { + req.int32(p.partition).int64(at.get(dirty.indexOf(p)).longValue()) + .string(null); + } + } + KafkaConnection.In in = callCoordinator(KafkaConnection.OFFSET_COMMIT, 2, req); + int nt = in.int32(); + for (int t = 0; t < nt; t++) { + String name = in.string(); + int np = in.int32(); + for (int i = 0; i < np; i++) { + int index = in.int32(); + int error = in.int16(); + for (int d = 0; d < dirty.size(); d++) { + Part p = dirty.get(d); + if (p.topic.equals(name) && p.partition == index) { + if (error == 0) { + synchronized (this) { + p.committed = Math.max(p.committed, at.get(d).longValue()); + } + } else if (error == 27 || error == 22 || error == 25) { + rejoin = true; + } + } + } + } + } + } + + private void leave() throws IOException { + if (memberId.length() == 0 || coordinator == null) { + return; + } + KafkaConnection.Out req = new KafkaConnection.Out(); + req.string(group).string(memberId); + callCoordinator(KafkaConnection.LEAVE_GROUP, 1, req); + } + + private KafkaConnection fetcher(int node) throws IOException { + KafkaConnection c = fetchers.get(Integer.valueOf(node)); + if (c == null) { + String[] b = broker(node); + c = connect(b[0], b[1]); + fetchers.put(Integer.valueOf(node), c); + } + return c; + } + + /// Fetches for every partition whose records have all been handed out, + /// one request per leader; waits up to half a second when nothing new + /// is there. + private void fetch() throws IOException { + Map> byLeader = new LinkedHashMap>(); + synchronized (this) { + for (Part p : parts.values()) { + if (p.buffer.isEmpty()) { + int leader = leaders(p.topic, false)[p.partition]; + List list = byLeader.get(Integer.valueOf(leader)); + if (list == null) { + list = new ArrayList(); + byLeader.put(Integer.valueOf(leader), list); + } + list.add(p); + } + } + } + if (byLeader.isEmpty()) { + long until = System.currentTimeMillis() + FETCH_WAIT; + synchronized (this) { + long left = FETCH_WAIT; + while (!stopped && !drained() && left > 0) { + try { + wait(left); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + return; + } + left = until - System.currentTimeMillis(); + } + } + return; + } + int wait = byLeader.size() > 1 ? FETCH_WAIT / byLeader.size() : FETCH_WAIT; + for (Map.Entry> e : byLeader.entrySet()) { + fetchFrom(e.getKey().intValue(), e.getValue(), wait); + } + } + + /// True when some partition has handed out every record it fetched. + /// Called with the monitor held. + private boolean drained() { + for (Part p : parts.values()) { + if (p.buffer.isEmpty()) { + return true; + } + } + return false; + } + + private void fetchFrom(int leader, List list, int wait) throws IOException { + Map> byTopic = byTopic(list); + KafkaConnection.Out req = new KafkaConnection.Out(); + req.int32(-1).int32(wait).int32(1).int32(4 * 1024 * 1024).int8(0) + .int32(byTopic.size()); + for (Map.Entry> e : byTopic.entrySet()) { + req.string(e.getKey()).int32(e.getValue().size()); + for (Part p : e.getValue()) { + req.int32(p.partition).int64(p.fetchOffset).int32(1024 * 1024); + } + } + KafkaConnection.In in; + try { + in = fetcher(leader).call(KafkaConnection.FETCH, 4, req); + } catch (IOException err) { + KafkaConnection c = fetchers.remove(Integer.valueOf(leader)); + if (c != null) { + c.close(); + } + throw err; + } + in.int32(); + int nt = in.int32(); + for (int t = 0; t < nt; t++) { + String name = in.string(); + int np = in.int32(); + for (int i = 0; i < np; i++) { + int index = in.int32(); + int error = in.int16(); + in.int64(); + in.int64(); + int aborted = in.int32(); + for (int a = 0; a < aborted; a++) { + in.int64(); + in.int64(); + } + byte[] set = in.bytes(); + Part p = find(list, name, index); + if (p == null) { + continue; + } + if (error == 1) { + reset(p); + continue; + } + if (error == 3 || error == 5 || error == 6) { + leaders(p.topic, true); + continue; + } + if (error != 0) { + throw new IOException("Kafka refused a fetch of " + p.name() + ": " + + KafkaConnection.error(error)); + } + if (set == null || set.length == 0) { + continue; + } + List records = new ArrayList(); + KafkaConnection.records(set, p.fetchOffset, records); + if (records.isEmpty()) { + continue; + } + synchronized (this) { + if (parts.get(p.name()) == p) { // NOPMD CompareObjectsWithEquals - still this member's + p.buffer.addAll(records); + p.fetchOffset = records.get(records.size() - 1).offset + 1; + notifyAll(); + } + } + } + } + } + + private Part find(List list, String name, int index) { + for (Part p : list) { + if (p.topic.equals(name) && p.partition == index) { + return p; + } + } + return null; + } + + /// The next record of a partition with none being handled, or none + /// after `waitMillis`. A retry partition whose first record is not due + /// yet is passed over until it is. + List take(long waitMillis) { + long until = System.currentTimeMillis() + waitMillis; + synchronized (this) { + while (running.get()) { + long now = System.currentTimeMillis(); + long due = until; + List all = new ArrayList(parts.values()); + for (int i = 0; i < all.size(); i++) { + Part p = all.get((turn + i) % all.size()); + if (p.inFlight || p.buffer.isEmpty()) { + continue; + } + KafkaConnection.Record r = p.buffer.getFirst(); + Message m = message(p, r); + if (p.retry && notYet(m, now)) { + due = Math.min(due, Long.parseLong(m.getHeader(Message.NOT_BEFORE))); + continue; + } + p.buffer.removeFirst(); + p.inFlight = true; + turn = (turn + i + 1) % all.size(); + if (p.buffer.isEmpty()) { + notifyAll(); + } + List out = new ArrayList(1); + out.add(new Delivery(m, new Tag(this, p, r.offset))); + return out; // NOPMD AvoidBranchingStatementAsLastInLoop - the first ready partition wins + } + long left = due - now; + if (now >= until || left <= 0 && due == until) { + break; + } + try { + wait(Math.max(1, left)); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + break; + } + } + return new ArrayList(0); + } + } + + private Message message(Part p, KafkaConnection.Record r) { + Map headers = new LinkedHashMap(); + for (Map.Entry h : r.headers.entrySet()) { + headers.put(h.getKey(), h.getValue() == null ? null + : WireBuffer.fromUtf8(h.getValue())); + } + return identified(new Message(topic, r.key == null ? null : WireBuffer.fromUtf8(r.key), + r.value == null ? new byte[0] : r.value, headers), p.name() + "@" + r.offset); + } + + /// The handler of `tag` returned (or its record was rescheduled): the + /// offset may move past it, and its partition is free again. + synchronized void acked(Tag tag) { + if (parts.get(tag.part.name()) != tag.part) { // NOPMD CompareObjectsWithEquals - still this member's + // Given up in a rebalance; its next owner handles it again. + return; + } + tag.part.commitOffset = Math.max(tag.part.commitOffset, tag.offset + 1); + tag.part.inFlight = false; + notifyAll(); + } + } + + private static Map> byTopic(List list) { + Map> out = new LinkedHashMap>(); + for (Part p : list) { + List l = out.get(p.topic); + if (l == null) { + l = new ArrayList(); + out.put(p.topic, l); + } + l.add(p); + } + return out; + } + + @Override + public boolean isHealthy() { + return !closed.get(); + } + + @Override + public void close() { + if (closed.getAndSet(true)) { + return; + } + stopSubscriptions(); + List all; + synchronized (groups) { + all = new ArrayList(groups.values()); + groups.clear(); + } + for (Group g : all) { + g.stop(); + } + synchronized (producers) { + for (KafkaConnection c : producers.values()) { + c.close(); + } + producers.clear(); + } + synchronized (metaLock) { + dropControl(); + } + } +} diff --git a/vm/backend/src/com/codename1/backend/messaging/kafka/package-info.java b/vm/backend/src/com/codename1/backend/messaging/kafka/package-info.java new file mode 100644 index 00000000000..1256f9c9c9e --- /dev/null +++ b/vm/backend/src/com/codename1/backend/messaging/kafka/package-info.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +/// The Apache Kafka transport. Compiled into an application only when its +/// configuration names a `kafka://` or `kafkas://` messaging URL, or Spring +/// Boot's `spring.kafka.bootstrap-servers`. +package com.codename1.backend.messaging.kafka; From b6a460cf6ab275204a1830ab6afce0e8f5919cf3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:43:11 +0200 Subject: [PATCH 05/11] Backend: Amazon SQS and Google Cloud Pub/Sub messaging transports Both over HTTP with no vendor SDK: SQS through the backend's SigV4 signer, with retries as visibility changes counted by SQS's receive count; Pub/Sub through a service-account key or the metadata server (or the emulator), one subscription per group, retries as ack-deadline changes. Co-Authored-By: Claude Opus 5.5 --- .../maven/processors/BackendBeans.java | 2 + .../maven/processors/BackendBeansTest.java | 8 + .../demo/msgcheck/com/demo/MsgCheck.java | 10 + .../backend/messaging/Messaging.java | 2 +- .../backend/messaging/package-info.java | 4 +- .../messaging/pubsub/PubSubTransport.java | 420 ++++++++++++++++++ .../messaging/pubsub/package-info.java | 26 ++ .../backend/messaging/sqs/SqsTransport.java | 356 +++++++++++++++ .../backend/messaging/sqs/package-info.java | 26 ++ 9 files changed, 851 insertions(+), 3 deletions(-) create mode 100644 vm/backend/src/com/codename1/backend/messaging/pubsub/PubSubTransport.java create mode 100644 vm/backend/src/com/codename1/backend/messaging/pubsub/package-info.java create mode 100644 vm/backend/src/com/codename1/backend/messaging/sqs/SqsTransport.java create mode 100644 vm/backend/src/com/codename1/backend/messaging/sqs/package-info.java diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java index dbc6612a4fe..4afac589341 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java @@ -1925,6 +1925,8 @@ static List messagingTransports(ProcessorContext ctx) { "com.codename1.backend.messaging.redis.RedisStreamTransport.Factory"); TRANSPORTS.put("amqp", "com.codename1.backend.messaging.amqp.AmqpTransport.Factory"); TRANSPORTS.put("kafka", "com.codename1.backend.messaging.kafka.KafkaTransport.Factory"); + TRANSPORTS.put("sqs", "com.codename1.backend.messaging.sqs.SqsTransport.Factory"); + TRANSPORTS.put("pubsub", "com.codename1.backend.messaging.pubsub.PubSubTransport.Factory"); } /// The listener annotation on `m`, under any of its names, or null. diff --git a/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java index 69fa9e3e682..3d7df631f33 100644 --- a/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java @@ -1713,6 +1713,14 @@ public void messageListenersReceiveRetryAndDeadLetter() throws Exception { if (kafka != null && kafka.length() > 0) { runs.add(new String[] {kafka, new File(dir, "e.db").getAbsolutePath()}); } + String sqs = System.getenv("CN1_TEST_SQS"); + if (sqs != null && sqs.length() > 0) { + runs.add(new String[] {sqs, new File(dir, "f.db").getAbsolutePath()}); + } + String pubsub = System.getenv("CN1_TEST_PUBSUB"); + if (pubsub != null && pubsub.length() > 0) { + runs.add(new String[] {pubsub, new File(dir, "g.db").getAbsolutePath()}); + } runs.add(new String[] {"memory:", new File(dir, "a.db").getAbsolutePath()}); runs.add(new String[] {"db:", new File(dir, "b.db").getAbsolutePath()}); String external = System.getenv("CN1_TEST_DB_URL"); diff --git a/vm/backend/demo/msgcheck/com/demo/MsgCheck.java b/vm/backend/demo/msgcheck/com/demo/MsgCheck.java index b6de5e4d265..18732e55048 100644 --- a/vm/backend/demo/msgcheck/com/demo/MsgCheck.java +++ b/vm/backend/demo/msgcheck/com/demo/MsgCheck.java @@ -30,6 +30,8 @@ import com.codename1.backend.messaging.Messaging; import com.codename1.backend.messaging.amqp.AmqpTransport; import com.codename1.backend.messaging.kafka.KafkaTransport; +import com.codename1.backend.messaging.pubsub.PubSubTransport; +import com.codename1.backend.messaging.sqs.SqsTransport; import com.codename1.backend.messaging.redis.RedisStreamTransport; import com.codename1.backend.messaging.Subscription; import java.util.ArrayList; @@ -55,6 +57,8 @@ public static void main(String[] args) throws Exception { Messaging.register("redis", new RedisStreamTransport.Factory()); Messaging.register("amqp", new AmqpTransport.Factory()); Messaging.register("kafka", new KafkaTransport.Factory()); + Messaging.register("sqs", new SqsTransport.Factory()); + Messaging.register("pubsub", new PubSubTransport.Factory()); run("memory:", null); String file = "msgcheck-" + Long.toString(System.nanoTime(), 36) + ".db"; run("db:", file); @@ -92,6 +96,12 @@ private static void run(String url, String database) throws Exception { } catch (java.io.IOException noTableYet) { // created by the first send } + if (t instanceof PubSubTransport) { + // Pub/Sub keeps a message only for subscriptions that already exist. + for (String s : new String[] {"o", "h", "b", "c", "r", "d", "x"}) { + ((PubSubTransport) t).declare(q + s, "g"); + } + } try { order(w, t, q + "o"); headers(w, t, q + "h"); diff --git a/vm/backend/src/com/codename1/backend/messaging/Messaging.java b/vm/backend/src/com/codename1/backend/messaging/Messaging.java index a2f96958f0b..8dce50e690e 100644 --- a/vm/backend/src/com/codename1/backend/messaging/Messaging.java +++ b/vm/backend/src/com/codename1/backend/messaging/Messaging.java @@ -34,7 +34,7 @@ /// /// | Setting | Default | | /// |---|---|---| -/// | `cn1.messaging.url` | see below | `memory:`, `db:`, `redis://...`, `amqp://...`, `kafka://...` | +/// | `cn1.messaging.url` | see below | `memory:`, `db:`, `redis://...`, `amqp://...`, `kafka://...`, `sqs://...`, `pubsub://...` | /// | `cn1.messaging.lease` | 60000 | milliseconds before a message a consumer took and never settled is delivered again | /// | `cn1.messaging.timeout` | 5000 | milliseconds a broker connection or reply may take | /// | `cn1.messaging.db.poll` | 500 | milliseconds between polls of an empty `db:` queue | diff --git a/vm/backend/src/com/codename1/backend/messaging/package-info.java b/vm/backend/src/com/codename1/backend/messaging/package-info.java index b358751ff00..c8d3bac739e 100644 --- a/vm/backend/src/com/codename1/backend/messaging/package-info.java +++ b/vm/backend/src/com/codename1/backend/messaging/package-info.java @@ -22,8 +22,8 @@ */ /// Messaging: sending through [com.codename1.backend.messaging.MessageTemplate] /// and receiving in `@MessageListener` methods, over a queue in the -/// application's own database, Redis Streams, RabbitMQ or Kafka -- whichever -/// [com.codename1.backend.messaging.Messaging] is configured with. +/// application's own database, Redis Streams, RabbitMQ, Kafka, Amazon SQS or +/// Google Cloud Pub/Sub -- whichever [com.codename1.backend.messaging.Messaging] is configured with. /// /// Listeners are found and wired at build time: no reflection, no proxies, and /// a protocol the configuration does not name is not in the application. diff --git a/vm/backend/src/com/codename1/backend/messaging/pubsub/PubSubTransport.java b/vm/backend/src/com/codename1/backend/messaging/pubsub/PubSubTransport.java new file mode 100644 index 00000000000..bc5efce6349 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/messaging/pubsub/PubSubTransport.java @@ -0,0 +1,420 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.messaging.pubsub; + +import com.codename1.backend.Base64; +import com.codename1.backend.Config; +import com.codename1.backend.Json; +import com.codename1.backend.Web; +import com.codename1.backend.messaging.Message; +import com.codename1.backend.messaging.MessageTransport; +import com.codename1.backend.messaging.Messaging; +import com.codename1.backend.messaging.PollingTransport; +import com.codename1.backend.messaging.RetryPolicy; +import com.codename1.backend.security.oauth2.client.credentials.AccessToken; +import com.codename1.backend.security.oauth2.client.credentials.AccessTokenGrant; +import com.codename1.backend.security.oauth2.client.credentials.AccessTokenProvider; +import com.codename1.backend.security.oauth2.client.credentials.GoogleServiceAccountCredentials; +import com.codename1.backend.security.oauth2.client.credentials.TokenAcquisitionException; +import com.codename1.impl.backend.WireBuffer; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +/// Messaging over Google Cloud Pub/Sub's REST API, with no Google library. +/// +/// ```properties +/// cn1.messaging.url=pubsub://my-project +/// ``` +/// +/// A destination is a topic, and a listener's group a subscription to it named +/// `.`; both are created on first use. Every group therefore +/// sees every message, and the listeners of one group compete for them. +/// Pub/Sub keeps a message only for the subscriptions that exist when it is +/// published, so a message sent before its group ever subscribed is not +/// delivered to that group: create the subscriptions at deployment time, or +/// call [#declare(String, String)] before the first send. Pub/Sub allows +/// letters, digits and `-_.~+%` in a name that starts with a letter; any +/// other character becomes `-`, and a name that does not start with a letter +/// gains a `t-`. +/// +/// Credentials, in order: the service-account key file named by +/// `cn1.messaging.pubsub.credentials` or `GOOGLE_APPLICATION_CREDENTIALS`, +/// then the metadata server of the Google Cloud machine the server runs on. +/// `PUBSUB_EMULATOR_HOST` (or `cn1.messaging.pubsub.emulator-host`) sends +/// everything, unauthenticated, to the emulator at that `host:port` instead. +/// +/// A message is handed to one consumer for `cn1.messaging.lease` milliseconds +/// (the subscription's ack deadline, at most ten minutes); one not settled in +/// that time is delivered again. A failed message is not copied, since a +/// copy published to the topic would reach every other group too: its +/// deadline is moved to when the retry is due, at most ten minutes away, and +/// its attempt is counted in this server's memory. After a restart, or when +/// another replica receives the retry, the count starts again, so a message +/// may be tried more often than the retry policy says before it reaches the +/// dead-letter topic. +public final class PubSubTransport extends PollingTransport { + private static final String API = "https://pubsub.googleapis.com/v1/"; + private static final String SCOPE = "https://www.googleapis.com/auth/pubsub"; + private static final String METADATA_TOKEN = "http://metadata.google.internal/computeMetadata" + + "/v1/instance/service-accounts/default/token"; + private static final String KEY = "cn1-key"; + private static final String EMPTY = "cn1-empty"; + private static final int MAX_DEADLINE = 600; + private static final int MAX_TRACKED = 10000; + + private final String project; + private final String base; + private final AccessTokenProvider tokens; + private final int leaseSeconds; + private final Set declared = Collections.synchronizedSet(new HashSet()); + /// Attempts of messages this server put back, by subscription and id. + private final Map attempts = new Attempts(); + private final AtomicBoolean closed = new AtomicBoolean(); + + /// The newest attempts this server counted; the oldest are forgotten. + private static final class Attempts extends LinkedHashMap { + private static final long serialVersionUID = 1L; + + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_TRACKED; + } + } + + /// Registers this transport with [Messaging]; the generated wiring does it. + public static final class Factory implements Messaging.TransportFactory { + @Override + public MessageTransport open(String url, Config config, RetryPolicy retry) + throws IOException { + String emulator = config == null ? null + : config.get("cn1.messaging.pubsub.emulator-host"); + if (emulator == null) { + emulator = System.getenv("PUBSUB_EMULATOR_HOST"); + } + String key = config == null ? null : config.get("cn1.messaging.pubsub.credentials"); + if (key == null) { + key = System.getenv("GOOGLE_APPLICATION_CREDENTIALS"); + } + int lease = config == null ? 60000 : config.getInt("cn1.messaging.lease", 60000); + return new PubSubTransport(url, emulator, key, retry, lease); + } + } + + /// A transport to the project `url` names (`pubsub://project`), through + /// the emulator at `emulatorHost` when it is set, otherwise authenticated + /// with the key file `keyFile` or, when that is null, the metadata server. + public PubSubTransport(String url, String emulatorHost, String keyFile, RetryPolicy retry, + int leaseMillis) throws IOException { + super(retry); + int scheme = url.indexOf("://"); + if (scheme < 0 || !"pubsub".equalsIgnoreCase(url.substring(0, scheme))) { + throw new IOException("Not a Pub/Sub URL: " + url); + } + String rest = url.substring(scheme + 3); + int slash = rest.indexOf('/'); + project = slash < 0 ? rest : rest.substring(0, slash); + if (project.length() == 0) { + throw new IOException("The Pub/Sub URL names no project: " + url); + } + if (emulatorHost != null && emulatorHost.trim().length() > 0) { + base = "http://" + emulatorHost.trim() + "/v1/"; + tokens = null; + } else { + base = API; + AccessTokenGrant grant = keyFile != null && keyFile.trim().length() > 0 + ? GoogleServiceAccountCredentials.fromFile(keyFile.trim()).grant(SCOPE) + : new MetadataServerGrant(); + tokens = new AccessTokenProvider(grant); + } + leaseSeconds = Math.max(10, Math.min(MAX_DEADLINE, leaseMillis / 1000)); + } + + @Override + public String getName() { + return "pubsub"; + } + + /// The default service account of the Google Cloud machine this runs on. + private static final class MetadataServerGrant implements AccessTokenGrant { + @Override + public AccessToken fetch() { + List headers = new ArrayList(1); + headers.add("Metadata-Flavor: Google"); + try { + Web.Result r = Web.request("GET", METADATA_TOKEN, headers, null); + if (!r.isSuccess()) { + throw new TokenAcquisitionException("The metadata server refused a token (" + + r.getStatus() + ")", r.getStatus(), null, true, null); + } + Map reply = Json.parseObject(r.getBodyAsString()); + Object token = reply.get("access_token"); + Object expires = reply.get("expires_in"); + if (!(token instanceof String)) { + throw new TokenAcquisitionException("The metadata server sent no token", + r.getStatus(), null, true, null); + } + long seconds = expires instanceof Number ? ((Number) expires).longValue() : 300; + return new AccessToken((String) token, "Bearer", + System.currentTimeMillis() + seconds * 1000, + Collections.emptySet()); + } catch (IOException err) { + throw new TokenAcquisitionException("No Google credentials: no key file is " + + "configured and the metadata server did not answer: " + + err.getMessage(), 0, null, true, err); + } + } + } + + /// One REST call; the reply, or null when `tolerate` is its status. + private Map call(String method, String path, Map body, int tolerate) + throws IOException { + if (closed.get()) { + throw new IOException("The pubsub transport is closed"); + } + List headers = new ArrayList(2); + headers.add("Content-Type: application/json"); + if (tokens != null) { + try { + headers.add("Authorization: Bearer " + tokens.getTokenValue()); + } catch (TokenAcquisitionException err) { + throw new IOException(err.getMessage(), err); + } + } + Web.Result r = Web.request(method, base + path, headers, + body == null ? null : WireBuffer.utf8(Json.write(body))); + if (r.getStatus() == tolerate) { + return null; + } + String text = r.getBodyAsString(); + if (!r.isSuccess()) { + if (r.getStatus() == 401 && tokens != null) { + tokens.invalidate(); + } + throw new IOException("Pub/Sub refused " + method + " " + path + " (" + + r.getStatus() + "): " + (text == null ? r.getError() : text)); + } + return text == null || text.trim().length() == 0 ? new LinkedHashMap() + : Json.parseObject(text); + } + + /// The Pub/Sub name `name` maps to. + static String resourceName(String name) { + StringBuilder sb = new StringBuilder(name.length() + 2); + char first = name.length() == 0 ? '0' : name.charAt(0); + if (!(first >= 'a' && first <= 'z' || first >= 'A' && first <= 'Z')) { + sb.append("t-"); + } + for (int i = 0; i < name.length() && sb.length() < 255; i++) { + char c = name.charAt(i); + boolean ok = c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' + || c == '-' || c == '_' || c == '.' || c == '~' || c == '+' || c == '%'; + sb.append(ok ? c : '-'); + } + while (sb.length() < 3) { + sb.append('-'); + } + return sb.toString(); + } + + private String topic(String destination) { + return "projects/" + project + "/topics/" + resourceName(destination); + } + + private String subscription(String destination, String group) { + return "projects/" + project + "/subscriptions/" + + resourceName(destination + "." + (group == null ? "default" : group)); + } + + private String declareTopic(String destination) throws IOException { + String topic = topic(destination); + if (!declared.contains(topic)) { + call("PUT", topic, new LinkedHashMap(), 409); + declared.add(topic); + } + return topic; + } + + /// Creates `destination`'s topic and `group`'s subscription to it unless + /// they exist, so messages sent from now on are kept for that group even + /// before its listener starts. + public String declare(String destination, String group) throws IOException { + String topic = declareTopic(destination); + String sub = subscription(destination, group); + if (!declared.contains(sub)) { + Map req = new LinkedHashMap(); + req.put("topic", topic); + req.put("ackDeadlineSeconds", Integer.valueOf(leaseSeconds)); + call("PUT", sub, req, 409); + declared.add(sub); + } + return sub; + } + + @Override + public void send(Message message) throws IOException { + Map attributes = new LinkedHashMap(); + for (Map.Entry h : message.getHeaders().entrySet()) { + if (h.getValue() != null) { + attributes.put(h.getKey(), h.getValue()); + } + } + if (message.getKey() != null) { + attributes.put(KEY, message.getKey()); + } + byte[] payload = message.getPayload(); + if (payload.length == 0 && attributes.isEmpty()) { + // Pub/Sub refuses a message with neither data nor attributes. + attributes.put(EMPTY, "1"); + } + Map m = new LinkedHashMap(); + if (payload.length > 0) { + m.put("data", Base64.encode(payload)); + } + if (!attributes.isEmpty()) { + m.put("attributes", attributes); + } + List messages = new ArrayList(1); + messages.add(m); + Map req = new LinkedHashMap(); + req.put("messages", messages); + call("POST", declareTopic(message.getDestination()) + ":publish", req, -1); + } + + @Override + protected List poll(String destination, String group, int max, long waitMillis) + throws IOException { + String sub = declare(destination, group); + Map req = new LinkedHashMap(); + req.put("maxMessages", Integer.valueOf(Math.max(1, max))); + // A pull that waits holds the consumer past its subscription's close; + // an empty answer is followed by a short sleep instead. + req.put("returnImmediately", Boolean.TRUE); + Object received = call("POST", sub + ":pull", req, -1).get("receivedMessages"); + List out = new ArrayList(); + if (received instanceof List) { + for (Object o : (List) received) { + if (o instanceof Map) { + Delivery d = delivery(destination, sub, (Map) o); + if (d != null) { + out.add(d); + } + } + } + } + if (out.isEmpty()) { + try { + Thread.sleep(Math.min(waitMillis, 200)); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + } + } + return out; + } + + private Delivery delivery(String destination, String sub, Map r) throws IOException { + Object ackId = r.get("ackId"); + Object m = r.get("message"); + if (!(ackId instanceof String) || !(m instanceof Map)) { + return null; + } + Map message = (Map) m; + Map headers = new LinkedHashMap(); + Object attributes = message.get("attributes"); + if (attributes instanceof Map) { + Iterator it = ((Map) attributes).entrySet().iterator(); + while (it.hasNext()) { + Map.Entry e = (Map.Entry) it.next(); + if (e.getValue() instanceof String) { + headers.put(String.valueOf(e.getKey()), (String) e.getValue()); + } + } + } + headers.remove(EMPTY); + String key = headers.remove(KEY); + Object id = message.get("messageId"); + String messageId = id instanceof String ? (String) id : (String) ackId; + Integer attempt; + synchronized (attempts) { + attempt = attempts.get(sub + "\n" + messageId); + } + if (attempt != null) { + headers.put(Message.ATTEMPT, attempt.toString()); + } + Object data = message.get("data"); + byte[] payload = data instanceof String ? Base64.decode((String) data) : new byte[0]; + return new Delivery(identified(new Message(destination, key, payload, headers), + messageId), new String[] {sub, (String) ackId, messageId}); + } + + @Override + protected void ack(String destination, String group, Delivery delivery) throws IOException { + String[] h = (String[]) delivery.handle; + synchronized (attempts) { + attempts.remove(h[0] + "\n" + h[2]); + } + List ids = new ArrayList(1); + ids.add(h[1]); + Map req = new LinkedHashMap(); + req.put("ackIds", ids); + call("POST", h[0] + ":acknowledge", req, -1); + } + + /// Keeps the message in its subscription, due again at `notBefore` (at + /// most ten minutes away), and remembers which attempt that will be. + @Override + protected void redeliver(String destination, String group, Delivery delivery, int attempt, + long notBefore) throws IOException { + String[] h = (String[]) delivery.handle; + synchronized (attempts) { + attempts.put(h[0] + "\n" + h[2], Integer.valueOf(attempt)); + } + long seconds = (notBefore - System.currentTimeMillis() + 999) / 1000; + List ids = new ArrayList(1); + ids.add(h[1]); + Map req = new LinkedHashMap(); + req.put("ackIds", ids); + req.put("ackDeadlineSeconds", Integer.valueOf((int) Math.max(0, + Math.min(MAX_DEADLINE, seconds)))); + call("POST", h[0] + ":modifyAckDeadline", req, -1); + } + + @Override + public boolean isHealthy() { + return !closed.get(); + } + + @Override + public void close() { + if (!closed.getAndSet(true)) { + stopSubscriptions(); + } + } +} diff --git a/vm/backend/src/com/codename1/backend/messaging/pubsub/package-info.java b/vm/backend/src/com/codename1/backend/messaging/pubsub/package-info.java new file mode 100644 index 00000000000..2054c3b35a2 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/messaging/pubsub/package-info.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +/// The Google Cloud Pub/Sub transport. Compiled into an application only when +/// its configuration names a `pubsub://` messaging URL. +package com.codename1.backend.messaging.pubsub; diff --git a/vm/backend/src/com/codename1/backend/messaging/sqs/SqsTransport.java b/vm/backend/src/com/codename1/backend/messaging/sqs/SqsTransport.java new file mode 100644 index 00000000000..ad27a7b5b0b --- /dev/null +++ b/vm/backend/src/com/codename1/backend/messaging/sqs/SqsTransport.java @@ -0,0 +1,356 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.messaging.sqs; + +import com.codename1.backend.Base64; +import com.codename1.backend.Config; +import com.codename1.backend.Json; +import com.codename1.backend.Web; +import com.codename1.backend.aws.Aws; +import com.codename1.backend.aws.Credentials; +import com.codename1.backend.messaging.Message; +import com.codename1.backend.messaging.MessageTransport; +import com.codename1.backend.messaging.Messaging; +import com.codename1.backend.messaging.PollingTransport; +import com.codename1.backend.messaging.RetryPolicy; +import com.codename1.impl.backend.WireBuffer; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +/// Messaging over Amazon SQS, signed with the backend's own SigV4 signer and no +/// AWS SDK. +/// +/// ```properties +/// cn1.messaging.url=sqs://eu-west-1 +/// ``` +/// +/// The host part is a region, which addresses +/// `sqs..amazonaws.com`, or a `host:port` endpoint for a compatible +/// service (ElasticMQ, LocalStack), whose region comes from +/// `cn1.messaging.sqs.region` (default `us-east-1`). Only an endpoint on +/// loopback is spoken to over plain HTTP. Credentials are resolved the way +/// [Credentials#resolve()] documents: the environment, then the container, +/// then the instance metadata service. +/// +/// A destination is a standard queue, created on first use. SQS allows +/// letters, digits, `-` and `_` in a queue name, so every other character of +/// a destination -- the `.` of `orders.dlq` -- becomes `-`. Listeners of one +/// queue compete for its messages whatever their group: SQS has no consumer +/// groups, and fan-out is SNS's job. +/// +/// The body is sent base64 encoded, so any payload survives; the key and +/// headers travel in one message attribute, `cn1-headers`, which keeps clear of +/// SQS's limit of ten attributes. A message from a producer that is not this +/// transport is read as it is: its body as UTF-8 text and its string +/// attributes as headers. SQS limits a message to 256 KiB, which the encoding +/// makes about 190 KiB of payload. +/// +/// A message is handed to one consumer for `cn1.messaging.lease` milliseconds +/// (SQS's visibility timeout); one not settled in that time is delivered +/// again. A failed message is not copied: its visibility is pushed back by the +/// retry delay, and SQS's own receive count is its attempt number. A delay is +/// at most twelve hours, SQS's limit. +public final class SqsTransport extends PollingTransport { + private static final String JSON = "application/x-amz-json-1.0"; + private static final String HEADERS = "cn1-headers"; + private static final String ENCODING = "cn1-encoding"; + private static final String KEY = "cn1-key"; + private static final int MAX_VISIBILITY = 43200; + + private final String host; + private final String region; + private final boolean secure; + private final int leaseSeconds; + private final Map queues = new HashMap(); + private final Object credentialLock = new Object(); + private Credentials credentials; + private final AtomicBoolean closed = new AtomicBoolean(); + + /// Registers this transport with [Messaging]; the generated wiring does it. + public static final class Factory implements Messaging.TransportFactory { + @Override + public MessageTransport open(String url, Config config, RetryPolicy retry) + throws IOException { + String region = config == null ? null : config.get("cn1.messaging.sqs.region"); + int lease = config == null ? 60000 : config.getInt("cn1.messaging.lease", 60000); + return new SqsTransport(url, region, retry, lease); + } + } + + /// A transport to the region or endpoint `url` names; `region` signs + /// requests to an endpoint, and may be null for `us-east-1`. + public SqsTransport(String url, String region, RetryPolicy retry, int leaseMillis) + throws IOException { + super(retry); + int scheme = url.indexOf("://"); + if (scheme < 0 || !"sqs".equalsIgnoreCase(url.substring(0, scheme))) { + throw new IOException("Not an SQS URL: " + url); + } + String rest = url.substring(scheme + 3); + int slash = rest.indexOf('/'); + if (slash >= 0) { + rest = rest.substring(0, slash); + } + if (rest.length() == 0) { + throw new IOException("The SQS URL names no region: " + url); + } + if (rest.indexOf(':') < 0 && rest.indexOf('.') < 0) { + this.region = rest; + this.host = "sqs." + rest + ".amazonaws.com"; + this.secure = true; + } else { + this.region = region == null || region.trim().length() == 0 ? "us-east-1" + : region.trim(); + this.host = rest; + String name = rest.indexOf(':') < 0 ? rest : rest.substring(0, rest.lastIndexOf(':')); + this.secure = !"localhost".equalsIgnoreCase(name) && !"127.0.0.1".equals(name) // NOPMD AvoidUsingHardCodedIP - loopback is the one place plain HTTP is allowed + && !"[::1]".equals(name); + } + this.leaseSeconds = Math.max(1, Math.min(MAX_VISIBILITY, leaseMillis / 1000)); + } + + @Override + public String getName() { + return "sqs"; + } + + private Credentials credentials() throws IOException { + synchronized (credentialLock) { + if (credentials == null || credentials.isExpiring(60000)) { + credentials = Credentials.resolve(); + } + return credentials; + } + } + + /// One SQS action; the reply's JSON object. + private Map call(String action, Map request) throws IOException { + if (closed.get()) { + throw new IOException("The sqs transport is closed"); + } + Map headers = new LinkedHashMap(); + headers.put("content-type", JSON); + headers.put("x-amz-target", "AmazonSQS." + action); + Web.Result r = Aws.send(credentials(), region, "sqs", "POST", host, "/", null, headers, + WireBuffer.utf8(Json.write(request)), null, secure); + String body = r.getBodyAsString(); + if (!r.isSuccess()) { + throw new SqsException(action, r.getStatus(), body == null ? r.getError() : body); + } + if (body == null || body.trim().length() == 0) { + return new HashMap(); + } + return Json.parseObject(body); + } + + /// An SQS error reply. + static final class SqsException extends IOException { + private static final long serialVersionUID = 1L; + + SqsException(String action, int status, String body) { + super("SQS refused " + action + " (" + status + "): " + body); + } + } + + /// The SQS queue name `destination` maps to. + static String queueName(String destination) { + StringBuilder sb = new StringBuilder(destination.length()); + for (int i = 0; i < destination.length() && sb.length() < 80; i++) { + char c = destination.charAt(i); + boolean ok = c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' + || c == '-' || c == '_'; + sb.append(ok ? c : '-'); + } + return sb.toString(); + } + + private String queue(String destination) throws IOException { + synchronized (queues) { + String url = queues.get(destination); + if (url != null) { + return url; + } + } + Map req = new LinkedHashMap(); + req.put("QueueName", queueName(destination)); + Object url = call("CreateQueue", req).get("QueueUrl"); + if (!(url instanceof String)) { + throw new IOException("SQS created " + destination + " but named no queue URL"); + } + synchronized (queues) { + queues.put(destination, (String) url); + } + return (String) url; + } + + @Override + public void send(Message message) throws IOException { + Map headers = new LinkedHashMap(message.getHeaders()); + if (message.getKey() != null) { + headers.put(KEY, message.getKey()); + } + Map attributes = new LinkedHashMap(); + // SQS refuses an empty body, so an empty payload travels as one space. + byte[] payload = message.getPayload(); + attributes.put(ENCODING, stringAttribute(payload.length == 0 ? "empty" : "base64")); + if (!headers.isEmpty()) { + attributes.put(HEADERS, stringAttribute(Json.write(headers))); + } + Map req = new LinkedHashMap(); + req.put("QueueUrl", queue(message.getDestination())); + req.put("MessageBody", payload.length == 0 ? " " : Base64.encode(payload)); + req.put("MessageAttributes", attributes); + call("SendMessage", req); + } + + private static Map stringAttribute(String value) { + Map a = new LinkedHashMap(); + a.put("DataType", "String"); + a.put("StringValue", value); + return a; + } + + @Override + protected List poll(String destination, String group, int max, long waitMillis) + throws IOException { + List all = new ArrayList(1); + all.add("All"); + List count = new ArrayList(1); + count.add("ApproximateReceiveCount"); + Map req = new LinkedHashMap(); + req.put("QueueUrl", queue(destination)); + req.put("MaxNumberOfMessages", Integer.valueOf(Math.max(1, Math.min(10, max)))); + req.put("WaitTimeSeconds", Integer.valueOf((int) Math.max(0, Math.min(20, + waitMillis / 1000)))); + req.put("VisibilityTimeout", Integer.valueOf(leaseSeconds)); + req.put("MessageAttributeNames", all); + req.put("AttributeNames", count); + req.put("MessageSystemAttributeNames", count); + Object messages = call("ReceiveMessage", req).get("Messages"); + List out = new ArrayList(); + if (!(messages instanceof List)) { + return out; + } + for (Object o : (List) messages) { + if (o instanceof Map) { + Delivery d = delivery(destination, (Map) o); + if (d != null) { + out.add(d); + } + } + } + return out; + } + + private static Delivery delivery(String destination, Map m) throws IOException { + Object handle = m.get("ReceiptHandle"); + Object body = m.get("Body"); + if (!(handle instanceof String)) { + return null; + } + Map headers = new LinkedHashMap(); + String encoding = null; + Object attributes = m.get("MessageAttributes"); + if (attributes instanceof Map) { + Iterator it = ((Map) attributes).entrySet().iterator(); + while (it.hasNext()) { + Map.Entry e = (Map.Entry) it.next(); + Object v = e.getValue() instanceof Map ? ((Map) e.getValue()).get("StringValue") + : null; + if (!(v instanceof String)) { + continue; + } + String name = String.valueOf(e.getKey()); + if (ENCODING.equals(name)) { + encoding = (String) v; + } else if (HEADERS.equals(name)) { + Map parsed = Json.parseObject((String) v); + Iterator h = parsed.entrySet().iterator(); + while (h.hasNext()) { + Map.Entry he = (Map.Entry) h.next(); + if (he.getValue() instanceof String) { + headers.put(String.valueOf(he.getKey()), (String) he.getValue()); + } + } + } else { + headers.put(name, (String) v); + } + } + } + if (headers.get(Message.ATTEMPT) == null) { + Object system = m.get("Attributes"); + Object count = system instanceof Map ? ((Map) system).get("ApproximateReceiveCount") + : null; + if (count instanceof String) { + headers.put(Message.ATTEMPT, (String) count); + } + } + String key = headers.remove(KEY); + String text = body instanceof String ? (String) body : ""; + byte[] payload = "empty".equals(encoding) ? new byte[0] + : "base64".equals(encoding) ? Base64.decode(text) : WireBuffer.utf8(text); + Object id = m.get("MessageId"); + return new Delivery(identified(new Message(destination, key, payload, headers), + id instanceof String ? (String) id : null), handle); + } + + @Override + protected void ack(String destination, String group, Delivery delivery) throws IOException { + Map req = new LinkedHashMap(); + req.put("QueueUrl", queue(destination)); + req.put("ReceiptHandle", delivery.handle); + call("DeleteMessage", req); + } + + /// Keeps the message where it is, hidden until `notBefore`: SQS counts + /// the next receive as the next attempt. + @Override + protected void redeliver(String destination, String group, Delivery delivery, int attempt, + long notBefore) throws IOException { + long seconds = (notBefore - System.currentTimeMillis() + 999) / 1000; + Map req = new LinkedHashMap(); + req.put("QueueUrl", queue(destination)); + req.put("ReceiptHandle", delivery.handle); + req.put("VisibilityTimeout", Integer.valueOf((int) Math.max(0, + Math.min(MAX_VISIBILITY, seconds)))); + call("ChangeMessageVisibility", req); + } + + @Override + public boolean isHealthy() { + return !closed.get(); + } + + @Override + public void close() { + if (!closed.getAndSet(true)) { + stopSubscriptions(); + } + } +} diff --git a/vm/backend/src/com/codename1/backend/messaging/sqs/package-info.java b/vm/backend/src/com/codename1/backend/messaging/sqs/package-info.java new file mode 100644 index 00000000000..60754debe41 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/messaging/sqs/package-info.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +/// The Amazon SQS transport. Compiled into an application only when its +/// configuration names an `sqs://` messaging URL. +package com.codename1.backend.messaging.sqs; From 4634e3d8dcbbafdceb0e0703afd54cbb00bc1675 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:46:36 +0200 Subject: [PATCH 06/11] Docs: backend caching and messaging chapter Co-Authored-By: Claude Opus 5.5 --- .../backend/messaging/Checkout.java | 52 ++++ .../backend/messaging/OrderLookup.java | 49 ++++ .../backend/messaging/Shipping.java | 55 ++++ .../src/main/resources/application.properties | 8 + .../Backend-Messaging.asciidoc | 241 ++++++++++++++++++ docs/developer-guide/developer-guide.asciidoc | 2 + 6 files changed, 407 insertions(+) create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/messaging/Checkout.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/messaging/OrderLookup.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/messaging/Shipping.java create mode 100644 docs/developer-guide/Backend-Messaging.asciidoc diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/messaging/Checkout.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/messaging/Checkout.java new file mode 100644 index 00000000000..1863b5129c0 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/messaging/Checkout.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.backend.messaging; + +import com.codename1.backend.DataSource; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.messaging.MessageTemplate; +import com.codenameone.developerguide.backend.orders.Order; + +import java.io.IOException; + +// tag::backend-messaging-send[] +@Component +public class Checkout { + private final DataSource db; + private final MessageTemplate messages; + + public Checkout(DataSource db, MessageTemplate messages) { + this.db = db; + this.messages = messages; + } + + @Transactional + public void place(Order order) throws IOException { + db.execute("INSERT INTO orders (id, customer) VALUES (?, ?)", + new Object[] {Long.valueOf(order.id), order.customer}); + // Written to the outbox in this transaction, sent once it commits. + messages.send("orders.placed", order.customer, order); + } +} +// end::backend-messaging-send[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/messaging/OrderLookup.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/messaging/OrderLookup.java new file mode 100644 index 00000000000..7bfdb8dc733 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/messaging/OrderLookup.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.backend.messaging; + +import com.codename1.backend.annotations.CacheEvict; +import com.codename1.backend.annotations.Cacheable; +import com.codename1.backend.annotations.Component; +import com.codenameone.developerguide.backend.orders.Order; + +// tag::backend-cache[] +@Component +public class OrderLookup { + @Cacheable(value = "orders", key = "#id") + public Order find(long id) { + return load(id); + } + + @CacheEvict(value = "orders", key = "#order.id") + public void update(Order order) { + // write the change; the next find() reads it again + } +// end::backend-cache[] + + private Order load(long id) { + Order o = new Order(); + o.id = id; + return o; + } +} diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/messaging/Shipping.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/messaging/Shipping.java new file mode 100644 index 00000000000..bd486fb912f --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/messaging/Shipping.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.backend.messaging; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.KafkaListener; +import com.codename1.backend.annotations.MessageListener; +import com.codename1.backend.messaging.Message; +import com.codenameone.developerguide.backend.orders.Order; + +// tag::backend-messaging-listen[] +@Component +public class Shipping { + @MessageListener(value = "orders.placed", group = "shipping", concurrency = 4) + public void placed(Order order) { + // runs once per message; throwing has it delivered again later + } + + @MessageListener(value = "orders.placed.dlq", group = "support") + public void gaveUp(Message message) { + String why = message.getHeader(Message.ERROR); + // the order that failed every attempt, and the last exception + } +} +// end::backend-messaging-listen[] + +// tag::backend-messaging-spring[] +@Component +class Invoicing { + @KafkaListener(topics = "orders.placed", groupId = "invoicing") + public void placed(Order order) { + // the same listener, written as a Spring Kafka application writes it + } +} +// end::backend-messaging-spring[] diff --git a/docs/demos/backend/src/main/resources/application.properties b/docs/demos/backend/src/main/resources/application.properties index 6ae761ced1e..b66d768681b 100644 --- a/docs/demos/backend/src/main/resources/application.properties +++ b/docs/demos/backend/src/main/resources/application.properties @@ -5,3 +5,11 @@ cn1.datasource.url=${DATABASE_URL} cn1.server.port=8080 # end::backend-config-base[] + +# tag::backend-scale-out[] +# One Redis for the cache, sessions, rate limits and scheduler locks... +cn1.kv.url=${REDIS_URL} +cn1.session.store=kv +# ...and a broker for messages. +cn1.messaging.url=kafka://kafka-1:9092,kafka-2:9092 +# end::backend-scale-out[] diff --git a/docs/developer-guide/Backend-Messaging.asciidoc b/docs/developer-guide/Backend-Messaging.asciidoc new file mode 100644 index 00000000000..3e3aae200df --- /dev/null +++ b/docs/developer-guide/Backend-Messaging.asciidoc @@ -0,0 +1,241 @@ +[[backend-messaging]] +== Backend caching and messaging + +A server that runs as one process can keep its state in memory: the cache, the +sessions, the rate-limit counters and the record of which scheduled job ran. Once +a second instance starts behind a load balancer, each of those splits in two. A +user signs in on one instance and is a stranger to the other, a limit of five +attempts becomes ten, and every instance sends the nightly digest. Work that has +to outlive a request -- shipping an order, sending a receipt -- needs somewhere to +wait that survives a restart. + +This chapter covers both answers: a shared key/value store, usually Redis, that +holds the state instances must agree on, and messaging, which hands work from one +part of a system to another through a queue or a topic. The programming model is +Spring's (`@Cacheable`, `@KafkaListener`, `@RabbitListener`), and Spring Boot's +property names work too. What differs is how it's built: the build finds every +cache annotation and listener and writes the code for it, so there are no +proxies and no reflection. Each broker protocol is a few hundred lines on the +backend's own network layer, with no client library. A protocol the configuration +doesn't name isn't compiled into the server. + +=== Scaling out + +Two settings move a server from one process to many: + +[source,properties] +---- +include::../demos/backend/src/main/resources/application.properties[tag=backend-scale-out] +---- + +`cn1.kv.url` names the shared store: `redis://user:password@host:6379/0`, or +`rediss://` for TLS. Redis, Valkey and KeyDB all speak the same protocol. Once +it's set: + +* `@Cacheable` and the rest of the cache annotations keep their entries there, + so a value one instance computed is a hit on every other. +* `cn1.session.store=kv` keeps sessions there, so any instance can serve any + request. +* `KeyValueRateLimiter` counts there, so a limit applies across the whole + deployment. +* A `@Scheduled` job with a `lock` claims it there when the server has no + database. A server with a database keeps its locks in the database, as + <> describes. +* The health endpoint reports whether the store answers. + +Without `cn1.kv.url`, Spring Boot's `spring.data.redis.url` is read, and then +`spring.data.redis.host` with its `port`, `username`, `password`, `database` and +`ssl.enabled`. A configuration written for a Spring application needs no change. +With none of them set, everything stays in the process, as it was. + +=== Caching + +[source,java,role=cn1-no-try] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/messaging/OrderLookup.java[tag=backend-cache,indent=0] +---- + +`@Cacheable` answers a call from the cache when the cache holds its key, and +otherwise runs the method and keeps the result. `@CachePut` always runs the +method and stores the result, and `@CacheEvict` removes one key, or every key of +the cache with `allEntries = true`. + +The build rewrites each method rather than wrapping it in a proxy, so a call +from inside the same class is cached too. Spring's proxy would skip that call. +The key expression is compiled at build time. It can name a parameter (`#id`, +or `#p0` by position), a property of one (`#order.id`, read through its getter +or a public field), a quoted literal, and `#root.methodName`, joined with `+`. +An expression the build can't compile fails the build, instead of failing on +the first call in production. An empty key means every parameter, as Spring's +default key generator does. + +Values are written as JSON by a codec the build generates for the return type, +not by Java serialization. A null result isn't kept. An entry lives for +`cn1.cache..ttl` seconds, then `cn1.cache.ttl`, then Spring's +`spring.cache.redis.time-to-live`; with none set it lives until evicted. + +=== Messaging + +A message is a payload, an optional key and some string headers, sent to a +_destination_: a queue, a topic or a stream, depending on the broker. Sending +goes through `MessageTemplate`, which a bean receives like any other dependency: + +[source,java,role=cn1-no-try] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/messaging/Checkout.java[tag=backend-messaging-send,indent=0] +---- + +The payload is written as JSON by a generated codec, unless it's already a +`String` or a `byte[]`. The key keeps related messages in order on brokers that +partition, such as Kafka: messages with one key are handled in the order they +were sent. + +Receiving is a method on a bean: + +[source,java,role=cn1-no-try] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/messaging/Shipping.java[tag=backend-messaging-listen,indent=0] +---- + +The method runs once for each message, on a consumer thread, and the message is +acknowledged when the method returns. It takes the payload, decoded by a +generated codec, or a `String`, a `byte[]`, or the whole `Message` with its +headers. `concurrency` sets how many messages it handles at once. A destination +can name a setting, `${orders.topic}`, read when the server starts. + +Spring's annotations are accepted as written, so a listener can be moved from a +Spring application unchanged: + +[source,java,role=cn1-no-try] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/messaging/Shipping.java[tag=backend-messaging-spring,indent=0] +---- + +`@RabbitListener(queues = ...)` and `@JmsListener(destination = ...)` work the +same way. + +==== Groups + +Every listener belongs to a group, `group` (or `groupId`). Each group receives +every message sent to the destination, and the listeners of one group share the +messages between them: `shipping` and `invoicing` each see every order, and +two instances of the shipping service never ship one order twice. Brokers that +have no groups of their own (RabbitMQ and SQS) hand every listener of a queue a +share of its messages, whatever its group. Use a destination per consumer +there, or a fan-out exchange or SNS topic in front of the queues. + +==== Delivery, retries and dead letters + +Delivery is _at least once_. A message is acknowledged only after its listener +returned, so a server that dies halfway through a message has that message +delivered again, to itself after a restart or to another instance. A listener +should therefore be safe to run twice for one message: check whether the order +already shipped before shipping it. + +A listener that throws has its message delivered again after a backoff: + +[cols="2,1,3"] +|=== +|Setting |Default | + +|`cn1.messaging.retry.max-attempts` |3 |deliveries before the message goes to the dead-letter destination +|`cn1.messaging.retry.backoff` |1000 |milliseconds before the second delivery +|`cn1.messaging.retry.multiplier` |2 |how much longer each wait is than the last +|`cn1.messaging.retry.max-backoff` |30000 |the longest wait +|=== + +A message that fails every attempt goes to `.dlq`, with two extra +headers: the exception that failed it (`Message.ERROR`) and the destination it +came from (`Message.ORIGIN`). A dead-letter destination is a destination like +any other, so a listener can read it, as `gaveUp` does above, to alert someone +or to send the message back after a fix. + +==== Sending inside a transaction + +With a broker, a message sent from a `@Transactional` method isn't sent straight +away. It's written to a table, `cn1_outbox`, in the same transaction, and sent once the +transaction commits. This is the _transactional outbox_ pattern. A rollback +sends nothing, so no message ever announces an order that was never saved. A +commit never loses its messages either: a process that dies between the commit +and the send leaves the row behind, and another instance sends it after +`cn1.messaging.outbox.sweep` milliseconds (10 seconds). The message may then be +sent twice, which at-least-once delivery already allows. Outside a transaction, +`send` sends at once and throws when the broker doesn't accept the message. + +=== Brokers + +`cn1.messaging.url` chooses the transport: + +[cols="2,3"] +|=== +|URL |Transport + +|`memory:` |In the process. Nothing survives a restart, and other instances see nothing. The default without a database. +|`db:` |A queue table, `cn1_messages`, in the server's own database. The default when there's a database, so messaging works with no new infrastructure. +|`redis://host:6379` |Redis Streams, one stream per destination and one consumer group per group. +|`amqp://user:password@host:5672/vhost` |RabbitMQ, one durable queue per destination, with publisher confirms. +|`kafka://host:9092,host:9092` |Apache Kafka, one topic per destination and one consumer group per group. +|`sqs://eu-west-1` |Amazon SQS, one standard queue per destination. +|`pubsub://project` |Google Cloud Pub/Sub, one topic per destination and one subscription per group. +|=== + +`amqps://`, `rediss://` and `kafkas://` are the TLS forms. Spring Boot's +`spring.rabbitmq.host` (or `addresses`) selects RabbitMQ when +`cn1.messaging.url` isn't set, and `spring.kafka.bootstrap-servers` selects +Kafka. `cn1.messaging.timeout` (5000 milliseconds) limits how long a broker +connection or reply may take, and `cn1.messaging.lease` (60000 milliseconds) +is how long a consumer may hold a message before it's delivered again +elsewhere, on the transports that take a lease. + +Each broker has its own details: + +* *Database.* A send inside a transaction is a row of that transaction, so it + needs no outbox. A consumer claims a row with a compare-and-set on it, safe on + every engine, and PostgreSQL claims a batch at once with + `FOR UPDATE SKIP LOCKED`. `cn1.messaging.db.poll` (500 milliseconds) sets how + often an empty queue is polled. +* *Redis Streams.* A message a consumer claimed and never acknowledged is + claimed again by another after the lease. +* *RabbitMQ.* A retry waits in a queue of its own whose messages expire back + into the original queue. Nothing needs installing on the broker. +* *Kafka.* Partitions are shared among a group's members as the Java client + shares them, so a Codename One server and a Spring one can be members of the + same group. Records of a partition are handled one at a time, in order, and + the group's offset moves past a record only after its listener returned. A + failed record waits in `.retry.` and never holds up the topic + itself, which Spring Kafka calls a non-blocking retry. A new group starts at + the earliest offset; `spring.kafka.consumer.auto-offset-reset=latest` starts + it at the end. Gzip-compressed records can be read; snappy, lz4 and zstd + can't, and transactions and SASL aren't supported. +* *SQS.* `sqs://` addresses AWS, and `sqs://host:port` a compatible + service such as ElasticMQ or LocalStack, signed for `cn1.messaging.sqs.region`. + Credentials come from the environment, the container or the instance, as + for S3. A failed message stays in its queue, hidden until its retry is due. + SQS limits a message to 256 KiB, about 190 KiB of payload after encoding. +* *Pub/Sub.* Credentials come from the service-account key file named by + `GOOGLE_APPLICATION_CREDENTIALS`, or from the metadata server on Google + Cloud; `PUBSUB_EMULATOR_HOST` points everything at the emulator. Pub/Sub keeps + a message only for subscriptions that exist when it's sent, so a group that + has never run misses earlier messages. Create the subscriptions when you + deploy, or call `PubSubTransport.declare` before the first send. + +Destination names that a broker doesn't allow are adjusted: SQS turns the `.` +of `orders.dlq` into `-`, for example. Keep destination names to letters, +digits, `.`, `-` and `_` and they stay recognizable on every broker. + +=== Pitfalls + +* *A listener that isn't idempotent.* Every transport redelivers a message whose + listener didn't finish, and the outbox can send a message twice. A listener + that charges a card on every delivery charges it twice sooner or later. +* *A `memory:` transport in production.* It's the default for a server without + a database, and it loses every queued message on restart. Name a broker, or + give the server a database for `db:`. +* *Holding a message too long.* A listener that runs longer than + `cn1.messaging.lease` has its message delivered again to another consumer + while it's still running. Raise the lease, or hand the slow work to a job of + its own. +* *Ordering without a key.* Only messages with one key keep their order on + Kafka. Messages without a key are spread over the partitions. +* *Caching a mutable object.* A cached value is a copy written as JSON. Changing + the object a call returned doesn't change the cache; evict or put instead. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index 87aeff8ab5c..4e5603995b7 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -141,6 +141,8 @@ include::Backend-Security.asciidoc[] include::Backend-Scheduling.asciidoc[] +include::Backend-Messaging.asciidoc[] + include::Backend-Observability.asciidoc[] include::Backend-MCP.asciidoc[] From c27954a2400de7ca402599e6a39980dc07e0192c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:51:27 +0200 Subject: [PATCH 07/11] Savor: cache menu photos with @Cacheable Co-Authored-By: Claude Opus 5.5 --- scripts/savor/backend/application.properties | 4 ++++ .../com/codenameone/examples/savor/catalog/Catalog.java | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/scripts/savor/backend/application.properties b/scripts/savor/backend/application.properties index 16cb2f2d4cb..c1e161033b9 100644 --- a/scripts/savor/backend/application.properties +++ b/scripts/savor/backend/application.properties @@ -96,3 +96,7 @@ cn1.server.port=8080 # Creates the tables the accounts, the registered app and the tokens are kept in. # Read by the build, so it has to be here and not in a profile's file. cn1.security.schema.enabled=true + +# Photos are cached (Catalog.photo); with cn1.kv.url set, in Redis and shared +# by every instance. +cn1.cache.photos.ttl=3600 diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/catalog/Catalog.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/catalog/Catalog.java index 0e126aa1b5f..3140d070d4c 100644 --- a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/catalog/Catalog.java +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/catalog/Catalog.java @@ -20,6 +20,7 @@ import com.codename1.backend.Base64; import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Cacheable; import com.codename1.backend.annotations.Component; import com.codename1.backend.annotations.Transactional; import com.codename1.orm.session.Identifier; @@ -292,6 +293,13 @@ static DishDto dish(Dish row, List extras) { } /// A photo, to anyone: menus are public. + /// + /// Kept in the `photos` cache, which every instance shares once + /// `cn1.kv.url` names a Redis: a photo is never changed, only replaced by + /// a new one, and the one it replaced is deleted only when no menu names + /// it any more -- so an entry can only go stale for a photo nobody asks + /// for, and lives `cn1.cache.photos.ttl` seconds. + @Cacheable(value = "photos", key = "#id") @Transactional(readOnly = true) public PhotoDto photo(String id) { Photo photo = id == null || id.length() > 32 ? null : photos.findById(id); From 62088c5b64dc74c75a2911b3c6a5dd9769758f29 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 10 Nov 2026 19:57:59 +0200 Subject: [PATCH 08/11] Backend messaging: red-team fixes - Kafka: advance the fetch offset past control and empty batches so a partition never stalls on one. - Cache keys: refuse identity-keyed parameters (arrays, objects without their own toString) at build time; a null key part no longer collides with the text "null". - Transports: a RuntimeException in a poll, ack or outbox relay no longer kills the consumer thread. - cn1.messaging.transports names the transports to compile in when the broker URL is only known at run time. Co-Authored-By: Claude Opus 5.5 --- .../maven/processors/BackendBeans.java | 24 ++++++++- .../maven/processors/CacheKeyCompiler.java | 53 +++++++++++++++++-- .../maven/processors/BackendBeansTest.java | 40 +++++++++++++- .../backend/messaging/OutboxTransport.java | 8 +-- .../backend/messaging/PollingTransport.java | 8 +-- .../messaging/kafka/KafkaConnection.java | 13 +++-- .../messaging/kafka/KafkaTransport.java | 13 +++-- .../codename1/impl/backend/CacheRuntime.java | 17 +++++- 8 files changed, 153 insertions(+), 23 deletions(-) diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java index 4afac589341..aabb61deb46 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java @@ -1881,9 +1881,27 @@ private void collectJobs(Bean bean) { /// The broker transports the module's configuration names, as the factory /// classes the wiring registers: only those are compiled into the /// application. A URL the build cannot read -- a `${...}` -- includes - /// every one, since any could be meant. + /// every one, since any could be meant, and so does a configuration that + /// names no broker at all: the URL is then left to the deployment (an + /// environment variable, a file beside the server) and the build cannot + /// know which. `cn1.messaging.transports` -- `kafka,sqs` -- names the set + /// outright, for a deployment that wants the others left out. static List messagingTransports(ProcessorContext ctx) { Set schemes = new LinkedHashSet(); + for (String list : RestControllerAnnotationProcessor.applicationPropertyValues(ctx, + "cn1.messaging.transports")) { + int from = 0; + while (from <= list.length()) { + int comma = list.indexOf(',', from); + int end = comma < 0 ? list.length() : comma; + String name = list.substring(from, end).trim(); + if (name.length() > 0) { + schemes.add(name); + } + from = end + 1; + } + } + boolean named = !schemes.isEmpty(); for (String url : RestControllerAnnotationProcessor.applicationPropertyValues(ctx, "cn1.messaging.url")) { if (url.indexOf("${") >= 0) { @@ -1906,6 +1924,10 @@ static List messagingTransports(ProcessorContext ctx) { "spring.kafka.bootstrap-servers")) { schemes.add("kafka"); } + if (!named && schemes.isEmpty() && !RestControllerAnnotationProcessor + .applicationPropertyKnown(ctx, "cn1.messaging.url")) { + schemes.addAll(TRANSPORTS.keySet()); + } List out = new ArrayList(); for (String scheme : schemes) { String factory = TRANSPORTS.get(scheme); diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/CacheKeyCompiler.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/CacheKeyCompiler.java index f7524dc0c6e..0b7fff2464b 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/CacheKeyCompiler.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/CacheKeyCompiler.java @@ -77,6 +77,7 @@ String compile(String expression) throws Refused { if (i > 0) { sb.append(", "); } + keyable(args[i], "parameter " + (i + 1)); sb.append(box(args[i], "a" + i)); } return sb.append("})").toString(); @@ -89,8 +90,8 @@ String compile(String expression) throws Refused { } sb.append(term(term.trim())); } - // "" + makes a lone number or char a String. - return terms.size() == 1 ? "String.valueOf(" + sb + ")" : "(\"\" + " + sb + ")"; + // "" + makes a lone literal a String; every parameter term is already one. + return terms.size() == 1 ? sb.toString() : "(\"\" + " + sb + ")"; } /// The terms between top-level `+`, with quoted text kept whole. @@ -130,6 +131,7 @@ private String term(String t) throws Refused { if (isNumber(t)) { return BackendSources.quote(t); } + if (t.length() < 2 || t.charAt(0) != '#') { throw new Refused("\"" + t + "\" is not a parameter (#name), a quoted literal or " + "#root.methodName"); @@ -162,7 +164,8 @@ private String term(String t) throws Refused { expr.append('.').append(step[0]); type = Type.getType(step[1]); } - return expr.toString(); + keyable(type, "\"" + t + "\""); + return "com.codename1.impl.backend.CacheRuntime.part(" + box(type, expr.toString()) + ")"; } private int parameter(String name) throws Refused { @@ -182,6 +185,50 @@ && isNumber(name.substring(1))) { + "use #p0, #p1 ... or compile with -parameters" : "")); } + /// Refuses a key part whose text does not tell two values apart: an + /// array, or an object without a `toString()` of its own, keys by an + /// identity hash -- a new key on every call that never hits, and that + /// after a restart can be the key another object's result was kept under. + private void keyable(Type type, String what) throws Refused { + if (type.getSort() != Type.OBJECT && type.getSort() != Type.ARRAY) { + return; + } + String hint = "; key by one of its properties instead, for example key = \"#p0.id\""; + if (type.getSort() == Type.ARRAY) { + throw new Refused(what + " is an array, whose text is its identity" + hint); + } + String n = type.getInternalName(); + if (VALUE_TYPES.contains(n)) { + return; + } + String current = n; + for (int depth = 0; current != null && !"java/lang/Object".equals(current) + && depth < 32; depth++) { + AnnotatedClass c = RestControllerAnnotationProcessor.resolveClass(ctx, current); + if (c == null) { + break; + } + if (c.isEnum() || c.isRecord()) { + return; + } + for (MethodInfo m : c.getMethods()) { + if ("toString".equals(m.getName()) && "()Ljava/lang/String;".equals(m.getDescriptor()) + && !m.isStatic()) { + return; + } + } + current = c.getSuperInternalName(); + } + throw new Refused(what + " is a " + type.getClassName() + ", which has no toString() " + + "of its own, so its text is its identity" + hint); + } + + private static final java.util.Set VALUE_TYPES = new java.util.HashSet( + java.util.Arrays.asList("java/lang/String", "java/lang/Boolean", "java/lang/Character", + "java/lang/Byte", "java/lang/Short", "java/lang/Integer", "java/lang/Long", + "java/lang/Float", "java/lang/Double", "java/math/BigInteger", + "java/math/BigDecimal", "java/util/UUID", "java/lang/CharSequence")); + /// The Java member access reading `prop` of `owner` and its descriptor, or /// null when it has none the helper may read. private String[] property(String owner, String prop) { diff --git a/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java index 3d7df631f33..38a6ff8184f 100644 --- a/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java @@ -1607,7 +1607,10 @@ public void cachingAnnotationsAnswerFromTheStoreAndEvict() throws Exception { @Test public void messagingRegistersOnlyTheTransportsTheConfigurationNames() throws Exception { String[][] cases = { - {"", ""}, + // Nothing named: the URL comes from the deployment, so all of them. + {"", "redis amqp"}, + {"cn1.messaging.url=db:\n", ""}, + {"cn1.messaging.transports=amqp\n", "amqp"}, {"cn1.messaging.url=redis://cache:6379\n", "redis"}, {"cn1.messaging.url=amqps://u:p@rabbit/%2F\n", "amqp"}, {"spring.rabbitmq.host=rabbit\n", "amqp"}, @@ -1884,6 +1887,41 @@ public void anUncompilableCacheKeyFailsTheBuild() throws Exception { assertTrue(errors, errors.contains("only \"#result == null\" is supported")); } + /// A key part whose text is its identity -- an array, an object with no + /// toString() of its own -- would key every call apart, and after a restart + /// could hit another object's entry, so the build refuses it. A value type, + /// a record, an enum or a property of the object is fine. + @Test + public void aCacheKeyThatIsAnIdentityFailsTheBuild() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Query", PKG + "public class Query { public long id; }\n"); + s.put("com.example.Named", PKG + "public class Named {\n" + + " public String toString() { return \"n\"; }\n}\n"); + s.put("com.example.Keys", PKG + "@Component public class Keys {\n" + + " @Cacheable(\"c\") public String plain(Query q) { return \"\"; }\n" + + " @Cacheable(\"c\") public String array(long[] ids) { return \"\"; }\n" + + " @Cacheable(value = \"c\", key = \"#p0.id\")\n" + + " public String byId(Query q) { return \"\"; }\n" + + " @Cacheable(\"c\") public String named(Named n) { return \"\"; }\n" + + "}\n"); + String errors = String.valueOf(process(compile(s)).getErrors()); + assertTrue(errors, errors.contains("plain cannot be compiled")); + assertTrue(errors, errors.contains("array cannot be compiled")); + assertFalse(errors, errors.contains("byId")); + assertFalse(errors, errors.contains("named")); + } + + /// `null` and the text "null" are two keys, alone and among others. + @Test + public void aNullKeyPartIsNotTheTextNull() throws Exception { + Class rt = Class.forName("com.codename1.impl.backend.CacheRuntime"); + java.lang.reflect.Method key = rt.getMethod("key", Object[].class); + assertFalse(key.invoke(null, new Object[] {new Object[] {null}}) + .equals(key.invoke(null, new Object[] {new Object[] {"null"}}))); + assertFalse(key.invoke(null, new Object[] {new Object[] {"a", null}}) + .equals(key.invoke(null, new Object[] {new Object[] {"a", "null"}}))); + } + /// A `@Repository` interface is injected like a bean: built once from the /// factory the ORM's bootstrap registered, over the injected Session, so it /// works inside the caller's @Transactional method and every injection point diff --git a/vm/backend/src/com/codename1/backend/messaging/OutboxTransport.java b/vm/backend/src/com/codename1/backend/messaging/OutboxTransport.java index cad72f271d2..c8c9f0c449b 100644 --- a/vm/backend/src/com/codename1/backend/messaging/OutboxTransport.java +++ b/vm/backend/src/com/codename1/backend/messaging/OutboxTransport.java @@ -135,14 +135,14 @@ public void afterCommit() { private boolean relay(Object id, Message message) { try { broker.send(message); - } catch (IOException err) { + } catch (IOException | RuntimeException err) { System.err.println("cn1: a message to " + message.getDestination() + " stays in " + "the outbox until the broker takes it: " + err.getMessage()); return false; } try { db.execute("DELETE FROM " + TABLE + " WHERE id = ?", new Object[]{id}); - } catch (IOException err) { + } catch (IOException | RuntimeException err) { System.err.println("cn1: a sent message stays in the outbox and will be sent " + "again: " + err.getMessage()); } @@ -220,7 +220,9 @@ private void sweepLoop() { if (System.currentTimeMillis() >= nextSweep) { try { sweep(); - } catch (IOException err) { + } catch (IOException | RuntimeException err) { + // A row it cannot read must not end the thread every + // committed message waits on. System.err.println("cn1: the outbox sweep failed: " + err.getMessage()); } nextSweep = System.currentTimeMillis() + sweepAfter; diff --git a/vm/backend/src/com/codename1/backend/messaging/PollingTransport.java b/vm/backend/src/com/codename1/backend/messaging/PollingTransport.java index 234111f9a04..39eb4c45901 100644 --- a/vm/backend/src/com/codename1/backend/messaging/PollingTransport.java +++ b/vm/backend/src/com/codename1/backend/messaging/PollingTransport.java @@ -150,7 +150,7 @@ final void dispatch(String destination, String group, MessageHandler handler, De } try { ack(destination, group, d); - } catch (IOException err) { + } catch (IOException | RuntimeException err) { System.err.println("cn1: a handled message of " + destination + " could not be acknowledged and will be delivered again: " + err.getMessage()); @@ -171,7 +171,7 @@ private void settleFailure(String destination, String group, Delivery d, Throwab redeliver(destination, group, d, attempt + 1, System.currentTimeMillis() + retry.delayAfter(attempt)); } - } catch (IOException io) { + } catch (IOException | RuntimeException io) { System.err.println("cn1: a failed message of " + destination + " could not be rescheduled and will be delivered again: " + io.getMessage()); @@ -215,7 +215,9 @@ void consume() { List batch; try { batch = poll(destination, group, 1, 1000); - } catch (IOException err) { + } catch (IOException | RuntimeException err) { + // A RuntimeException is a reply or a row the transport could + // not read: left uncaught it would end this consumer, silently. if (!running.get()) { return; } diff --git a/vm/backend/src/com/codename1/backend/messaging/kafka/KafkaConnection.java b/vm/backend/src/com/codename1/backend/messaging/kafka/KafkaConnection.java index 8a6cea80fba..d746a9daca4 100644 --- a/vm/backend/src/com/codename1/backend/messaging/kafka/KafkaConnection.java +++ b/vm/backend/src/com/codename1/backend/messaging/kafka/KafkaConnection.java @@ -203,13 +203,18 @@ static byte[] batch(byte[] key, byte[] value, Map headers, long /// off at the end of the set -- the fetch size ended inside it -- is left /// for the next fetch; control batches carry no records of the /// application's. - static void records(byte[] set, long from, List out) throws IOException { + /// @return the offset after the last whole batch read -- past control, + /// empty and already-read batches too, so a fetch position always moves + /// even when no record of the application's was in them -- or `from` + /// when the set held no whole batch + static long records(byte[] set, long from, List out) throws IOException { + long next = from; In in = new In(set); while (in.remaining() >= 12) { long base = in.int64(); int length = in.int32(); if (length < 0 || length > in.remaining()) { - return; + return next; } In batch = new In(in.take(length)); batch.int32(); @@ -220,7 +225,8 @@ static void records(byte[] set, long from, List out) throws IOException } batch.int32(); int attributes = batch.int16(); - batch.int32(); + int lastDelta = batch.int32(); + next = Math.max(next, base + lastDelta + 1); batch.int64(); batch.int64(); batch.int64(); @@ -261,6 +267,7 @@ static void records(byte[] set, long from, List out) throws IOException } } } + return next; } private static byte[] gunzip(byte[] data) throws IOException { diff --git a/vm/backend/src/com/codename1/backend/messaging/kafka/KafkaTransport.java b/vm/backend/src/com/codename1/backend/messaging/kafka/KafkaTransport.java index 40103960887..8288fe7a2a6 100644 --- a/vm/backend/src/com/codename1/backend/messaging/kafka/KafkaTransport.java +++ b/vm/backend/src/com/codename1/backend/messaging/kafka/KafkaTransport.java @@ -1114,15 +1114,14 @@ private void fetchFrom(int leader, List list, int wait) throws IOException continue; } List records = new ArrayList(); - KafkaConnection.records(set, p.fetchOffset, records); - if (records.isEmpty()) { - continue; - } + long next = KafkaConnection.records(set, p.fetchOffset, records); synchronized (this) { if (parts.get(p.name()) == p) { // NOPMD CompareObjectsWithEquals - still this member's - p.buffer.addAll(records); - p.fetchOffset = records.get(records.size() - 1).offset + 1; - notifyAll(); + p.fetchOffset = Math.max(p.fetchOffset, next); + if (!records.isEmpty()) { + p.buffer.addAll(records); + notifyAll(); + } } } } diff --git a/vm/backend/src/com/codename1/impl/backend/CacheRuntime.java b/vm/backend/src/com/codename1/impl/backend/CacheRuntime.java index 96992a60650..f5d3f3bf32b 100644 --- a/vm/backend/src/com/codename1/impl/backend/CacheRuntime.java +++ b/vm/backend/src/com/codename1/impl/backend/CacheRuntime.java @@ -101,14 +101,14 @@ public static String key(Object[] parts) { return ""; } if (parts.length == 1) { - return String.valueOf(parts[0]); + return part(parts[0]); } StringBuilder sb = new StringBuilder(); for (int i = 0; i < parts.length; i++) { if (i > 0) { sb.append(','); } - String s = String.valueOf(parts[i]); + String s = part(parts[i]); for (int c = 0; c < s.length(); c++) { char ch = s.charAt(c); if (ch == ',' || ch == '\\') { @@ -120,6 +120,19 @@ public static String key(Object[] parts) { return sb.toString(); } + /// One part of a key as text. A null is a lone NUL character, which no + /// other value's text is -- a value whose text starts with one gains a + /// second -- so `null` and `"null"` are two keys. + public static String part(Object value) { + if (value == null) { + return String.valueOf(NUL); + } + String s = String.valueOf(value); + return s.length() > 0 && s.charAt(0) == NUL ? NUL + s : s; + } + + private static final char NUL = (char) 0; + private static String entry(String cache, String key) { return PREFIX + cache + ':' + key; } From f5325045951b758ad973ed6f19582010aaaa95a1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:49:36 +0200 Subject: [PATCH 09/11] Docs: LanguageTool findings in the messaging chapter Co-Authored-By: Claude Opus 5.5 --- docs/developer-guide/Backend-Messaging.asciidoc | 8 ++++---- docs/developer-guide/languagetool-accept.txt | 3 +++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/developer-guide/Backend-Messaging.asciidoc b/docs/developer-guide/Backend-Messaging.asciidoc index 3e3aae200df..3000d6f8d19 100644 --- a/docs/developer-guide/Backend-Messaging.asciidoc +++ b/docs/developer-guide/Backend-Messaging.asciidoc @@ -156,8 +156,8 @@ With a broker, a message sent from a `@Transactional` method isn't sent straight away. It's written to a table, `cn1_outbox`, in the same transaction, and sent once the transaction commits. This is the _transactional outbox_ pattern. A rollback sends nothing, so no message ever announces an order that was never saved. A -commit never loses its messages either: a process that dies between the commit -and the send leaves the row behind, and another instance sends it after +commit never loses its messages either: a process that dies after the commit +but before sending leaves the row behind, and another instance sends it after `cn1.messaging.outbox.sweep` milliseconds (10 seconds). The message may then be sent twice, which at-least-once delivery already allows. Outside a transaction, `send` sends at once and throws when the broker doesn't accept the message. @@ -189,7 +189,7 @@ elsewhere, on the transports that take a lease. Each broker has its own details: -* *Database.* A send inside a transaction is a row of that transaction, so it +* *Database.* A message sent inside a transaction is a row of that transaction, so it needs no outbox. A consumer claims a row with a compare-and-set on it, safe on every engine, and PostgreSQL claims a batch at once with `FOR UPDATE SKIP LOCKED`. `cn1.messaging.db.poll` (500 milliseconds) sets how @@ -219,7 +219,7 @@ Each broker has its own details: has never run misses earlier messages. Create the subscriptions when you deploy, or call `PubSubTransport.declare` before the first send. -Destination names that a broker doesn't allow are adjusted: SQS turns the `.` +Destination names that a broker rejects are adjusted: SQS turns the `.` of `orders.dlq` into `-`, for example. Keep destination names to letters, digits, `.`, `-` and `_` and they stay recognizable on every broker. diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index 99a02aa3c17..5fc762ff4f8 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -903,3 +903,6 @@ picocli subcommand subcommands transpiles +Redis +Valkey +zstd From 91922d95563dfa932fadaf1aba312f9abc9a3ce9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 11 Nov 2026 03:43:48 +0200 Subject: [PATCH 10/11] Savor: cache key by position, the backend compiles without -parameters The compiled-tests build refused key "#id" on Catalog.photo. The guide's example and wording now lead with #p0 and say when a name works. Initializr template resynced. Co-Authored-By: Claude Opus 5.5 --- .../backend/messaging/OrderLookup.java | 4 ++-- .../Backend-Messaging.asciidoc | 7 ++++--- .../src/main/resources/savor-modules.zip | Bin 3106021 -> 3106611 bytes .../examples/savor/catalog/Catalog.java | 2 +- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/messaging/OrderLookup.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/messaging/OrderLookup.java index 7bfdb8dc733..6b6917972fc 100644 --- a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/messaging/OrderLookup.java +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/messaging/OrderLookup.java @@ -30,12 +30,12 @@ // tag::backend-cache[] @Component public class OrderLookup { - @Cacheable(value = "orders", key = "#id") + @Cacheable(value = "orders", key = "#p0") public Order find(long id) { return load(id); } - @CacheEvict(value = "orders", key = "#order.id") + @CacheEvict(value = "orders", key = "#p0.id") public void update(Order order) { // write the change; the next find() reads it again } diff --git a/docs/developer-guide/Backend-Messaging.asciidoc b/docs/developer-guide/Backend-Messaging.asciidoc index 3000d6f8d19..b9fcd474bab 100644 --- a/docs/developer-guide/Backend-Messaging.asciidoc +++ b/docs/developer-guide/Backend-Messaging.asciidoc @@ -62,9 +62,10 @@ the cache with `allEntries = true`. The build rewrites each method rather than wrapping it in a proxy, so a call from inside the same class is cached too. Spring's proxy would skip that call. -The key expression is compiled at build time. It can name a parameter (`#id`, -or `#p0` by position), a property of one (`#order.id`, read through its getter -or a public field), a quoted literal, and `#root.methodName`, joined with `+`. +The key expression is compiled at build time. It can name a parameter by +position (`#p0`, `#p1`), or by name (`#id`) when the project compiles with +javac's `-parameters`; a property of one (`#p0.id`, read through its getter or +a public field); a quoted literal; and `#root.methodName`, joined with `+`. An expression the build can't compile fails the build, instead of failing on the first call in production. An empty key means every parameter, as Spring's default key generator does. diff --git a/scripts/initializr/common/src/main/resources/savor-modules.zip b/scripts/initializr/common/src/main/resources/savor-modules.zip index 9b9e8080308fb7e19b68effb06bfa7b17ba274a1..21de02dcd47ad12ae5894b041cc0f2c23a6da206 100644 GIT binary patch delta 5312 zcmZ8l3sh9q8fNwpdB0zRJPt1zhM99f5s=sL5D*mwl~VA*0F$T)1}I?(nq}F&WmfWw zT+_OyX{PoxHR4h?)0W=E%~?aD~_hfJYKC-S67)bCN4VKK7Vy|&@<6NDhPuJkYNyO$Z&`a#1>)) zv4=Q793f5+XNU{L72*bQhj>6dAzlz~h!120#24ZR842-+1V94!M+XHx(O`dVQP})0 zy_Hp$p8A%DB+=sEC70%wphGrFD-5NT^yr;HZ*u)2z?;PMpjcYj7#>DT{uER9=Whyn zKc-2RWqT2S7{y7(C3Q+$>MD$4iK%zBCA_CvvA$wKS;b#k!@E{1R-;YkASL(vTq0H( z78}uE-snV3Oi@KNy-Mk_Sk_cwsG4sq*CIw`gIHr+QfVl|4wg2U;~8LD54BNi+9;(q z0C7RxeQSg(H&zf1amaF8LAq@#nm`1I-OYCR1GW|tLm`h zWtf@#=U0o^`%0tuNOg_5=Bnz_>hcEBP`jws91VJ2Rpo+ZSf^>}e?2KBIBjLsRpqsk z$t^8&aAS<>WroV7env4x4B@>F(TafXXIl+5D^j#34w${!XV+0 zQIH6T8WIW7K%yX8h;F}pO!{!7{rzW$djBWQR=rJ=FH1Q>>uePz&Q=9b_*Kc3KD#XW z2^Uu}in$_r(5=f-lIk+aSELET-TlL8^ZsE0L{}t@YA1bqMap4{#7Z9 z8Jmd|*hSx7m9kZ9DD-ErGy7>FM%6~z_cN3&)DI-&?{J~s?Jgsz@S3Dyv1@46H7P@I zKk7#B9C7od@4%|Ik$sPpt9puN^uT{RZR(LGs6L_}fP|oQZ~7$7+mEuY!{28bJ$hZr z7TBjF=(Ua!BAo*(1orq++cn?8wZ-;Iek}D2<@dt<&XtkWdS#?9Uv`J0nmvOq^h!z0 ztCsw3NGZY(s{*KFQ$QTOctaW^c)vV|?%a?@v+#`+-6t&)LRw9%o-XtupVf{*o3xwo zS05fk1D^*tQKG->N?+eZdt-hXhGmwOnss6R_pc2rt6Z!5f7?|4Es9zE&_!ZR_P?XghO=fio89a-16#V+734|2 zUdS@yHWt|)EmB8UP%0ghL@%Cwqr$UvkvdbwDcrPtpXll|ly#eG+q)*Gcxr4Ho#~@} zq7!ZPM8t&l2h1MpxNqi3fBb>oRNWb*=Kd+A3b#fFQ%6o{Jmn8awlwcjunqnAr!-n9 z_~|daFS&Rl{#$*)mSy~W%hIgtj+DT{Y&NLm1SMW7+Zv6zp1vBuS=ke7noqhbrLi5^ zrunhEQVJVC#WbIIPfBFFrNA_kxf;-+4ew;%k3t;(=gb!{M$5NGfYlor;aM~W@|Z>Id_`qMr)X?_^)Zc z_L~rA9azY0evE5PjV?P!sbZWB`mO1H>p}9S+c9LV+l!YdO ziYh|g$krKAKCMxhc$(7~YC|>7SOnEuFc+P%f|)DLj2nIHf-}*wD%A2!oL$Wgx>kky zCsdD)7X)-d7M7%pcO@?pX1}VuenjQ6A{&!G#=b_Q38ZG;rYUiDn%8OaTpSpi>z$ z$;Ar|sXwiN40^^3J)VAvgS%dG61&#J!3u9Vn$4@F^&`+F(L)|i*SxVgZ#G#lB|czS zyTT-qLtD5Xo$y1K?)ad(bzdrUDzzR7ccF2&aQvS;5pLPMFHb54PcrQ{%9ugPUxz#K z)z$gJ|NHMaebN_wd%B;4JU=;F*mZRjb$%S4NC*AoG~u{w1V`~Bb*o5L~&qz5{ zSpCHyTKjo~r*gMzRJqjak7HYOF=8Z%S`>TAAIVn#NH_fD=h@`nlrfi%1jy+um{F$| zh2;gxSuEMc3~cB^AfBsH-kg~iBxkdtu^g<-QQOiC$3S-)sg=FxY_i&N$-a6Et40w) zCS{qJ=3qI8Ey*=&eRyr}2BWsgFLT;8M9ySFvl$Gd)(~v(g^ir)X;u$<=|kbw+sf&C zEoxsSm>Yc&D*H0$t(*>gRqe-fObLVa&eu7;J4~LyhVS5@JzTb?lyFq}^iDIA!Q6H$ zAd|Wya3&^?LZkfmSukft;pDIXz=C=6J+%wZwJHL+#vC&1#@gt4NmTJ;K@$G4@=sBhK;@LD&h!onz>9tWP>+e@M zaMHqgBAw9UB&^fQY3$~8Me#KIs81O)Xp;`Tp7w_s*i+^2YEK?^s)VpF4RGDt63#{` ze&fW+9<)^w!zfCIYlmRL?2xg!AKF_mhioH*NQ%Mfdng*gQe7={JEQRcf8t@m_{Ye} ztl8UwY4wa8!Tq1}kF-2sUj|xOedRx76+0SP@QsQpHMD#*61+T?gKh;ppUgq_7$j=Q z=3vhlXr7qPfoH6o!tNGwup$;$)yiTHzEd>Xr3#3lsbg`06#tEbwy|;s^I4~W3ikeb zZoYe6WB`?n#mfB>WJmf>ob1ecUNY+($uk~ayP7SS7vgaNN+wn-#xXH{+ zWbv<>fh~0y2{PL8z8sW)u9Tk-7(#S3o=&cuZ zd<3SG;JeUveYR*LvAJvsp|!uk+1<4aR>QF496 zAQO%dYn2biQ@IFnU1K&JP6KM_^b=wIkXE4+X|6_cqhm8+eRv|Q_v$$9KM4nAr_6!t zWT;=CB#&n4F=i%{?T9x6TcXKOzm#LaIOSnok5A#uiaa#rU_J-Ed3dZHr*SZU3hv6H z=^R{|g0$xgIVhScr>XXmTR!gg_ChS&w5VUFqRdmXxXI*vI4_vP!O?u=*gBU~1#&fe zrIZZQ(CR$}cr=~L6_7!?X(+OKi2@#HD{7Q6iF~KyzFkqrV{DiX^Bqe$*jp>PQ{W7A z>%tS9d1?lBvEW$_X04H&cuk&#sLB5YPA@D(h@N#E^gl1T(2YWb_<93p&c7^qQ{_Wo zqKPvpGi4jnZ@>agC#vru1Y5PmLigfk?Aq*y;o|a|1s%9$Xrur4FkDLiY1XB(zrVqO z%Nx>gYMY7Ivds=N<4kF@5bW1CIdgYAD*70VYAtmZp=bBD_u%y~|9pB>t!r^=8L|K%_>?pzuA8}=Q! YmCMUm)A!1#rz7RE&+sR&Ncffaf0+~M_5c6? delta 5193 zcmY+I30zdw9><+~!GVE+L3R*l7!YQGdst+VVc%UqkV+9y$uyt&dYYQ%%giz@&81TR zYN_}hxnw43xjZ$Y&0HRuOK#<}+)`X{O+oPe?wny5{CxhO%kO*s=YRg^f6sEqZH;>s zmk;b!)Wy20-#Y!l*`vPQy2;k-qe0GobZAP5+qMy|M}6Pktev}AtAGhG5#|JQhINN2 zVJF{xOzsl}i{3G#QQ97?>Z}W;U6(2qj8BkVeS&dK`>ZOD zvfHE>QP*Ns>2%p+4>PeP%An{TsQqmXgQmVM>jtxaUm6C;D zoTx!$$rO{%T5I*Q;r2IC8zZri;V;RkFi$!Boo~&#u$E(LyJLb=x4540`Ou;)I{KaxvbS zB@0`2$|9YH>_$*)jt}>B3A%DGcc|#j`U(TS<>Ewl)M3tkE{c^bU8ry5;;<5K>?9Yb zPU$^(&`TG(1bz2BcZm5NWk2A844baoiIP%WQPg*D*~lFf=ebr?Z%Ip+Ajyxo!-W>T z7p1EZWMw-iBV59|(-$hngdNUwN5hh+CNIo|M!JOI^l^h@cQ*&TMIEN5^)epg>wrB2 z!dxlX9e8iB9lO$8cjOip-bt3JSsy_aV+Sgls%G&*jm1vf>9!iVJk7QfA9~3Hfoluw z#D$9bhuMmC--87SG>Es=donK?>WThsqwFM4=r)#%lHGbGZ5W4%UA-%BI}N%{o*=iS zQ@sYg`c09=6hZX{hwHqM?)+J@uu#nGFmE#TKq}22aI$m>LV;-L+?p`QwK{W^?2tsd znlL}Ui_P8`D`K-9yVB7usPZCjgd2az0k?XyL}AkzPTI=C-Dt?K9SQF&3)iSS&YfCC zWobA+g5jp;!vy=XAo0mi8!tH)-km1+M+V*N@u0+{kde zw;w<9Q%-jAI)BW;Yu?G;YF@B!gYjOL{N~W6>xng+bk9yKV&$I^yw1qXnOs9yhB(tB z(vJM8R|tx+Aq2%ZIn>5mWv_%EZ0eW&k-qZnW)fR3L{e-7%cAucBYV;XJv!>O=$LTP z#^2Pk0b=|8PNk%=*TZ^{TFd+>UB?2&<4+wM3l+HI&gpRi_E#Dl%W}(!N)0TV5Ku|x zP#im_y*b$&%JK#GcrF_I8C$y;4;=*?uWid}^+$=x)(TiIR*r;-)1{e_~XB)?L zMjXPIcXF|OH=m-0%ne-%apW7>A&=f@=*aL76M~4}ahzku<)_?_HNj>JTN~wP9u17b zQa2uxJ#y%96jEAvf(y$jgD;=Zv}i;*d5+^#(JVtqzsg00gnB)>#>H8S#57KP-`3xvI$-e=JWPD@{pvDvwa9&@Org8~yJK7gTZe7MjMhZmCaxNCFFuKs;mAscp zxc0FFjM@$J<{QskKu=@SdimQA~CYTzEs)SQiho;_`JydEB(w0W!qr|Y@!SeJ^U z=6fd-kMlScQOBxmiY1xwb0rOEVV_PELhWfdww7gEQ9mALb~YBbfPavi}l@Kc`mIpM6?gdtrOpCKwra1R+Hg{DHOB9d%7R3WkxDe$_ z3v=Ked&P$Chk&AH4^=bG)KI_Kn(?3y%R1Mx9-_()$DCgR8flC`mOC-@-Kp2QqUYHnWRKCKlQp zg<*m!FpN(ThN(7l{8AAjS43OIvKYyq&J@DMr5G;vqz>vyo(;HGClxbEC{O61O1fN( zA#?LOL34p*`(^N6vE;bGq6f-O6BOfV@gRJ+XO$qcZw6UA()JP@obIJu#FrwU1!Y`p zEX844{j4lf$ayFxFue%b!A1yiD?uSB6RW zV~I?%XaON2D+h_ky(^RRYa|bO#wMX*H7DcOf>_^T!Z6gpXRVB=$u?O`62iZhpJ{Yx2i8m42qf^;b{?R77yi4)x*L8N zf$SB#==Tw9qHuOMeOZnC7L7!d2YY0ZN+0jT+tyU0ckVZwT(6hZe32*r*d^Itzmpv% zD;7~*HO5Ui(6JfbejcOz`ylUr?s?o|w-0kMWE2*_?4z= extras) { /// a new one, and the one it replaced is deleted only when no menu names /// it any more -- so an entry can only go stale for a photo nobody asks /// for, and lives `cn1.cache.photos.ttl` seconds. - @Cacheable(value = "photos", key = "#id") + @Cacheable(value = "photos", key = "#p0") @Transactional(readOnly = true) public PhotoDto photo(String id) { Photo photo = id == null || id.length() > 32 ? null : photos.findById(id); From c0c9864b43b4980ff0d31ad4c75d056395cddea7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 11 Nov 2026 07:46:33 +0200 Subject: [PATCH 11/11] Savor: drop @Cacheable until a release carries it The Initializr template builds against the released framework, which has no backend cache annotations yet, so the shipped Savor failed to compile. Co-Authored-By: Claude Opus 5.5 --- .../src/main/resources/savor-modules.zip | Bin 3108971 -> 3108381 bytes scripts/savor/backend/application.properties | 4 ---- .../examples/savor/catalog/Catalog.java | 8 -------- 3 files changed, 12 deletions(-) diff --git a/scripts/initializr/common/src/main/resources/savor-modules.zip b/scripts/initializr/common/src/main/resources/savor-modules.zip index deea243de797d57a6c6c6083683128208620c2d0..9f50eedc092f064cd29deacaf62b0cea619f6df3 100644 GIT binary patch delta 5124 zcmY*d30RcX8fN|<7#tWFmSGuW1_p$g0se!cAlo1)3Ti5vx!?ks3%Q_{S|sjESx?C| z{RHYLkELAV-MmmP%`a3;5 z{GF6rWzC|B8p{DHO|lH33>qycF$US#PM-KjBSjist1jSr1aTv~O=0*Qvi zK&+5hNF1aCBp#9gu|ez*2ZZ}1L6RG*4k?%8bWi$1Gp;EkBxYIhouxfpxu!TYQDnNV z6v<=eOZ0HQ6i3UhgM6MozOEE%=FvmwG>KGrL+K?gPNeD^N)OFTbmWFoApNnDqHZcZ zL8}BJO^4R%NG(XXjMxpnWRC)D3RJ7o}(n;PzD*Efb(pj#nRnhPV zuw3x2iuON1l=`n#l>Jaimm99BX!}Fx(jT-UCm9|_1yjx=On+mXnI~YpDJJ==R5#^Hg{4T%yH=L6N_o~rRWaa_Ie@ll;E<_dCOPUD4?@ zwZ;-m%Vk6kKKg|6C&`ng%XO!pP+EHG9OAy|3Dapxw98vdrjF4@K1+xEk<*CfMMb@?$N1Cu5LhknVJ7K_CspfMSIWH}<3sbbRzJGn!yM8;h10oo zFU1;2KHjjN*$&pFoj6@U0bQ&H(f(VU9@az9Lv<`&y4i<=m!(ky1gRfn)lrErg03FM znOxfM%d({7l^i%J&ksH)Rtm7&kL60QuM;3$&$1;)od6AbmLvIY6(Gf*rAj~Tasf}O z`q=7A4!$3JKernE9xq{?=7ZhBs(k>`)HVsQB7kK`eZCaH&%iRJCEp039I%E^tpUaE zJtUY3KUo7fW1!cMwu#Js5e8L(tgH0uX#rBsp@T(wSoiv?jrGL~E(0yq{${LEl5wGUM$n01jFy=Y%J+IBXWc~eOUOOw2P1@pc>)}PLV zz+s$GF#h4OI_f$K#YC86-J8-BDGZv7=tFFr0N=;Q`cb?D>v5ghSnE=R!S+yO+n6PQ zHH>wZ9_0(Lr65)>TpPl$9qM`sS|5(^)SrVwS{}}r^k57Jvn7uS!lEhyJK@Et9HdjJ ziD~I}1k8$NxEQCTo8tmnI&DJQ*XO$!PfA%78%)iSD0_M&a$l`>(|s3-@N?G*CVFFR zC_hqxw6Y;Kz-^0k-RUxDM|;|{PSTm51vBrLSR>^|p{3??f|*(p=Sw@H5ccp;+82X^ zv-ejtImyiOq>56`R5Y1F!~srfS1k-uqX!g1YYA{y0qves_uYp8pgzVQ{pQf ze5uL`#~&66`m7bU314xLMF;nHFwtxL+FcNfbRQk&w3AN8qNFQFImo9q%^gCiA`T&n zn+0hTy1MqTER6YtruXug6cyy(K&@J zmQpth^2`>SNjN&{+mv}|i!i9Hx0z``3Kk@ByI{UZVcn(r_XT)nr!8FgPwt4sY1rId zF#F4mHg7uLU<(tvJ6xCdu}i0RW??7LpM2(`y{Wv(79d=-sn8AG>!zEO%A8XEe!*Nx zWd+jA11k8|=0$TlqaT%r1arSL=Gx(i01LXH3u}*auvpTZ$AI)#hXU4zCfQV!d7O@^tUdl=UL? z)y;Mvn%f=d@&vX@)&PdWzzrHA3`-e(xJ)dM0Z_bgva4f%~^18p1 z)U>^y0$*lI7HQW#g|yiY6`e{-Rww%H7o^Hte9WV;Vq9lm-sT`{)j;M+ z)y4Q!_|uPK2C+RMVM{ zIn*s3G0vAD*_Y4YAg)NG;-PrjqK9C1iCta5hpv8?;6>YpFh#0%3OZ^irZc-A2Q#Iy zgL%9x(hXI7DPS0Csv9DhqTx!Q$Uwu8;U=yOZg_bZ>n8OW?gCo6Is)_FHW8&P8xF&- zN4V+!9FE#+M)7%PQcf9upiL@a4$T)7Q-WzXm!Q*wo)g;sBXIpJ8!Lb^0p%PVfpX@Q z)3lLjTIZ2C>$jfg;6-WJEI#IweG~$Wf0=_U+As>0jemvCjY6NQXNy2x<|;u{Q;gz1 z8ja$#nkmM@MFTD(Jc6QAg({0gmrc{DsZ#y=_V#@r5ubpMV^f_HZy$%J@=r z?bKJ;uchV474)@W_Ln1Q@_qp_p9h%zjR4UHlmKda9=0Jr2xj+T?3~_%v5MU%@>M)6 z=($I*3hzurUr+qU#e|A8m_7-H2}fMCj=ulYqN6j%6faSI_{lca|LeFgEj@`Am?tCo zoo2zTJEf>=wRJN7b72+zGzkm*a5A3G0q2CttSS7;_^$w$reH1?e^bL%p!@#63$W|Y d$L;<4Vw>dU=Y@g(5>{mJRA%s=bXCDa^nYdg%%}hW delta 5615 zcmZ9Q30zdw7sr|Rgn@y90oh?@o-n}7F!L@Ti)_OVA|yzziOK*YAmc!S68&?_)D|nv z@`(Nu?O$rNXpz~bq*-ZcYGrPPmZg}cf;(>i^X87jke|=-@%!F$&OP_s`xbcp*Bzn@ zr+0|9#=1$Xj~&k#?eF9ym||s#vpKtRU(cDnOHwr_VRIa72xt6Q4#PaoX_}R0}T$8C;$JJT5GE;ROMp~-5rdo`z#%z_X zoN10}Rx1uSH5)BeCSD>0S~JspO?-2+IemCyOpK&-QH*kFj8cTgps{E}(45g+(8Or2 zXl`f{Gv%^8GgT};^5Fx zr4BN)l)lh#Q4zLXwnlj^c0M6Q!r?!>az&=bW}!{ZwI;5@+RLU2+G8EpQd?J5`>$5f zE;exu*fx8Tih;gYa2DfC6E>VSJ_UcUX60}bEJD}0s`<4>OO2^ohdIox+$>Y0*;s`; zTG?vPXGGT)95(5!8z$#@+4X8Pjr5R5^75-$6uW8`$ESOE)^oCjXnk@~L4b`ojv8levo(;!oRpz=m zxK8WR|9x^Q@bpzRSgM9~4RtDwUhV?d^g^n>+J z;O3M4N;D2_7+M%wIGPGA0xc3P3QdisLDQl|qv_D}+sS_Zl1eggM?q9CU(URzTv?m39EH!#7QkbMK^dZ3x?m01_#sScHZKH zMR(zcTUb^3Sn#{er!sy`P7ed zhab*3ZGx;jd^4l|Mz|)!l{|}~t+(;S|HJ6I3ZkR{2nu-|`lEEBrr{cT> z-JaM;g~^?yGm|=~_`{tHXO!Ehi0%yYf;KUh-RyyL-}{W>A3QK`PZt$=Qj*NH?V)0) z6xZ_eJ}ORs8|Dkeo>-mdVS(g;@*viGM}ql|cw)Y}M+H2G`Q_QCZFNL|odooPAq~8H6yh%LM;{>roNd~MGgtIFZu0AA{87rmY86T|tqW~)I z`ruOC6@qAC9*-2RnJ`}!E}`uXj0tzV^(MwqhYDXzekq-byQ$%>(Cv%WJd#Jr{iiqDkfBi`_j3N_@nRW8PMxbTAAImgyAHZGA~>V z9Rb)5XmKDxfw+c=3+%)jmIYx)I|4Dq@kJDOFANW$SQUiW@G8YEL3joZv_ot#uJq*V z;Zk}-<$o|}rAs;mhgppN2z5;TDcplTA4DOT%zTC>8yiCM8Tn)w7K%sy=MYlF?5GsP zY(_r|yyYYd(i&ABbba;nRF1uTXMx}_hspR}2uO#mN_^;*>{Hq5>5=0!jmI6v793T% z(-JyP4$AcRZw_8v3f$0dE;x`xC2sz->vodB{`h1BYdX@{4gwq`4LKokrp=ngmpX}3XkEq^}dJ=!{mM4f*9B@{*Hp!4eBg79YONh8y9Wr;N2)ONH0VM!}=(!eQhLW?z%rPsGvM53fJ_i z8!Xh~Bg`7JEQ%PIEuNHgd8k9EW3n0@+vF6#9ik4U_$vV)NE7fV&}c{&vn-#AV>L=I zxThz6a6*GVJBumK)Z(!_Q!0o>OxGIWngfScs{?7i#I!p&fKUw^?mlWAeL$a<;xt9U6J`zO~gZoI$!pJ(QNA zBv|q@oiHYb6vEmM)BzN~BjA^}Q!IS-h^Lv2yD3z}V)vHs6+|9<^%g%apagj|Lx;2R828``#j?u}*HxQxQf!deyrAf%0oi+6Q{gs3-(j#IG>{=sFgcHu1AefhowJsZh zUh(cWerg0MU^PBAH7%JGvL6K4)W?1r8GS~GQb@2U7FMK?G)M{3c+yP!LNtyaEp7^1 zoM)&ekb3lD9Gf0#Db<@l@wp7&uI28 zHhyS)v5xwN!qhC{$@Fe;7+ar(Q5|pDNd}v{Sp&vWT_W7iCN5C2QR4+`v+)6A{%U|H zv!9)^sm2_V!Y=mI(wOHxv{JZ|gYFt%8{&Pmeo&i>sKhXUT%kM-d5^#+6x(?DDD6;~ znuq9oqYYiMXuXAK8T^rlyLfo9jsLPp8vw=m==#%pHnb%lpNwCgu&IawGLj8cMboAALeUI|_GrdoRUbT-JHR#ZfrbmQX#Ny`%`=9nb4*>Se7yK$utt zPuA-l&)jD-R~3_b_Dq}2u?O@%bnTlC>K)Jcxx+S(E4{J^i4%^p>D;tI6E-(jU`AQL!V+vZYnpd7n;Y91Bm zp2GF*n}Pw;r(l5J5{k)G{8HOB1v5Xl!cLNyv0xX@uxTouiHJ4_qL_xao_jkbFHOT+ zuXa5Zjq7+X2&%w^e7f0A#IScG?@603-po7RL(42PG2vc5-9^RoJ8;3)_!&C~#dmy39fGH0^(nilcyT(u zjh61A;^o~Brw*&ZEvfhxyzB6`YOTS5|Lhe+8eFQu-ha1`ippA&B&q~i9p1g>0}nfT zy;j)I9|e~=jOG{NnhOW&FmCoSD%KuUN?_F_{EYkV_@EjkzX}ei&@}^3O2Kcmtdf(w zFN~_k1^?Mi$<}&2i|fu%p)up?Wq(nz&WuMt`5YBKGqKa extras) { } /// A photo, to anyone: menus are public. - /// - /// Kept in the `photos` cache, which every instance shares once - /// `cn1.kv.url` names a Redis: a photo is never changed, only replaced by - /// a new one, and the one it replaced is deleted only when no menu names - /// it any more -- so an entry can only go stale for a photo nobody asks - /// for, and lives `cn1.cache.photos.ttl` seconds. - @Cacheable(value = "photos", key = "#p0") @Transactional(readOnly = true) public PhotoDto photo(String id) { Photo photo = id == null || id.length() > 32 ? null : photos.findById(id);