diff --git a/www-server/pom.xml b/www-server/pom.xml index 59c6abd8acbecfc52041be9cc9cc83d05095b0a3..c153b7cd7987a9cd67a5e5c76a59d36054687238 100644 --- a/www-server/pom.xml +++ b/www-server/pom.xml @@ -16,6 +16,7 @@ <properties> <mockito-core.version>5.5.0</mockito-core.version> + <sava.version>0.0.1-SNAPSHOT</sava.version> <swagger.version>2.2.19</swagger.version> <checkstyle.config.location>file://${basedir}/../config/sun_checks.xml</checkstyle.config.location> </properties> @@ -154,6 +155,12 @@ <artifactId>postgresql</artifactId> <version>42.7.0</version> </dependency> + <!-- SAVA --> + <dependency> + <groupId>fr.inrae.agroclim</groupId> + <artifactId>sava-core-jakarta</artifactId> + <version>${sava.version}</version> + </dependency> <!-- Tests --> <dependency> <groupId>com.h2database</groupId> diff --git a/www-server/src/main/java/fr/agrometinfo/www/server/MetricsServlet.java b/www-server/src/main/java/fr/agrometinfo/www/server/MetricsServlet.java new file mode 100644 index 0000000000000000000000000000000000000000..ef334068563c3e1175300fdef87df24d310562bd --- /dev/null +++ b/www-server/src/main/java/fr/agrometinfo/www/server/MetricsServlet.java @@ -0,0 +1,17 @@ +package fr.agrometinfo.www.server; + + +import fr.agroclim.sava.core.MetricsBasicAuthServlet; +import jakarta.servlet.annotation.WebServlet; + +/** + * Servlet to using SAVA to provide metrics. + */ +@WebServlet("/metrics") +public class MetricsServlet extends MetricsBasicAuthServlet { + /** + * UID. + */ + private static final long serialVersionUID = 1003353995341179826L; + +} diff --git a/www-server/src/main/java/fr/agrometinfo/www/server/dao/DaoHibernate.java b/www-server/src/main/java/fr/agrometinfo/www/server/dao/DaoHibernate.java index 5c33432169a148074bee347f84c512180d67e166..dc1e1b7898b45a82adbc06d4e1f9f69c8da4eb3c 100644 --- a/www-server/src/main/java/fr/agrometinfo/www/server/dao/DaoHibernate.java +++ b/www-server/src/main/java/fr/agrometinfo/www/server/dao/DaoHibernate.java @@ -1,9 +1,14 @@ package fr.agrometinfo.www.server.dao; +import java.math.BigDecimal; +import java.sql.Connection; +import java.sql.SQLException; import java.util.ArrayList; import java.util.List; import java.util.Map; +import org.hibernate.engine.spi.SessionImplementor; + import jakarta.persistence.NoResultException; import jakarta.persistence.Query; import jakarta.persistence.TypedQuery; @@ -17,6 +22,37 @@ import lombok.extern.log4j.Log4j2; */ @Log4j2 public abstract class DaoHibernate<T> { + /** + * Get the class name of JDBC driver. + * + * @param em Entity manager + * @return name of JDBC driver + */ + protected static final String getDriver(final ScopedEntityManager em) { + final SessionImplementor sessionImp = (SessionImplementor) em.getDelegate(); + try { + final Connection conn = sessionImp.getJdbcConnectionAccess().obtainConnection(); + return conn.getMetaData().getDriverName(); + } catch (final SQLException e) { + return ""; + } + } + + /** + * Checks if the database is connected by a PostgreSQL, useful to call specific + * SQL. + * + * @param em Entity manager + * @return if the SGBD is PostgreSQL + */ + protected static final boolean isPostgreSQLDriver(final ScopedEntityManager em) { + final var driverName = getDriver(em); + if (driverName == null || driverName.isBlank()) { + return false; + } + return driverName.startsWith("PostgreSQL"); + } + /** * Related class. */ @@ -125,13 +161,34 @@ public abstract class DaoHibernate<T> { } } + /** + * Find the first result. + * + * @param <U> entity class + * @param sql SQL statement + * @param params parameters for the JPQL statement + * @return the found entity or null if not entity matches + */ + @SuppressWarnings("unchecked") + protected final <U> U findFirstScalarWithNamedParameters(final String sql, final Map<String, Object> params) { + try (ScopedEntityManager em = getScopedEntityManager()) { + final Query query = em.createNativeQuery(sql); + if (params != null) { + params.forEach(query::setParameter); + } + return (U) query.getSingleResult(); + } catch (final NoResultException e) { + return null; + } + } + /** * Find the first result. * * @param <U> entity class * @param stmt Jakarta Persistence Query Language statement * @param params parameters for the JPQL statement - * @param clazz2 entity class + * @param clazz2 entity class * @return the found entity or null if not entity matches */ protected final <U> U findOneByJPQL(final String stmt, final Map<String, Object> params, final Class<U> clazz2) { @@ -146,6 +203,37 @@ public abstract class DaoHibernate<T> { } } + /** + * @return name of current schema. + */ + public String getSchemaName() { + final String sql = """ + SELECT table_schema FROM information_schema.tables WHERE LOWER(table_name) = 'indicator' + """; + return findFirstScalar(sql, null); + } + + /** + * See https://www.postgresql.org/docs/current/functions-admin.html. + * + * @param schema schema name (usually public) + * @return size of all content of a schema, in bytes + */ + public BigDecimal getSchemaSize(final String schema) { + try (ScopedEntityManager em = getScopedEntityManager()) { + if (isPostgreSQLDriver(em)) { + final String sql = "SELECT " + + "SUM(pg_total_relation_size(CONCAT(quote_ident(schemaname), '.', quote_ident(tablename)))) " + + "FROM pg_catalog.pg_tables WHERE schemaname=:schema"; + final Query query = em.createNativeQuery(sql); + query.setParameter("schema", schema); + return (BigDecimal) query.getSingleResult(); + } else { + throw new UnsupportedOperationException("Database driver not handled! " + getDriver(em)); + } + } + } + /** * @return Proxy'ed EntityManager to handle transactions. */ diff --git a/www-server/src/main/java/fr/agrometinfo/www/server/dao/IndicatorDao.java b/www-server/src/main/java/fr/agrometinfo/www/server/dao/IndicatorDao.java index baf6581838cb9198c871e340f8e0496dc2906bbe..03b4d9cfc0951033d8390a9b7aba33e5995cbc7e 100644 --- a/www-server/src/main/java/fr/agrometinfo/www/server/dao/IndicatorDao.java +++ b/www-server/src/main/java/fr/agrometinfo/www/server/dao/IndicatorDao.java @@ -1,5 +1,6 @@ package fr.agrometinfo.www.server.dao; +import java.math.BigDecimal; import java.util.List; import fr.agrometinfo.www.server.model.Indicator; @@ -23,4 +24,13 @@ public interface IndicatorDao { */ Indicator findByCodeAndPeriod(String code, String periodCode); + /** + * @return size of all content of the current schema, in bytes + */ + BigDecimal getCurrentSchemaSize(); + + /** + * @return name of current schema. + */ + String getSchemaName(); } diff --git a/www-server/src/main/java/fr/agrometinfo/www/server/dao/IndicatorDaoHibernate.java b/www-server/src/main/java/fr/agrometinfo/www/server/dao/IndicatorDaoHibernate.java index edeec7aff47dc879394f80d30491760b7be5f040..feec09391f4e940202f11c0eb9d21a1c3e77d969 100644 --- a/www-server/src/main/java/fr/agrometinfo/www/server/dao/IndicatorDaoHibernate.java +++ b/www-server/src/main/java/fr/agrometinfo/www/server/dao/IndicatorDaoHibernate.java @@ -1,5 +1,6 @@ package fr.agrometinfo.www.server.dao; +import java.math.BigDecimal; import java.util.Map; import fr.agrometinfo.www.server.model.Indicator; @@ -29,9 +30,14 @@ public class IndicatorDaoHibernate extends DaoHibernate<Indicator> implements In * @return found or null */ @Override - public Indicator findByCodeAndPeriod(@NotNull final String code, @NotNull final String periodCode) { + public final Indicator findByCodeAndPeriod(@NotNull final String code, @NotNull final String periodCode) { final var jpql = "SELECT t FROM Indicator AS t WHERE t.code=:code AND t.period.code=:periodCode"; return super.findOneByJPQL(jpql, Map.of("code", code, "periodCode", periodCode), Indicator.class); } + @Override + public final BigDecimal getCurrentSchemaSize() { + return super.getSchemaSize(super.getSchemaName()); + } + } diff --git a/www-server/src/main/java/fr/agrometinfo/www/server/scheduled/BackgroundJobManager.java b/www-server/src/main/java/fr/agrometinfo/www/server/scheduled/BackgroundJobManager.java new file mode 100644 index 0000000000000000000000000000000000000000..ff82b7a5f859f9bb5d1e3711ea963885fa3a127c --- /dev/null +++ b/www-server/src/main/java/fr/agrometinfo/www/server/scheduled/BackgroundJobManager.java @@ -0,0 +1,130 @@ +package fr.agrometinfo.www.server.scheduled; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import fr.agroclim.sava.core.SavaUtils; +import fr.agrometinfo.www.server.dao.IndicatorDao; +import fr.agrometinfo.www.server.dao.PersistenceManager; +import jakarta.inject.Inject; +import jakarta.servlet.ServletContextEvent; +import jakarta.servlet.ServletContextListener; +import jakarta.servlet.annotation.WebListener; +import lombok.extern.log4j.Log4j2; + +/** + * Manager for scheduled tasks. + * + * Full JavaEE simply uses javax.ejb.Schedule annotation. + * + * @author Olivier Maury + */ +@WebListener +@Log4j2 +public class BackgroundJobManager implements ServletContextListener { + + /** + * Number of seconds in a day. + */ + private static final int DAY_IN_SECONDS = 60 * 60 * 24; + + /** + * Number of seconds in an hour. + */ + private static final int HOUR_IN_SECONDS = 60 * 60; + + /** + * @param hour the hour-of-day of delay, from 0 to 23 + * @param minute the minute-of-hour of delay, from 0 to 59 + * @param second the second-of-minute of delay, from 0 to 59 + * @return delays in seconds until the next hour:minute + */ + static long initialDelay(final int hour, final int minute, final int second) { + LocalDateTime next = LocalDateTime.of(LocalDate.now(), LocalTime.of(hour, minute, second)); + final LocalDateTime now = LocalDateTime.now(); + if (next.isBefore(now)) { + next = next.plusDays(1); + } + return Duration.between(now, next).getSeconds(); + } + + /** + * DAO providing database details. + */ + @Inject + private IndicatorDao indicatorDao; + + /** + * Scheduler for tasks. + */ + private ScheduledExecutorService scheduler; + + @Override + public final void contextDestroyed(final ServletContextEvent event) { + LOGGER.traceEntry(); + PersistenceManager.getInstance().closeEntityManagerFactory(); + scheduler.shutdownNow(); + } + + @Override + public final void contextInitialized(final ServletContextEvent event) { + scheduler = Executors.newSingleThreadScheduledExecutor(); + scheduleEveryPeriod(this::updateHourlyMetrics, HOUR_IN_SECONDS); + scheduleEveryDayAt(this::updateHourlyMetrics, 0, 0, 0); + initMetrics(); + updateHourlyMetrics(); + } + + /** + * Define metrics using SAVA before use. + */ + private void initMetrics() { + SavaUtils.addGauge("schema_size", "Database schema size, in bytes", "schema_name"); + } + + /** + * Creates and executes a periodic action. + * + * @param runnable + * the task to execute + * @param hour + * the hour of schedule + * @param minute + * the minute of schedule + * @param second + * the second of schedule + */ + private void scheduleEveryDayAt(final Runnable runnable, final int hour, final int minute, final int second) { + final long initDelay = initialDelay(hour, minute, second); + LOGGER.trace("Scheduling {} after {}s at {}:{}:{}", runnable.getClass().getCanonicalName(), initDelay, hour, + minute, second); + scheduler.scheduleAtFixedRate(runnable, initDelay, DAY_IN_SECONDS, TimeUnit.SECONDS); + } + + /** + * Creates and executes a periodic action after waiting one period. + * + * @param runnable + * the task to execute + * @param period + * period between 2 runs (in seconds) + */ + private void scheduleEveryPeriod(final Runnable runnable, final int period) { + LOGGER.trace("Scheduling {} after {}s.", runnable.getClass().getCanonicalName(), period); + scheduler.scheduleAtFixedRate(runnable, period, period, TimeUnit.SECONDS); + } + + /** + * Update metrics using SAVA. + */ + private void updateHourlyMetrics() { + final var schemaName = indicatorDao.getSchemaName(); + final var schemaSize = indicatorDao.getCurrentSchemaSize(); + SavaUtils.setGaugeValue("schema_size", schemaSize.doubleValue(), schemaName); + } +} diff --git a/www-server/src/main/java/fr/agrometinfo/www/server/scheduled/package-info.java b/www-server/src/main/java/fr/agrometinfo/www/server/scheduled/package-info.java new file mode 100644 index 0000000000000000000000000000000000000000..98d6cdefdb923b5b37b7b28c86f20f25d5cc8525 --- /dev/null +++ b/www-server/src/main/java/fr/agrometinfo/www/server/scheduled/package-info.java @@ -0,0 +1,4 @@ +/** + * Scheduled jobs. + */ +package fr.agrometinfo.www.server.scheduled;