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..6b6917972fc --- /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 = "#p0") + public Order find(long id) { + return load(id); + } + + @CacheEvict(value = "orders", key = "#p0.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..b9fcd474bab --- /dev/null +++ b/docs/developer-guide/Backend-Messaging.asciidoc @@ -0,0 +1,242 @@ +[[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 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. + +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 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. + +=== 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 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 + 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 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. + +=== 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 d0201cc391d..a0601ed86f3 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[] diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index 5b1645e104d..4d72dba17fc 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -903,6 +903,9 @@ picocli subcommand subcommands transpiles +Redis +Valkey +zstd minimap killfeed paintballs 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..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 @@ -97,6 +97,22 @@ 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;"; + 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 +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, - 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))); @@ -138,6 +155,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"; @@ -257,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; @@ -345,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(); @@ -394,6 +435,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]. @@ -463,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 @@ -618,7 +672,9 @@ 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 + || 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 " @@ -739,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) { @@ -1710,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; @@ -1785,13 +1868,217 @@ 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); } } + /// 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, 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) { + 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"); + } + 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); + 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"); + 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. + 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) { + 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)) { @@ -2203,6 +2490,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."); @@ -2237,6 +2529,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) { @@ -2253,6 +2546,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() @@ -2332,6 +2626,15 @@ private void resolvePoint(Bean owner, Point p, AnnotatedClass where) { p.builtin = "config"; return; } + if (KEY_VALUE_TYPE.equals(type)) { + 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; @@ -2767,9 +3070,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 +3297,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 +3322,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 +3357,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 +3592,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(); @@ -3467,11 +3889,20 @@ 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); } } + 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..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 @@ -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"); + } + + 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..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,14 @@ 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. + return "com.codename1.backend.kv.KeyValueStores.current()"; + } if ("repository".equals(p.builtin)) { return model.repositories.get(p.type.getInternalName()); } @@ -822,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); @@ -868,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/CacheKeyCompiler.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/CacheKeyCompiler.java new file mode 100644 index 00000000000..0b7fff2464b --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/CacheKeyCompiler.java @@ -0,0 +1,302 @@ +/* + * 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(", "); + } + keyable(args[i], "parameter " + (i + 1)); + 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 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. + 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]); + } + keyable(type, "\"" + t + "\""); + return "com.codename1.impl.backend.CacheRuntime.part(" + box(type, 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" : "")); + } + + /// 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) { + 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/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 1cf4dee77ae..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 @@ -3940,6 +3942,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 +3965,27 @@ 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; + } + + /// 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 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..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 @@ -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; @@ -1506,6 +1507,421 @@ 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(); + } + } + + /// 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 = { + // 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"}, + {"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(); + 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); + // 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()}); + } + String kafka = System.getenv("CN1_TEST_KAFKA"); + 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"); + 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. + /// 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 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 @@ -3634,6 +4050,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/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/demo/msgcheck/com/demo/MsgCheck.java b/vm/backend/demo/msgcheck/com/demo/MsgCheck.java new file mode 100644 index 00000000000..18732e55048 --- /dev/null +++ b/vm/backend/demo/msgcheck/com/demo/MsgCheck.java @@ -0,0 +1,292 @@ +/* + * 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.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; +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 { + // 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()); + 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); + 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 + } + 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"); + 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 { + // One key: Kafka orders a partition, and spreads unkeyed records over all. + for (int i = 0; i < 50; i++) { + 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() { + 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(); + // 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 { + 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 ec9d94aa006..1194e16bb25 100644 --- a/vm/backend/src/com/codename1/backend/Access.java +++ b/vm/backend/src/com/codename1/backend/Access.java @@ -355,4 +355,20 @@ 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(); + } + + @Override + public Runnable inServer(Runnable work) { + return Tasks.inServer(work); + } } 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..b3d2bf2287a 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; @@ -151,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(); @@ -178,6 +196,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 +467,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/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/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/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..8dce50e690e --- /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://...`, `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 | +/// +/// 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..c8c9f0c449b --- /dev/null +++ b/vm/backend/src/com/codename1/backend/messaging/OutboxTransport.java @@ -0,0 +1,252 @@ +/* + * 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.ArrayList; +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; + /// 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); + } + + 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() { + synchronized (pause) { + committed.add(new Object[] {Long.valueOf(id), message}); + pause.notifyAll(); + } + } + }); + } + + /// 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 { + broker.send(message); + } 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 | RuntimeException err) { + 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(); + 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(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) { + 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() { + long nextSweep = 0; + while (running.get()) { + 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 | 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; + } + synchronized (pause) { + try { + long left = nextSweep - System.currentTimeMillis(); + if (running.get() && committed.isEmpty() && left > 0) { + pause.wait(left); + } + } 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..39eb4c45901 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/messaging/PollingTransport.java @@ -0,0 +1,269 @@ +/* + * 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 | RuntimeException 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 | RuntimeException 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() { + boolean failing = false; + while (running.get()) { + List batch; + try { + batch = poll(destination, group, 1, 1000); + } 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; + } + 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); + } + } + } + + 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/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; 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..d746a9daca4 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/messaging/kafka/KafkaConnection.java @@ -0,0 +1,539 @@ +/* + * 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. + /// @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 next; + } + 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(); + int lastDelta = batch.int32(); + next = Math.max(next, base + lastDelta + 1); + 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)); + } + } + } + return next; + } + + 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..8288fe7a2a6 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/messaging/kafka/KafkaTransport.java @@ -0,0 +1,1251 @@ +/* + * 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(); + long next = KafkaConnection.records(set, p.fetchOffset, records); + synchronized (this) { + if (parts.get(p.name()) == p) { // NOPMD CompareObjectsWithEquals - still this member's + p.fetchOffset = Math.max(p.fetchOffset, next); + if (!records.isEmpty()) { + p.buffer.addAll(records); + 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; 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..c8d3bac739e --- /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, 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. +package com.codename1.backend.messaging; 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/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/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; 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..efff72c5f7a 100644 --- a/vm/backend/src/com/codename1/impl/backend/BackendAccess.java +++ b/vm/backend/src/com/codename1/impl/backend/BackendAccess.java @@ -271,4 +271,28 @@ 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(); + + /// `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); } 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..f5d3f3bf32b --- /dev/null +++ b/vm/backend/src/com/codename1/impl/backend/CacheRuntime.java @@ -0,0 +1,244 @@ +/* + * 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 part(parts[0]); + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < parts.length; i++) { + if (i > 0) { + sb.append(','); + } + String s = part(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(); + } + + /// 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; + } + + /// 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; + } +}