diff --git a/CHANGELOG.md b/CHANGELOG.md index 57a5c2d4..5b3d9ac6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## 26.0-SNAPSHOT - unreleased +### 💥 Breaking Changes (upgrade difficulty: 🟢 TRIVIAL - change to runOnInstance signature.) + +### 🎁 New Features +* `BaseController.runOnInstance` now performs the json serialization on the target instance. This +allows lighter-weight remote endpoint executions, that do not require object serialization. +Applications must now provide a `ClusterJsonRequest` to this method rather than a `ClusterRequest` + ### ⚙️ Technical * Align all-built in log names to have form "App-Cluster-xxx.log" diff --git a/grails-app/controllers/io/xh/hoist/BaseController.groovy b/grails-app/controllers/io/xh/hoist/BaseController.groovy index a45c861c..98255e76 100644 --- a/grails-app/controllers/io/xh/hoist/BaseController.groovy +++ b/grails-app/controllers/io/xh/hoist/BaseController.groovy @@ -9,6 +9,8 @@ package io.xh.hoist import grails.async.Promise import groovy.transform.CompileStatic +import io.xh.hoist.cluster.ClusterJsonRequest +import io.xh.hoist.cluster.ClusterJsonResponse import io.xh.hoist.cluster.ClusterRequest import io.xh.hoist.cluster.ClusterService import io.xh.hoist.exception.ExceptionHandler @@ -88,18 +90,15 @@ abstract class BaseController implements LogSupport, IdentitySupport { * Note: Any exception that occurs is assumed to be logged on the target instance * and will not be logged on the instance running this action. */ - protected void runOnInstance(ClusterRequest task, String instance) { - def ret = clusterService.submitToInstance(task, instance) - if (ret.exception) { - // Just render exception, was already logged on target instance - xhExceptionHandler.handleException(exception: ret.exception, renderTo: response) - return - } - renderJSON(ret.value) + protected void runOnInstance(ClusterJsonRequest task, String instance) { + ClusterJsonResponse ret = clusterService.submitToInstance(task, instance) + response.setStatus(ret.httpStatus) + response.setContentType('application/json; charset=UTF-8') + render(ret.json) } /** Run a task on the primary cluster instance. */ - protected void runOnPrimary(ClusterRequest task) { + protected void runOnPrimary(ClusterJsonRequest task) { runOnInstance(task, clusterService.primaryName) } diff --git a/grails-app/controllers/io/xh/hoist/admin/MonitorResultsAdminController.groovy b/grails-app/controllers/io/xh/hoist/admin/MonitorResultsAdminController.groovy index f760b93f..7f8b4002 100644 --- a/grails-app/controllers/io/xh/hoist/admin/MonitorResultsAdminController.groovy +++ b/grails-app/controllers/io/xh/hoist/admin/MonitorResultsAdminController.groovy @@ -8,7 +8,7 @@ package io.xh.hoist.admin import io.xh.hoist.BaseController -import io.xh.hoist.cluster.ClusterRequest +import io.xh.hoist.cluster.ClusterJsonRequest import io.xh.hoist.security.Access import static io.xh.hoist.util.Utils.getAppContext @@ -25,9 +25,10 @@ class MonitorResultsAdminController extends BaseController { def forceRunAllMonitors() { runOnPrimary(new ForceRunAllMonitors()) } - static class ForceRunAllMonitors extends ClusterRequest { + static class ForceRunAllMonitors extends ClusterJsonRequest { def doCall() { appContext.monitorService.forceRun() + [success: true] } } } diff --git a/grails-app/controllers/io/xh/hoist/admin/cluster/ClusterAdminController.groovy b/grails-app/controllers/io/xh/hoist/admin/cluster/ClusterAdminController.groovy index ddce2adf..e7b537c7 100644 --- a/grails-app/controllers/io/xh/hoist/admin/cluster/ClusterAdminController.groovy +++ b/grails-app/controllers/io/xh/hoist/admin/cluster/ClusterAdminController.groovy @@ -8,7 +8,7 @@ package io.xh.hoist.admin.cluster import io.xh.hoist.BaseController -import io.xh.hoist.cluster.ClusterRequest +import io.xh.hoist.cluster.ClusterJsonRequest import io.xh.hoist.security.Access import io.xh.hoist.util.Utils @@ -42,7 +42,7 @@ class ClusterAdminController extends BaseController { // Wait enough to let async call below complete sleep(5 * SECONDS) } - static class ShutdownInstance extends ClusterRequest { + static class ShutdownInstance extends ClusterJsonRequest { def doCall() { // Run async to allow this call to successfully return. task { diff --git a/grails-app/controllers/io/xh/hoist/admin/cluster/ConnectionPoolMonitorAdminController.groovy b/grails-app/controllers/io/xh/hoist/admin/cluster/ConnectionPoolMonitorAdminController.groovy index 588b9317..84483fa0 100644 --- a/grails-app/controllers/io/xh/hoist/admin/cluster/ConnectionPoolMonitorAdminController.groovy +++ b/grails-app/controllers/io/xh/hoist/admin/cluster/ConnectionPoolMonitorAdminController.groovy @@ -7,7 +7,7 @@ package io.xh.hoist.admin.cluster import io.xh.hoist.BaseController -import io.xh.hoist.cluster.ClusterRequest +import io.xh.hoist.cluster.ClusterJsonRequest import io.xh.hoist.security.Access import static io.xh.hoist.util.Utils.appContext @@ -18,7 +18,7 @@ class ConnectionPoolMonitorAdminController extends BaseController { def snapshots(String instance) { runOnInstance(new Snapshots(), instance) } - static class Snapshots extends ClusterRequest { + static class Snapshots extends ClusterJsonRequest { def doCall() { def svc = appContext.connectionPoolMonitoringService return [ @@ -34,7 +34,7 @@ class ConnectionPoolMonitorAdminController extends BaseController { def takeSnapshot(String instance) { runOnInstance(new TakeSnapshot(), instance) } - static class TakeSnapshot extends ClusterRequest { + static class TakeSnapshot extends ClusterJsonRequest { def doCall() { appContext.connectionPoolMonitoringService.takeSnapshot() } @@ -44,7 +44,7 @@ class ConnectionPoolMonitorAdminController extends BaseController { def resetStats() { runOnInstance(new ResetStats()) } - static class ResetStats extends ClusterRequest { + static class ResetStats extends ClusterJsonRequest { def doCall() { appContext.connectionPoolMonitoringService.resetStats() } diff --git a/grails-app/controllers/io/xh/hoist/admin/cluster/EnvAdminController.groovy b/grails-app/controllers/io/xh/hoist/admin/cluster/EnvAdminController.groovy index f29c56a9..5156da28 100644 --- a/grails-app/controllers/io/xh/hoist/admin/cluster/EnvAdminController.groovy +++ b/grails-app/controllers/io/xh/hoist/admin/cluster/EnvAdminController.groovy @@ -7,7 +7,7 @@ package io.xh.hoist.admin.cluster import io.xh.hoist.BaseController -import io.xh.hoist.cluster.ClusterRequest +import io.xh.hoist.cluster.ClusterJsonRequest import io.xh.hoist.security.Access import static io.xh.hoist.util.Utils.isSensitiveParamName @@ -19,7 +19,7 @@ class EnvAdminController extends BaseController { def index(String instance) { runOnInstance(new Index(), instance) } - static class Index extends ClusterRequest { + static class Index extends ClusterJsonRequest { def doCall() { [ environment: System.getenv().collectEntries { diff --git a/grails-app/controllers/io/xh/hoist/admin/cluster/HzObjectAdminController.groovy b/grails-app/controllers/io/xh/hoist/admin/cluster/HzObjectAdminController.groovy index e6f8d31a..e98fff36 100644 --- a/grails-app/controllers/io/xh/hoist/admin/cluster/HzObjectAdminController.groovy +++ b/grails-app/controllers/io/xh/hoist/admin/cluster/HzObjectAdminController.groovy @@ -7,7 +7,7 @@ package io.xh.hoist.admin.cluster import io.xh.hoist.BaseController -import io.xh.hoist.cluster.ClusterRequest +import io.xh.hoist.cluster.ClusterJsonRequest import io.xh.hoist.security.Access import static io.xh.hoist.util.Utils.appContext @@ -20,7 +20,7 @@ class HzObjectAdminController extends BaseController { runOnInstance(new ListObjects(), instance) } - static class ListObjects extends ClusterRequest { + static class ListObjects extends ClusterJsonRequest { def doCall() { appContext.clusterAdminService.listObjects() } @@ -31,7 +31,7 @@ class HzObjectAdminController extends BaseController { runOnInstance(new ClearObjects(names: params.list('names')), instance) } - static class ClearObjects extends ClusterRequest { + static class ClearObjects extends ClusterJsonRequest { List names def doCall() { @@ -45,7 +45,7 @@ class HzObjectAdminController extends BaseController { runOnInstance(new ClearHibernateCaches(), instance) } - static class ClearHibernateCaches extends ClusterRequest { + static class ClearHibernateCaches extends ClusterJsonRequest { def doCall() { appContext.clusterAdminService.clearHibernateCaches() return [success: true] diff --git a/grails-app/controllers/io/xh/hoist/admin/cluster/LogViewerAdminController.groovy b/grails-app/controllers/io/xh/hoist/admin/cluster/LogViewerAdminController.groovy index 0b787a80..ee326ff7 100644 --- a/grails-app/controllers/io/xh/hoist/admin/cluster/LogViewerAdminController.groovy +++ b/grails-app/controllers/io/xh/hoist/admin/cluster/LogViewerAdminController.groovy @@ -10,6 +10,7 @@ package io.xh.hoist.admin.cluster import groovy.io.FileType import io.xh.hoist.BaseController import io.xh.hoist.cluster.ClusterRequest +import io.xh.hoist.cluster.ClusterJsonRequest import io.xh.hoist.configuration.LogbackConfig import io.xh.hoist.security.Access import io.xh.hoist.exception.RoutineRuntimeException @@ -23,7 +24,7 @@ class LogViewerAdminController extends BaseController { runOnInstance(new ListFiles(), instance) } - static class ListFiles extends ClusterRequest { + static class ListFiles extends ClusterJsonRequest { def doCall() { def logRootPath = appContext.logReaderService.logDir.absolutePath, files = availableFiles.collect { @@ -58,7 +59,7 @@ class LogViewerAdminController extends BaseController { ) } - static class GetFile extends ClusterRequest { + static class GetFile extends ClusterJsonRequest { String filename Integer startLine Integer maxLines @@ -86,19 +87,19 @@ class LogViewerAdminController extends BaseController { return } - render( - file: ret.value, - fileName: filename, - contentType: 'application/octet-stream' - ) + render(ret) } - static class Download extends ClusterRequest { + static class Download extends ClusterRequest { String filename - - File doCall() { + Map doCall() { if (!availableFiles[filename]) throwUnavailable(filename) - return appContext.logReaderService.get(filename) + def file = appContext.logReaderService.get(filename) + [ + file: file.bytes, + fileName: filename, + contentType: 'application/octet-stream' + ] } } @@ -112,7 +113,7 @@ class LogViewerAdminController extends BaseController { runOnInstance(new DeleteFiles(filenames: params.list('filenames')), instance) } - static class DeleteFiles extends ClusterRequest { + static class DeleteFiles extends ClusterJsonRequest { List filenames def doCall() { @@ -138,7 +139,7 @@ class LogViewerAdminController extends BaseController { runOnInstance(new ArchiveLogs(daysThreshold: daysThreshold), instance) } - static class ArchiveLogs extends ClusterRequest { + static class ArchiveLogs extends ClusterJsonRequest { Integer daysThreshold def doCall() { diff --git a/grails-app/controllers/io/xh/hoist/admin/cluster/MemoryMonitorAdminController.groovy b/grails-app/controllers/io/xh/hoist/admin/cluster/MemoryMonitorAdminController.groovy index 0378394e..828a3ea6 100644 --- a/grails-app/controllers/io/xh/hoist/admin/cluster/MemoryMonitorAdminController.groovy +++ b/grails-app/controllers/io/xh/hoist/admin/cluster/MemoryMonitorAdminController.groovy @@ -8,7 +8,7 @@ package io.xh.hoist.admin.cluster import io.xh.hoist.BaseController -import io.xh.hoist.cluster.ClusterRequest +import io.xh.hoist.cluster.ClusterJsonRequest import io.xh.hoist.security.Access import static io.xh.hoist.util.Utils.appContext @@ -21,7 +21,7 @@ class MemoryMonitorAdminController extends BaseController { def snapshots(String instance) { runOnInstance(new Snapshots(), instance) } - static class Snapshots extends ClusterRequest { + static class Snapshots extends ClusterJsonRequest { def doCall() { appContext.memoryMonitoringService.snapshots } @@ -31,7 +31,7 @@ class MemoryMonitorAdminController extends BaseController { def takeSnapshot(String instance) { runOnInstance(new TakeSnapshot(), instance) } - static class TakeSnapshot extends ClusterRequest { + static class TakeSnapshot extends ClusterJsonRequest { def doCall() { appContext.memoryMonitoringService.takeSnapshot() } @@ -42,7 +42,7 @@ class MemoryMonitorAdminController extends BaseController { def requestGc(String instance) { runOnInstance(new RequestGc(), instance) } - static class RequestGc extends ClusterRequest { + static class RequestGc extends ClusterJsonRequest { def doCall() { appContext.memoryMonitoringService.requestGc() } @@ -52,7 +52,7 @@ class MemoryMonitorAdminController extends BaseController { def dumpHeap(String filename, String instance) { runOnInstance(new DumpHeap(filename: filename), instance) } - static class DumpHeap extends ClusterRequest { + static class DumpHeap extends ClusterJsonRequest { String filename def doCall() { diff --git a/grails-app/controllers/io/xh/hoist/admin/cluster/ServiceManagerAdminController.groovy b/grails-app/controllers/io/xh/hoist/admin/cluster/ServiceManagerAdminController.groovy index 788d9840..2238894e 100644 --- a/grails-app/controllers/io/xh/hoist/admin/cluster/ServiceManagerAdminController.groovy +++ b/grails-app/controllers/io/xh/hoist/admin/cluster/ServiceManagerAdminController.groovy @@ -7,7 +7,7 @@ package io.xh.hoist.admin.cluster import io.xh.hoist.BaseController -import io.xh.hoist.cluster.ClusterRequest +import io.xh.hoist.cluster.ClusterJsonRequest import io.xh.hoist.security.Access import static io.xh.hoist.util.Utils.appContext @@ -18,7 +18,7 @@ class ServiceManagerAdminController extends BaseController { def listServices(String instance) { runOnInstance(new ListServices(), instance) } - static class ListServices extends ClusterRequest { + static class ListServices extends ClusterJsonRequest { def doCall() { appContext.serviceManagerService.listServices() } @@ -28,7 +28,7 @@ class ServiceManagerAdminController extends BaseController { def task = new GetStats(name: name) runOnInstance(task, instance) } - static class GetStats extends ClusterRequest { + static class GetStats extends ClusterJsonRequest { String name def doCall() { appContext.serviceManagerService.getStats(name) @@ -40,7 +40,7 @@ class ServiceManagerAdminController extends BaseController { def task = new ClearCaches(names: params.list('names')) instance ? runOnInstance(task, instance) : runOnAllInstances(task) } - static class ClearCaches extends ClusterRequest { + static class ClearCaches extends ClusterJsonRequest { List names def doCall() { diff --git a/grails-app/controllers/io/xh/hoist/admin/cluster/WebSocketAdminController.groovy b/grails-app/controllers/io/xh/hoist/admin/cluster/WebSocketAdminController.groovy index a0dea425..d14e6ab1 100644 --- a/grails-app/controllers/io/xh/hoist/admin/cluster/WebSocketAdminController.groovy +++ b/grails-app/controllers/io/xh/hoist/admin/cluster/WebSocketAdminController.groovy @@ -8,7 +8,7 @@ package io.xh.hoist.admin.cluster import io.xh.hoist.BaseController -import io.xh.hoist.cluster.ClusterRequest +import io.xh.hoist.cluster.ClusterJsonRequest import io.xh.hoist.security.Access import static io.xh.hoist.util.Utils.getAppContext @@ -19,9 +19,9 @@ class WebSocketAdminController extends BaseController { def allChannels(String instance) { runOnInstance(new AllChannels(), instance) } - static class AllChannels extends ClusterRequest { + static class AllChannels extends ClusterJsonRequest { def doCall() { - appContext.webSocketService.allChannels*.formatForJSON() + appContext.webSocketService.allChannels } } @@ -29,7 +29,7 @@ class WebSocketAdminController extends BaseController { def pushToChannel(String channelKey, String topic, String message, String instance) { runOnInstance(new PushToChannel(channelKey: channelKey, topic: topic, message: message), instance) } - static class PushToChannel extends ClusterRequest { + static class PushToChannel extends ClusterJsonRequest { String channelKey String topic String message diff --git a/grails-app/services/io/xh/hoist/cluster/ClusterService.groovy b/grails-app/services/io/xh/hoist/cluster/ClusterService.groovy index 2fcb9ebd..0b2a8145 100644 --- a/grails-app/services/io/xh/hoist/cluster/ClusterService.groovy +++ b/grails-app/services/io/xh/hoist/cluster/ClusterService.groovy @@ -16,6 +16,8 @@ import io.xh.hoist.util.Utils import org.springframework.boot.context.event.ApplicationReadyEvent import org.springframework.context.ApplicationListener +import java.util.concurrent.Callable + class ClusterService extends BaseService implements ApplicationListener { /** @@ -143,11 +145,11 @@ class ClusterService extends BaseService implements ApplicationListener ClusterResponse submitToInstance(ClusterRequest c, String instance) { - executorService.submitToMember(c, getMember(instance)).get() + T submitToInstance(Callable c, String instance) { + executorService.submitToMember(c, getMember(instance)).get() } - Map> submitToAllInstances(ClusterRequest c) { + Map submitToAllInstances(Callable c) { executorService .submitToAllMembers(c) .collectEntries { member, f -> [member.getAttribute('instanceName'), f.get()] } diff --git a/src/main/groovy/io/xh/hoist/cluster/ClusterJsonRequest.groovy b/src/main/groovy/io/xh/hoist/cluster/ClusterJsonRequest.groovy new file mode 100644 index 00000000..c2a747f2 --- /dev/null +++ b/src/main/groovy/io/xh/hoist/cluster/ClusterJsonRequest.groovy @@ -0,0 +1,46 @@ +package io.xh.hoist.cluster + +import io.xh.hoist.exception.InstanceNotAvailableException +import io.xh.hoist.log.LogSupport +import io.xh.hoist.util.Utils + +import java.util.concurrent.Callable + +import static io.xh.hoist.json.JSONSerializer.serialize +import static io.xh.hoist.util.Utils.isInstanceReady +import static org.apache.hc.core5.http.HttpStatus.SC_OK + +abstract class ClusterJsonRequest implements Callable, LogSupport { + + ClusterJsonResponse call() { + try { + if (!instanceReady) { + throw new InstanceNotAvailableException('Instance not available and may be initializing.') + } + def value = doCall() + new ClusterJsonResponse( + httpStatus: SC_OK, + json: serialize(value) + ) + } catch (Throwable t) { + def exceptionHandler = Utils.exceptionHandler + try { + exceptionHandler.handleException( + exception: t, + logTo: this, + logMessage: [_action: this.class.simpleName] + ) + } catch (Exception e) { + // Even logging failing -- just catch quietly and return neatly to calling member. + } + return new ClusterJsonResponse( + httpStatus: exceptionHandler.getHttpStatus(t), + json: serialize(t) + ) + } + } + + abstract Object doCall() +} + + diff --git a/src/main/groovy/io/xh/hoist/cluster/ClusterJsonResponse.groovy b/src/main/groovy/io/xh/hoist/cluster/ClusterJsonResponse.groovy new file mode 100644 index 00000000..c8310e31 --- /dev/null +++ b/src/main/groovy/io/xh/hoist/cluster/ClusterJsonResponse.groovy @@ -0,0 +1,8 @@ +package io.xh.hoist.cluster + +class ClusterJsonResponse { + int httpStatus + String json +} + + diff --git a/src/main/groovy/io/xh/hoist/cluster/ClusterRequest.groovy b/src/main/groovy/io/xh/hoist/cluster/ClusterRequest.groovy index 40e6a8bc..e63e2eca 100644 --- a/src/main/groovy/io/xh/hoist/cluster/ClusterRequest.groovy +++ b/src/main/groovy/io/xh/hoist/cluster/ClusterRequest.groovy @@ -1,5 +1,6 @@ package io.xh.hoist.cluster +import io.xh.hoist.exception.ClusterExecutionException import io.xh.hoist.exception.InstanceNotAvailableException import io.xh.hoist.log.LogSupport @@ -17,15 +18,17 @@ abstract class ClusterRequest implements Callable>, LogSup } return new ClusterResponse(value: doCall()) } catch (Throwable t) { + def simpleName = this.class.simpleName try { exceptionHandler.handleException( exception: t, logTo: this, - logMessage: [_action: this.class.simpleName] + logMessage: [_action: simpleName] ) } catch (Exception e) { - // Even logging failing -- just catch quietly and return neatly to calling member. + // Don't let logging ever prevent us from returning *actual* exception to caller. } + t = new ClusterExecutionException("Failed to execute ${simpleName}: ${t.message}", t) return new ClusterResponse(exception: t) } } diff --git a/src/main/groovy/io/xh/hoist/exception/ClusterExecutionException.groovy b/src/main/groovy/io/xh/hoist/exception/ClusterExecutionException.groovy new file mode 100644 index 00000000..1614fde7 --- /dev/null +++ b/src/main/groovy/io/xh/hoist/exception/ClusterExecutionException.groovy @@ -0,0 +1,24 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2023 Extremely Heavy Industries Inc. + */ + +package io.xh.hoist.exception + + +/** + * Wrapper Exception to be returned from a failed ClusterResponse. + * + * Exceptions are supposed to be serializable but can cause problems in practice, especially in + * Kryo. Note that we have already typically logged the actual exception on remote server. + */ +class ClusterExecutionException extends Exception { + String causeName + + ClusterExecutionException(String msg, Throwable t) { + super(msg) + causeName = t.class.simpleName + } +} diff --git a/src/main/groovy/io/xh/hoist/exception/ExceptionHandler.groovy b/src/main/groovy/io/xh/hoist/exception/ExceptionHandler.groovy index 91d3c8fd..9ddb27ed 100644 --- a/src/main/groovy/io/xh/hoist/exception/ExceptionHandler.groovy +++ b/src/main/groovy/io/xh/hoist/exception/ExceptionHandler.groovy @@ -75,6 +75,18 @@ class ExceptionHandler { summaryTextInternal(t, true) } + /** HttpStatus dode for this exception. */ + int getHttpStatus(Throwable t) { + if (t instanceof HttpException && !(t instanceof ExternalHttpException)) { + return ((HttpException) t).statusCode + } + + return t instanceof RoutineException ? + SC_BAD_REQUEST : + SC_INTERNAL_SERVER_ERROR + } + + //--------------------------------------------- // Template methods. For application override //--------------------------------------------- @@ -90,16 +102,6 @@ class ExceptionHandler { return t instanceof RoutineException } - protected int getHttpStatus(Throwable t) { - if (t instanceof HttpException && !(t instanceof ExternalHttpException)) { - return ((HttpException) t).statusCode - } - - return t instanceof RoutineException ? - SC_BAD_REQUEST : - SC_INTERNAL_SERVER_ERROR - } - //--------------------------- // Implementation //---------------------------