com.mchange.feedletter.db.PgDatabase.scala Maven / Gradle / Ivy
package com.mchange.feedletter.db
import zio.*
import java.sql.{Connection,Timestamp}
import javax.sql.DataSource
import scala.collection.{immutable,mutable}
import scala.util.Using
import scala.util.control.NonFatal
import scala.math.Ordering.Implicits.infixOrderingOps
import java.sql.SQLException
import java.time.{Duration as JDuration,Instant,ZonedDateTime,ZoneId}
import java.time.temporal.ChronoUnit
import com.mchange.feedletter.*
import MLevel.*
import audiofluidity.rss.util.formatPubDate
import com.mchange.mailutil.*
import com.mchange.cryptoutil.{*, given}
import com.mchange.feedletter.BuildInfo
import com.mchange.feedletter.api.ApiLinkGenerator
object PgDatabase extends Migratory, SelfLogging:
val LatestSchema = PgSchema.V1
override val targetDbVersion = LatestSchema.Version
private def fetchMetadataValue( conn : Connection, key : MetadataKey ) : Option[String] =
PgSchema.Unversioned.Table.Metadata.select( conn, key )
private def zfetchMetadataValue( conn : Connection, key : MetadataKey ) : Task[Option[String]] =
ZIO.attemptBlocking( PgSchema.Unversioned.Table.Metadata.select( conn, key ) )
private def fetchDbName(conn : Connection) : Task[String] =
ZIO.attemptBlocking:
Using.resource(conn.createStatement()): stmt =>
Using.resource(stmt.executeQuery("SELECT current_database()")): rs =>
uniqueResult("select-current-database-name", rs)( _.getString(1) )
private def fetchDumpDir(conn : Connection) : Task[os.Path] =
for
mbDumpDir <- Config.zfetchValue(conn, ConfigKey.DumpDbDir)
yield
os.Path( mbDumpDir.getOrElse( throw new ConfigurationMissing(ConfigKey.DumpDbDir) ) )
override def fetchDumpDir( ds : DataSource ) : Task[os.Path] =
withConnectionZIO(ds)( fetchDumpDir )
override def dump(ds : DataSource) : Task[os.Path] =
def runDump( dbName : String, dumpFile : os.Path ) : Task[Unit] =
ZIO.attemptBlocking:
val parsedCommand = List("pg_dump", dbName)
os.proc( parsedCommand ).call( stdout = dumpFile )
withConnectionZIO( ds ): conn =>
for
dbName <- fetchDbName(conn)
dumpDir <- fetchDumpDir(conn)
dumpFile <- Migratory.prepareDumpFileForInstant(dumpDir, java.time.Instant.now)
_ <- runDump( dbName, dumpFile )
yield dumpFile
override def dbVersionStatus(ds : DataSource) : Task[DbVersionStatus] =
withConnectionZIO( ds ): conn =>
val okeyDokeyIsh =
for
mbDbVersion <- zfetchMetadataValue(conn, MetadataKey.SchemaVersion)
mbCreatorAppVersion <- zfetchMetadataValue(conn, MetadataKey.CreatorAppVersion)
yield
try
mbDbVersion.map( _.toInt ) match
case Some( version ) if version == LatestSchema.Version => DbVersionStatus.Current( version )
case Some( version ) if version < LatestSchema.Version => DbVersionStatus.OutOfDate( version, LatestSchema.Version )
case Some( version ) => DbVersionStatus.UnexpectedVersion( Some(version.toString), mbCreatorAppVersion, Some( BuildInfo.version ), Some(LatestSchema.Version.toString()) )
case None => DbVersionStatus.SchemaMetadataDisordered( s"Expected key '${MetadataKey.SchemaVersion}' was not found in schema metadata!" )
catch
case nfe : NumberFormatException =>
DbVersionStatus.UnexpectedVersion( mbDbVersion, mbCreatorAppVersion, Some( BuildInfo.version ), Some(LatestSchema.Version.toString()) )
okeyDokeyIsh.catchSome:
case sqle : SQLException =>
val dbmd = conn.getMetaData()
try
val rs = dbmd.getTables(null,null,PgSchema.Unversioned.Table.Metadata.Name,null)
if !rs.next() then // the metadata table does not exist
ZIO.succeed( DbVersionStatus.SchemaMetadataNotFound )
else
ZIO.succeed( DbVersionStatus.SchemaMetadataDisordered(s"Metadata table found, but an Exception occurred while accessing it: ${sqle.toString()}") )
catch
case t : SQLException =>
WARNING.log("Exception while connecting to database.", t)
ZIO.succeed( DbVersionStatus.ConnectionFailed )
end dbVersionStatus
override def upMigrate(ds : DataSource, from : Option[Int]) : Task[Unit] =
def upMigrateFrom_New() : Task[Unit] =
TRACE.log( "upMigrateFrom_New()" )
withConnectionTransactional( ds ): conn =>
PgSchema.Unversioned.Table.Metadata.create( conn )
insertMetadataKeys(
conn,
(MetadataKey.SchemaVersion, "0"),
(MetadataKey.CreatorAppVersion, BuildInfo.version)
)
def upMigrateFrom_0() : Task[Unit] =
TRACE.log( "upMigrateFrom_0()" )
withConnectionTransactional( ds ): conn =>
Using.resource( conn.createStatement() ): stmt =>
PgSchema.V1.Table.Config.create( stmt )
PgSchema.V1.Table.Flags.create( stmt )
PgSchema.V1.Table.Feed.create( stmt )
PgSchema.V1.Table.Feed.Sequence.FeedSeq.create( stmt )
PgSchema.V1.Table.Item.Type.ItemAssignability.create( stmt )
PgSchema.V1.Table.Item.create( stmt )
PgSchema.V1.Table.Item.Index.ItemAssignability.create( stmt )
PgSchema.V1.Table.Subscribable.create( stmt )
PgSchema.V1.Table.Assignable.create( stmt )
PgSchema.V1.Table.Assignment.create( stmt )
PgSchema.V1.Table.Subscription.create( stmt )
PgSchema.V1.Table.Subscription.Sequence.SubscriptionSeq.create( stmt )
PgSchema.V1.Table.Subscription.Index.SubscriptionIdConfirmed.create( stmt )
PgSchema.V1.Table.Subscription.Index.DestinationUniqueSubscribableName.create( stmt )
PgSchema.V1.Table.MailableTemplate.create( stmt )
PgSchema.V1.Table.Mailable.create( stmt )
PgSchema.V1.Table.Mailable.Sequence.MailableSeq.create( stmt )
PgSchema.V1.Table.ImmediatelyMailable.create( stmt )
PgSchema.V1.Table.ImmediatelyMailable.Sequence.ImmediatelyMailableSeq.create( stmt )
PgSchema.V1.Table.MastoPostable.create( stmt )
PgSchema.V1.Table.MastoPostable.Sequence.MastoPostableSeq.create( stmt )
PgSchema.V1.Table.MastoPostableMedia.create( stmt )
updateMetadataKeys(
conn,
(MetadataKey.SchemaVersion, "1"),
(MetadataKey.CreatorAppVersion, BuildInfo.version)
)
TRACE.log( s"upMigrate( from=${from} )" )
from match
case None => upMigrateFrom_New()
case Some( 0 ) => upMigrateFrom_0()
case Some( `targetDbVersion` ) =>
ZIO.fail( new CannotUpMigrate( s"Cannot upmigrate from current target DB version: V${targetDbVersion}" ) )
case Some( other ) =>
ZIO.fail( new CannotUpMigrate( s"Cannot upmigrate from unknown DB version: V${other}" ) )
private def insertMetadataKeys( conn : Connection, pairs : (MetadataKey,String)* ) : Unit =
pairs.foreach( ( mdkey, value ) => PgSchema.Unversioned.Table.Metadata.insert(conn, mdkey, value) )
private def updateMetadataKeys( conn : Connection, pairs : (MetadataKey,String)* ) : Unit =
pairs.foreach( ( mdkey, value ) => PgSchema.Unversioned.Table.Metadata.update(conn, mdkey, value) )
private def insertConfigKeys( conn : Connection, pairs : (ConfigKey,String)* ) : Unit =
pairs.foreach( ( cfgkey, value ) => LatestSchema.Table.Config.insert(conn, cfgkey, value) )
setFlag(conn, Flag.MustReloadDaemon)
private def updateConfigKeys( conn : Connection, pairs : (ConfigKey,String)* ) : Unit =
pairs.foreach( ( cfgkey, value ) => LatestSchema.Table.Config.update(conn, cfgkey, value) )
setFlag(conn, Flag.MustReloadDaemon)
private def upsertConfigKeys( conn : Connection, pairs : (ConfigKey,String)* ) : Unit =
pairs.foreach( ( cfgkey, value ) => LatestSchema.Table.Config.upsert(conn, cfgkey, value) )
setFlag(conn, Flag.MustReloadDaemon)
private def sort( tups : Set[Tuple2[ConfigKey,String]] ) : immutable.SortedSet[Tuple2[ConfigKey,String]] =
immutable.SortedSet.from( tups )( using Ordering.by( tup => (tup(0).toString().toUpperCase, tup(1) ) ) )
private def upsertConfigKeyMapAndReport( conn : Connection, map : Map[ConfigKey,String] ) : immutable.SortedSet[Tuple2[ConfigKey,String]] =
upsertConfigKeys( conn, map.toList* )
reportAllConfigKeysStringified( conn )
def upsertConfigKeyMapAndReport( ds : DataSource, map : Map[ConfigKey,String] ) : Task[immutable.SortedSet[Tuple2[ConfigKey,String]]] =
withConnectionTransactional( ds )( conn => upsertConfigKeyMapAndReport( conn, map ) )
def reportNondefaultConfigKeys( ds : DataSource ): Task[immutable.SortedSet[Tuple2[ConfigKey,String]]] =
withConnectionTransactional( ds )( conn => sort( LatestSchema.Table.Config.selectTuples( conn ) ) )
def reportAllConfigKeysStringified( conn : Connection ): immutable.SortedSet[Tuple2[ConfigKey,String]] =
val stringifyThrowable : PartialFunction[Throwable,String] = {
case NonFatal(t) => "throws " + t.getClass().getName()
}
val tups = ConfigKey.values.map: ck =>
val v = try PgDatabase.Config.fetchByKey( conn, ck ).toString() catch stringifyThrowable
( ck, v )
sort( tups.toSet )
def reportAllConfigKeysStringified( ds : DataSource ): Task[immutable.SortedSet[Tuple2[ConfigKey,String]]] =
withConnectionTransactional( ds )( reportAllConfigKeysStringified )
private def ensureOpenAssignable( conn : Connection, feedId : FeedId, subscribableName : SubscribableName, withinTypeId : String, forGuid : Option[Guid]) : Unit =
LatestSchema.Table.Assignable.selectOpened( conn, subscribableName, withinTypeId ) match
case Some( time ) => /* okay, ignore */
case None => LatestSchema.Table.Assignable.insert( conn, subscribableName, withinTypeId, Instant.now )
def mostRecentlyOpenedAssignableWithinTypeStatus( conn : Connection, subscribableName : SubscribableName ) : Option[AssignableWithinTypeStatus] =
val withinTypeId = LatestSchema.Table.Assignable.selectWithinTypeIdMostRecentOpened( conn, subscribableName )
withinTypeId.map: wti =>
val count = LatestSchema.Table.Assignment.selectCountWithinAssignable( conn, subscribableName, wti )
AssignableWithinTypeStatus( wti, count )
def lastCompletedWithinTypeId( conn : Connection, subscribableName : SubscribableName ) : Option[String] =
LatestSchema.Table.Subscribable.selectLastCompletedWti( conn, subscribableName )
private def assignForSubscribable( conn : Connection, subscribableName : SubscribableName, feedId : FeedId, guid : Guid, content : ItemContent, status : ItemStatus ) : Unit =
TRACE.log( s"assignForSubscriptionManager( $conn, $subscribableName, $feedId, $guid, $content, $status )" )
val subscriptionManager = LatestSchema.Table.Subscribable.selectManager( conn, subscribableName )
subscriptionManager.withinTypeId( conn, subscribableName, feedId, guid, content, status ).foreach: wti =>
ensureOpenAssignable( conn, feedId, subscribableName, wti, Some(guid) )
LatestSchema.Table.Assignment.insert( conn, subscribableName, wti, guid )
DEBUG.log( s"Item with GUID '${guid}' from feed with ID ${feedId} has been assigned in subscribable '${subscribableName}'." )
private def assign( conn : Connection, feedId : FeedId, guid : Guid, content : ItemContent, status : ItemStatus ) : Unit =
TRACE.log( s"assign( $conn, $feedId, $guid, $content, $status )" )
val subscribableNames = LatestSchema.Table.Subscribable.selectSubscribableNamesByFeedId( conn, feedId )
subscribableNames.foreach( subscribableName => assignForSubscribable( conn, subscribableName, feedId, guid, content, status ) )
LatestSchema.Table.Item.updateLastCheckedAssignability( conn, feedId, guid, status.lastChecked, ItemAssignability.Assigned )
DEBUG.log( s"Item with GUID '${guid}' from feed with ID ${feedId} has been assigned in all subscribables to that feed." )
private def updateAssignItem( conn : Connection, fi : FeedInfo, guid : Guid, dbStatus : Option[ItemStatus], freshContent : ItemContent, now : Instant ) : Unit =
TRACE.log( s"updateAssignItem( $conn, $fi, $guid, $dbStatus, $freshContent, $now )" )
dbStatus match
case Some( prev @ ItemStatus( contentHash, firstSeen, lastChecked, stableSince, ItemAssignability.Unassigned ) ) =>
val seenMinutes = JDuration.between(firstSeen, now).get( ChronoUnit.SECONDS ) / 60 // ChronoUnite.Minutes fails, "UnsupportedTemporalTypeException: Unsupported unit: Minutes"
val stableMinutes = JDuration.between(stableSince, now).get( ChronoUnit.SECONDS ) / 60 // ChronoUnite.Minutes fails, "UnsupportedTemporalTypeException: Unsupported unit: Minutes"
def afterMinDelay = seenMinutes > fi.minDelayMinutes
def sufficientlyStable = stableMinutes > fi.awaitStabilizationMinutes
def pastMaxDelay = seenMinutes > fi.maxDelayMinutes
val newContentHash = freshContent.contentHash
/* don't update empty over actual content, treat empty content (rare, since guid would still have been found) as just stability */
if newContentHash == contentHash then
val newLastChecked = now
LatestSchema.Table.Item.updateStable( conn, fi.feedId, guid, newLastChecked )
DEBUG.log( s"Updated last checked time on stable unassigned item, feed ID ${fi.feedId}, guid '${guid}'." )
if (afterMinDelay && sufficientlyStable) || pastMaxDelay then
assign( conn, fi.feedId, guid, freshContent, prev.copy( lastChecked = newLastChecked ) )
else
val newStableSince = now
val newLastChecked = now
LatestSchema.Table.Item.updateChanged( conn, fi.feedId, guid, freshContent, ItemStatus( newContentHash, firstSeen, newLastChecked, newStableSince, ItemAssignability.Unassigned ) )
DEBUG.log( s"Updated stable since and last checked times, and content cache, on modified unassigned item, feed ID ${fi.feedId}, guid '${guid}'." )
if pastMaxDelay then
assign( conn, fi.feedId, guid, freshContent, prev.copy( lastChecked = newLastChecked ) )
case Some( prev @ ItemStatus( contentHash, firstSeen, lastChecked, stableSince, ItemAssignability.Assigned ) ) =>
val newContentHash = freshContent.contentHash
if newContentHash != contentHash then
val newStatus = ItemStatus( newContentHash, firstSeen, now, now, ItemAssignability.Assigned )
LatestSchema.Table.Item.updateChanged( conn, fi.feedId, guid, freshContent, newStatus )
DEBUG.log( s"Updated an already-assigned, not yet cleared, item which has been modified, feed ID ${fi.feedId}, guid '${guid}'." )
else
LatestSchema.Table.Item.updateStable( conn, fi.feedId, guid, now )
DEBUG.log( s"Updated last-checked-time on a stable already-assigned, not yet cleared, item, feed ID ${fi.feedId}, guid '${guid}'." )
case Some( ItemStatus( _, _, _, _, ItemAssignability.Cleared ) ) => /* ignore, we're done with this one */
case Some( ItemStatus( _, _, _, _, ItemAssignability.Excluded ) ) => /* ignore, we don't assign */
case None =>
def doUnassignedInsert() =
LatestSchema.Table.Item.insertNew(conn, fi.feedId, guid, Some(freshContent), ItemAssignability.Unassigned)
DEBUG.log( s"Added new item, feed ID ${fi.feedId}, guid '${guid}'." )
val dbStatus = LatestSchema.Table.Item.checkStatus( conn, fi.feedId, guid ).getOrElse:
throw new AssertionError("Just inserted row is not found???")
if fi.minDelayMinutes <= 0 && fi.awaitStabilizationMinutes <= 0 then // if eligible for immediate assignment...
assign( conn, fi.feedId, guid, freshContent, dbStatus )
def doExcludingInsert( pd : Instant ) =
WARNING.log(s"Excluding item found with parseable publication date '${pd}', which is prior to time of initial subscription, feed ID ${fi.feedId}, guid '${guid}'." )
LatestSchema.Table.Item.insertNew( conn, fi.feedId, guid, None, ItemAssignability.Excluded ) // don't cache items we're excluding anyway
freshContent.pubDate match
case Some( pd ) =>
if pd > fi.added then
doUnassignedInsert() // skip items known to be published prior to subscription
else
doExcludingInsert( pd )
case None =>
doUnassignedInsert()
private def updateAssignItems( conn : Connection, fi : FeedInfo ) : Unit =
val nextAssign = fi.lastAssigned.plusSeconds( fi.assignEveryMinutes * 60 )
val now = Instant.now()
if now > nextAssign then
val FeedDigest( fileOrderedGuids, guidToItemContent, timestamp ) = FeedDigest( fi.feedUrl, now )
// we feed the entries in reverse of their reverse-chronological file ordering!
fileOrderedGuids.reverse.map( guid => (guid, guidToItemContent( guid ) ) ).foreach: ( guid, freshContent ) =>
val dbStatus = LatestSchema.Table.Item.checkStatus( conn, fi.feedId, guid )
updateAssignItem( conn, fi, guid, dbStatus, freshContent, timestamp )
DEBUG.log( s"Deleting any as-yet-unassigned items that have been deleted from feed with ID ${fi.feedId}" )
val deleted = LatestSchema.Table.Item.deleteDisappearedUnassignedForFeed( conn, fi.feedId, guidToItemContent.keySet ) // so that if a post is deleted before it has been assigned, it won't be notified
if deleted > 0 then
INFO.log( s"Deleted ${deleted} disappeared unassigned items from feed with ID ${fi.feedId}." )
DEBUG.log( s"GUIDs in feed with ID ${fi.feedId} that should have been retained: " + guidToItemContent.keySet.mkString(", ") )
LatestSchema.Table.Feed.updateLastAssigned(conn, fi.feedId, timestamp)
INFO.log( s"Updated/assigned all items from feed with ID ${fi.feedId}, feed URL '${fi.feedUrl}'" )
// it's probably fine to use cached values, because we recache them continually, even after assignment
// so they should never be very stale.
//
// reverse chronological by first-seen time
private def materializeAssignable( conn : Connection, assignableKey : AssignableKey ) : Seq[ItemContent] =
LatestSchema.Join.ItemAssignment.selectItemContentsForAssignable( conn, assignableKey.subscribableName, assignableKey.withinTypeId )
private def route( conn : Connection, assignableKey : AssignableKey, apiLinkGenerator : ApiLinkGenerator ) : Unit =
val subscriptionManager = LatestSchema.Table.Subscribable.selectManager( conn, assignableKey.subscribableName )
route( conn, assignableKey, subscriptionManager, apiLinkGenerator )
private def route( conn : Connection, assignableKey : AssignableKey, sman : SubscriptionManager, apiLinkGenerator : ApiLinkGenerator ) : Unit =
val AssignableKey( subscribableName, withinTypeId ) = assignableKey
val contents = materializeAssignable(conn, assignableKey)
val idestinations = LatestSchema.Table.Subscription.selectConfirmedIdentifiedDestinationsForSubscribable( conn, subscribableName )
val narrowed =
val eithers = idestinations.map( sman.narrowIdentifiedDestination )
val tmp = Set.newBuilder[IdentifiedDestination[sman.D]]
eithers.foreach: either =>
either match
case Left( badIdDestination ) =>
WARNING.log( s"Destination '${badIdDestination.destination.unique}' is unsuitable for subscription '${assignableKey.subscribableName}', and will be ignored." )
case Right( goodIdDestination ) =>
tmp += goodIdDestination
tmp.result()
sman.route(conn, assignableKey, contents, narrowed, apiLinkGenerator)
def queueForMailing( conn : Connection, contents : String, from : AddressHeader[From], replyTo : Option[AddressHeader[ReplyTo]], to : AddressHeader[To], templateParams : TemplateParams, subject : String ) : Unit =
queueForMailing( conn, contents, from, replyTo, Set(Tuple2(to,templateParams)), subject )
def mailImmediately(
conn : Connection,
as : AppSetup,
contents : String,
from : AddressHeader[From],
replyTo : Option[AddressHeader[ReplyTo]],
to : AddressHeader[To],
templateParams : TemplateParams,
subject : String
) : Unit =
LatestSchema.Table.ImmediatelyMailable.insert(conn, contents, from, replyTo, to, templateParams, subject )
setFlag(conn, Flag.ImmediateMailQueued)
def queueForMailing( conn : Connection, contents : String, from : AddressHeader[From], replyTo : Option[AddressHeader[ReplyTo]], tosWithParams : Set[(AddressHeader[To],TemplateParams)], subject : String ) : Unit =
val hash = Hash.SHA3_256.hash( contents.getBytes( scala.io.Codec.UTF8.charSet ) )
LatestSchema.Table.MailableTemplate.ensure( conn, hash, contents )
LatestSchema.Table.Mailable.insertBatch( conn, hash, from, replyTo, tosWithParams, subject, 0 )
private def resilientDelayedForFeedInfo( ds : DataSource, fi : FeedInfo ) : Task[Unit] =
val simple = withConnectionTransactional( ds )( conn => updateAssignItems( conn, fi ) ).zlogError( DEBUG, what = "Attempt to update/assign for ${fi}" )
val retrying = simple.retry( Schedule.exponential( 10.seconds, 1.5f ) && Schedule.upTo( 3.minutes ) ) // XXX: hard-coded
val delaying =
for
_ <- ZIO.sleep( math.round(math.random * 20).seconds ) // XXX: hard-coded
_ <- retrying
yield()
delaying.zlogErrorDefect( WARNING, s"Update/assign for feed ${fi.feedId}" )
def updateAssignItems( ds : DataSource ) : Task[Unit] =
for
feedInfos <- withConnectionTransactional( ds )( conn => LatestSchema.Table.Feed.selectAll( conn ) )
_ <- ZIO.collectAllParDiscard( feedInfos.map( fi => resilientDelayedForFeedInfo(ds,fi) ) )
yield ()
def completeAssignables( ds : DataSource, apiLinkGenerator : ApiLinkGenerator ) : Task[Unit] =
withConnectionTransactional( ds ): conn =>
LatestSchema.Table.Assignable.selectAllKeys( conn ).foreach: ak =>
val AssignableKey( subscribableName, withinTypeId ) = ak
val count = LatestSchema.Table.Assignment.selectCountWithinAssignable( conn, subscribableName, withinTypeId )
val ( feedId, subscriptionManager ) = LatestSchema.Table.Subscribable.selectFeedIdAndManager( conn, subscribableName )
val feedLastAssigned = LatestSchema.Table.Feed.selectLastAssigned( conn, feedId ).getOrElse:
throw new AssertionError( s"DB constraints should have ensured a row for feed with ID '${feedId}' with a NOT NULL lastAssigned, but did not?" )
if subscriptionManager.isComplete( conn, withinTypeId, count, feedLastAssigned ) then
route( conn, ak, subscriptionManager, apiLinkGenerator )
LatestSchema.Table.Subscribable.updateLastCompletedWti( conn, subscribableName, withinTypeId )
cleanUpCompleted( conn, subscribableName, withinTypeId )
INFO.log( s"Completed assignable '${withinTypeId}' with subscribable '${subscribableName}'." )
INFO.log( s"Cleaned away data associated with completed assignable '${withinTypeId}' in subscribable '${subscribableName}'." )
def cleanUpCompleted( conn : Connection, subscribableName : SubscribableName, withinTypeId : String ) : Unit =
LatestSchema.Table.Assignment.cleanAwayAssignable( conn, subscribableName, withinTypeId )
LatestSchema.Table.Assignable.delete( conn, subscribableName, withinTypeId )
LatestSchema.Join.ItemAssignableAssignment.clearOldCache( conn ) // sets item status to ItemAssignability.Cleared for all fully-distributed items
DEBUG.log( s"Assignable (item collection) defined by subscribable name '${subscribableName}', within-type-id '${withinTypeId}' has been deleted." )
DEBUG.log( "Cached values of items fully distributed have been cleared." )
def addFeed( ds : DataSource, nf : NascentFeed ) : Task[Set[FeedInfo]] =
withConnectionTransactional( ds ): conn =>
val newFeedId = LatestSchema.Table.Feed.Sequence.FeedSeq.selectNext(conn)
LatestSchema.Table.Feed.insert(conn, newFeedId, nf)
try
val fd = FeedDigest( nf.feedUrl )
fd.guidToItemContent.foreach: (guid, itemContent) =>
LatestSchema.Table.Item.insertNew( conn, newFeedId, guid, None, ItemAssignability.Excluded ) // don't cache items we're excluding anyway
DEBUG.log( s"Pre-existing item with GUID '${guid}' from new feed with ID ${newFeedId} has been excluded from distribution." )
catch
case NonFatal(t) =>
WARNING.log(s"Failed to exclude existing content from assignment when adding feed '${nf.feedUrl}'. Existing content may be distributed.", t)
LatestSchema.Table.Feed.selectAll(conn)
def listFeeds( ds : DataSource ) : Task[Set[FeedInfo]] =
withConnectionTransactional( ds ): conn =>
LatestSchema.Table.Feed.selectAll(conn)
def fetchExcluded( ds : DataSource ) : Task[Set[ExcludedItem]] =
withConnectionTransactional( ds ): conn =>
LatestSchema.Table.Item.selectExcluded(conn)
def addSubscribable( ds : DataSource, subscribableName : SubscribableName, feedId : FeedId, subscriptionManager : SubscriptionManager ) : Task[(SubscribableName,FeedId,SubscriptionManager,Option[String])] =
withConnectionTransactional( ds ): conn =>
LatestSchema.Table.Subscribable.insert( conn, subscribableName, feedId, subscriptionManager, None )
INFO.log( s"New subscribable '${subscribableName}' defined on feed with ID ${feedId}." )
( subscribableName, feedId, subscriptionManager, None )
def addSubscription( ds : DataSource, fromExternalApi : Boolean, subscribableName : SubscribableName, destinationJson : Destination.Json, confirmed : Boolean, now : Instant ) : Task[(SubscriptionManager,SubscriptionId)] =
withConnectionTransactional( ds )( conn => addSubscription( conn, fromExternalApi, subscribableName, destinationJson, confirmed, now ) )
def addSubscription( ds : DataSource, fromExternalApi : Boolean, subscribableName : SubscribableName, destination : Destination, confirmed : Boolean, now : Instant ) : Task[(SubscriptionManager,SubscriptionId)] =
withConnectionTransactional( ds )( conn => addSubscription( conn, fromExternalApi, subscribableName, destination, confirmed, now ) )
def addSubscription( conn : Connection, fromExternalApi : Boolean, subscribableName : SubscribableName, destinationJson : Destination.Json, confirmed : Boolean, now : Instant ) : (SubscriptionManager,SubscriptionId) =
val subscriptionManager = LatestSchema.Table.Subscribable.selectManager( conn, subscribableName )
val destination = subscriptionManager.materializeDestination( destinationJson )
val newId = addSubscription( conn, fromExternalApi, subscribableName, subscriptionManager, destination, confirmed, now )
(subscriptionManager, newId)
def addSubscription( conn : Connection, fromExternalApi : Boolean, subscribableName : SubscribableName, destination : Destination, confirmed : Boolean, now : Instant ) : (SubscriptionManager,SubscriptionId) =
val subscriptionManager = LatestSchema.Table.Subscribable.selectManager( conn, subscribableName )
val newId = addSubscription( conn, fromExternalApi, subscribableName, subscriptionManager, destination, confirmed, now )
(subscriptionManager, newId)
def addSubscription( conn : Connection, fromExternalApi : Boolean, subscribableName : SubscribableName, subscriptionManager : SubscriptionManager, destination : Destination, confirmed : Boolean, now : Instant ) : SubscriptionId =
subscriptionManager.validateSubscriptionOrThrow( conn, fromExternalApi, destination, subscribableName )
val newId = LatestSchema.Table.Subscription.Sequence.SubscriptionSeq.selectNext( conn )
LatestSchema.Table.Subscription.insert( conn, newId, destination, subscribableName, confirmed, now )
INFO.log:
val status = if confirmed then "confirmed" else "unconfirmed"
s"New ${status} subscription to '${subscribableName}' created for destination '${destination.shortDesc}'."
newId
def listSubscribables( ds : DataSource ) : Task[Set[(SubscribableName,FeedId,SubscriptionManager,Option[String])]] =
withConnectionTransactional( ds ): conn =>
LatestSchema.Table.Subscribable.select( conn )
object Config:
def fetchValue( conn : Connection, key : ConfigKey ) : Option[String] = LatestSchema.Table.Config.select( conn, key )
def zfetchValue( conn : Connection, key : ConfigKey ) : Task[Option[String]] = ZIO.attemptBlocking( LatestSchema.Table.Config.select( conn, key ) )
def confirmHours( conn : Connection ) : Int = fetchValue( conn, ConfigKey.ConfirmHours ).map( _.toInt ).getOrElse( Default.Config.ConfirmHours )
def dumpDbDir( conn : Connection ) : os.Path = fetchValue( conn, ConfigKey.DumpDbDir ).map( os.Path.apply ).getOrElse( throw new ConfigurationMissing( ConfigKey.DumpDbDir ) )
def timeZone( conn : Connection ) : ZoneId = fetchValue( conn, ConfigKey.TimeZone ).map( str => ZoneId.of(str) ).getOrElse( ZoneId.systemDefault() )
def mailBatchDelaySeconds( conn : Connection ) : Int = fetchValue( conn, ConfigKey.MailBatchDelaySeconds ).map( _.toInt ).getOrElse( Default.Config.MailBatchDelaySeconds )
def mailMaxRetries( conn : Connection ) : Int = fetchValue( conn, ConfigKey.MailMaxRetries ).map( _.toInt ).getOrElse( Default.Config.MailMaxRetries )
def mastodonMaxRetries( conn : Connection ) : Int = fetchValue( conn, ConfigKey.MastodonMaxRetries ).map( _.toInt ).getOrElse( Default.Config.MastodonMaxRetries )
def mailBatchSize( conn : Connection ) : Int = fetchValue( conn, ConfigKey.MailBatchSize ).map( _.toInt ).getOrElse( Default.Config.MailBatchSize )
def webDaemonPort( conn : Connection ) : Int = fetchValue( conn, ConfigKey.WebDaemonPort ).map( _.toInt ).getOrElse( Default.Config.WebDaemonPort )
def webDaemonInterface( conn : Connection ) : String = fetchValue( conn, ConfigKey.WebDaemonInterface ).getOrElse( Default.Config.WebDaemonInterface )
def webApiProtocol( conn : Connection ) : String = fetchValue( conn, ConfigKey.WebApiProtocol ).getOrElse( Default.Config.WebApiProtocol )
def webApiHostName( conn : Connection ) : String = fetchValue( conn, ConfigKey.WebApiHostName ).getOrElse( Default.Config.WebApiHostName )
def webApiBasePath( conn : Connection ) : String = fetchValue( conn, ConfigKey.WebApiBasePath ).getOrElse( Default.Config.WebApiBasePath )
def webApiPort( conn : Connection ) : Option[Int] = fetchValue( conn, ConfigKey.WebApiPort ).map( _.toInt ) orElse Default.Config.WebApiPort
// may throw, if there's nothing set and no default! catch and handle accordingly!
def fetchByKey( conn : Connection, key : ConfigKey ) : Any =
key match
case ConfigKey.ConfirmHours => confirmHours(conn)
case ConfigKey.DumpDbDir => dumpDbDir(conn)
case ConfigKey.MailBatchSize => mailBatchSize(conn)
case ConfigKey.MailBatchDelaySeconds => mailBatchDelaySeconds(conn)
case ConfigKey.MailMaxRetries => mailMaxRetries(conn)
case ConfigKey.MastodonMaxRetries => mastodonMaxRetries(conn)
case ConfigKey.TimeZone => timeZone(conn)
case ConfigKey.WebDaemonPort => webDaemonPort(conn)
case ConfigKey.WebDaemonInterface => webDaemonInterface(conn)
case ConfigKey.WebApiProtocol => webApiProtocol(conn)
case ConfigKey.WebApiHostName => webApiHostName(conn)
case ConfigKey.WebApiBasePath => webApiBasePath(conn)
case ConfigKey.WebApiPort => webApiPort(conn)
//no default! as we add keys, pay attention to the compiler and add an item here!
def webApiUrlBasePath( conn : Connection ) : (String, List[String]) =
lazy val wdi = Config.webDaemonInterface(conn)
lazy val wdp = Config.webDaemonPort(conn)
val wapr = Config.webApiProtocol(conn)
val wahn = Config.webApiHostName(conn)
val wabp = Config.webApiBasePath(conn)
val wapo = Config.webApiPort(conn).orElse:
// special case... if we're under default localhost config, include the bound port directly for testing
// otherwise assume we are behind a proxy server, and no port should be inserted
if wahn == "localhost" && wdi == "127.0.0.1" then Some(wdp) else None
val portPart = wapo.fold("")(p => s":$p")
val url = s"${wapr}://${wahn}${portPart}/"
val bp = wabp.split('/').filter( _.nonEmpty ).toList
( url, bp )
def webApiUrlBasePath( ds : DataSource ) : Task[(String, List[String])] = withConnectionTransactional( ds )( webApiUrlBasePath )
def confirmHours( ds : DataSource ) : Task[Int] = withConnectionTransactional( ds )( Config.confirmHours )
def pullMailGroup( conn : Connection ) : Set[MailSpec.WithTemplate] =
val batchSize = Config.mailBatchSize( conn )
val withHashes : Set[MailSpec.WithHash] = LatestSchema.Table.Mailable.selectForDelivery(conn, batchSize)
val contentMap = mutable.Map.empty[Hash.SHA3_256,String]
def templateFromHash( hash : Hash.SHA3_256 ) : String =
LatestSchema.Table.MailableTemplate.selectByHash( conn, hash ).getOrElse:
throw new AssertionError(s"Database consistency issue, we should only be trying to load contents from extant hashes. (${hash.hex})")
withHashes.foreach: mswh =>
contentMap.getOrElseUpdate( mswh.templateHash, templateFromHash(mswh.templateHash) )
withHashes.map( mswh => MailSpec.WithTemplate( mswh.seqnum, mswh.templateHash, templateFromHash( mswh.templateHash ), mswh.from, mswh.replyTo, mswh.to, mswh.subject, mswh.templateParams, mswh.retried ) )
def attemptMail( conn : Connection, maxRetries : Int, mswt : MailSpec.WithTemplate, smtpContext : Smtp.Context ) : Unit =
LatestSchema.Table.Mailable.deleteSingle( conn, mswt.seqnum )
var attemptDeleteContents = true
try
val contents = mswt.templateParams.fill( mswt.template )
given Smtp.Context = smtpContext
Smtp.sendSimpleHtmlOnly( contents, subject = mswt.subject, from = mswt.from.str, to = mswt.to.str, replyTo = mswt.replyTo.map(_.str).toSeq )
INFO.log(s"Mail sent from '${mswt.from}' to '${mswt.to}' with subject '${mswt.subject}'")
catch
case NonFatal(t) =>
val lastRetryMessage = if mswt.retried == maxRetries then "(last retry, will drop)" else s"(maxRetries: ${maxRetries})"
WARNING.log( s"Failed email attempt: subject = ${mswt.subject}, from = ${mswt.from}, to = ${mswt.to}, replyTo = ${mswt.replyTo} ), retried = ${mswt.retried} ${lastRetryMessage}", t )
LatestSchema.Table.Mailable.insert( conn, mswt.templateHash, mswt.from, mswt.replyTo, mswt.to, mswt.subject, mswt.templateParams, mswt.retried + 1)
attemptDeleteContents = false
if attemptDeleteContents then
LatestSchema.Table.MailableTemplate.deleteIfUnreferenced( conn, mswt.templateHash )
def mailNextGroup( conn : Connection, smtpContext : Smtp.Context ) =
val retries = Config.mailMaxRetries( conn )
val mswts = pullMailGroup( conn )
mswts.foreach( mswt => attemptMail( conn, retries, mswt, smtpContext ) )
def mailNextGroup( ds : DataSource, smtpContext : Smtp.Context ) : Task[Unit] =
withConnectionTransactional( ds ): conn =>
mailNextGroup( conn, smtpContext )
def hasMetadataTable( conn : Connection ) : Boolean =
Using.resource( conn.getMetaData().getTables(null,null,PgSchema.Unversioned.Table.Metadata.Name,null) )( _.next() )
def ensureMetadataTable( conn : Connection ) : Task[Unit] =
ZIO.attemptBlocking:
if !hasMetadataTable(conn) then throw new DbNotInitialized("Please initialize the database. (No metadata table found.)")
def ensureDb( ds : DataSource ) : Task[Unit] =
withConnectionZIO( ds ): conn =>
for
_ <- ensureMetadataTable(conn)
mbSchemaVersion <- zfetchMetadataValue(conn, MetadataKey.SchemaVersion).map( option => option.map( _.toInt ) )
mbAppVersion <- zfetchMetadataValue(conn, MetadataKey.CreatorAppVersion)
yield
mbSchemaVersion match
case Some( schemaVersion ) =>
if schemaVersion > LatestSchema.Version then
throw new MoreRecentFeedletterVersionRequired(
s"The database schema version is ${schemaVersion}. " +
mbAppVersion.fold("")( appVersion => s"It was created by app version '${appVersion}'. " ) +
s"The latest version known by this version of the app is ${LatestSchema.Version}. " +
s"You are running app version '${BuildInfo.version}'."
)
else if schemaVersion < LatestSchema.Version then
throw new SchemaMigrationRequired(
s"The database schema version is ${schemaVersion}. " +
mbAppVersion.fold("")( appVersion => s"It was created by app version '${appVersion}'. " ) +
s"The current schema this version of the app (${BuildInfo.version}) is ${LatestSchema.Version}. " +
"Please migrate."
)
// else schemaVersion == LatestSchema.version and we're good
case None =>
throw new DbNotInitialized("Please initialize the database.")
end ensureDb
def feedUrl( conn : Connection, feedId : FeedId ) : Option[FeedUrl] =
LatestSchema.Table.Feed.selectUrl(conn, feedId)
def assertFeedUrl( conn : Connection, feedId : FeedId ) : FeedUrl =
LatestSchema.Table.Feed.selectUrl(conn, feedId).getOrElse:
throw new FeedletterException( s"Expected a URL assigned for Feed ID '$feedId', none found.")
def feedIdUrlForSubscribableName( conn : Connection, subscribableName : SubscribableName ) : ( FeedId, FeedUrl ) =
LatestSchema.Join.ItemSubscribable.selectFeedIdUrlForSubscribableName( conn, subscribableName )
def subscriptionManagerForSubscribableName( conn : Connection, subscribableName : SubscribableName ) : SubscriptionManager =
LatestSchema.Table.Subscribable.selectManager( conn, subscribableName )
def subscriptionManagerForSubscribableName( ds : DataSource, subscribableName : SubscribableName ) : Task[SubscriptionManager] =
withConnectionTransactional( ds )( subscriptionManagerForSubscribableName( _, subscribableName ) )
def feedUrlSubscriptionManagerForSubscribableName( conn : Connection, subscribableName : SubscribableName ) : ( FeedUrl, SubscriptionManager ) =
LatestSchema.Join.ItemSubscribable.selectFeedUrlSubscriptionManagerForSubscribableName( conn, subscribableName )
def feedUrlSubscriptionManagerForSubscribableName( ds : DataSource, subscribableName : SubscribableName ) : Task[( FeedUrl, SubscriptionManager )] =
withConnectionTransactional( ds )( feedUrlSubscriptionManagerForSubscribableName( _, subscribableName ) )
def updateSubscriptionManagerJson( conn : Connection, subscribableName : SubscribableName, subscriptionManager : SubscriptionManager ) =
LatestSchema.Table.Subscribable.updateSubscriptionManagerJson( conn, subscribableName, subscriptionManager )
def updateSubscriptionManagerJson( ds : DataSource, subscribableName : SubscribableName, subscriptionManager : SubscriptionManager ) : Task[Unit] =
withConnectionTransactional( ds )( conn => updateSubscriptionManagerJson(conn, subscribableName, subscriptionManager) )
def updateConfirmed( conn : Connection, subscriptionId : SubscriptionId, confirmed : Boolean ) : Unit =
LatestSchema.Table.Subscription.updateConfirmed( conn, subscriptionId, confirmed )
INFO.log:
val status = if confirmed then "confirmed" else "unconfirmed"
s"Subscription with ID ${subscriptionId} has been marked ${status}."
def updateConfirmed( ds : DataSource, subscriptionId : SubscriptionId, confirmed : Boolean ) : Task[Unit] =
withConnectionTransactional( ds )( conn => updateConfirmed(conn, subscriptionId, confirmed) )
def subscriptionInfoForSubscriptionId( conn : Connection, id : SubscriptionId ) : Option[SubscriptionInfo] =
LatestSchema.Join.SubscribableSubscription.selectSubscriptionInfoForSubscriptionId( conn, id )
def unsubscribe( conn : Connection, id : SubscriptionId ) : Option[SubscriptionInfo] =
val out = subscriptionInfoForSubscriptionId( conn, id )
out.foreach: _ =>
LatestSchema.Table.Subscription.delete( conn, id )
INFO.log( s"Subscription with ID ${id} removed." )
out
def webDaemonBinding( ds : DataSource ) : Task[(String,Int)] =
withConnectionTransactional( ds ): conn =>
( Config.webDaemonInterface( conn ), Config.webDaemonPort( conn ) )
def uninterpretedManagerJsonForSubscribableName( ds : DataSource, subscribableName : SubscribableName ) : Task[String] =
withConnectionTransactional( ds ): conn =>
LatestSchema.Table.Subscribable.selectUninterpretedManagerJson( conn, subscribableName )
def checkFlag( conn : Connection, flag : Flag ) : Boolean = LatestSchema.Table.Flags.isSet( conn, flag )
def clearFlag( conn : Connection, flag : Flag ) : Unit = LatestSchema.Table.Flags.unset( conn, flag )
def setFlag ( conn : Connection, flag : Flag ) : Unit = LatestSchema.Table.Flags.set ( conn, flag )
def checkFlag( ds : DataSource, flag : Flag ) : Task[Boolean] = withConnectionTransactional(ds)( conn => checkFlag(conn,flag) )
def clearFlag( ds : DataSource, flag : Flag ) : Task[Unit] = withConnectionTransactional(ds)( conn => clearFlag(conn,flag) )
def expireUnconfirmed( conn : Connection ) : Unit =
val confirmHours = math.max(Config.confirmHours( conn ), 0)
DEBUG.log( s"Expiring unconfirmed subscriptions at least ${confirmHours} hours old." )
val expiredThreshold = Instant.now().minusSeconds( confirmHours * 60 )
val subscriptionsExpired = LatestSchema.Table.Subscription.expireUnconfirmedAddedBefore( conn, expiredThreshold )
if subscriptionsExpired > 0 then
INFO.log( s"Expired ${subscriptionsExpired} unconfirmed subscriptions at least ${confirmHours} hours old." )
def expireUnconfirmed( ds : DataSource ) : Task[Unit] = withConnectionTransactional( ds )( expireUnconfirmed )
def queueForMastoPost( conn : Connection, fullContent : String, mastoInstanceUrl : MastoInstanceUrl, mastoName : MastoName, media : Seq[ItemContent.Media] ) =
val id = LatestSchema.Table.MastoPostable.Sequence.MastoPostableSeq.selectNext( conn )
LatestSchema.Table.MastoPostable.insert( conn, id, fullContent, mastoInstanceUrl, mastoName, 0 )
(0 until media.size).foreach: i =>
LatestSchema.Table.MastoPostableMedia.insert( conn, id, i, media(i) )
def attemptMastoPost( conn : Connection, appSetup : AppSetup, maxRetries : Int, mastoPostable : MastoPostable ) : Boolean =
val media = mastoPostable.media
if media.nonEmpty then
LatestSchema.Table.MastoPostableMedia.deleteById(conn, mastoPostable.id)
LatestSchema.Table.MastoPostable.delete(conn, mastoPostable.id)
try
mastoPost( appSetup, mastoPostable )
INFO.log( s"Notification posted to Mastodon destination ${mastoPostable.instanceUrl}." )
true
catch
case NonFatal(t) =>
val lastRetryMessage = if mastoPostable.retried == maxRetries then "(last retry, will drop)" else s"(maxRetries: ${maxRetries})"
WARNING.log( s"Failed attempt to post to Mastodon destination '${mastoPostable.instanceUrl}', retried = ${mastoPostable.retried} ${lastRetryMessage}", t )
val newId = LatestSchema.Table.MastoPostable.Sequence.MastoPostableSeq.selectNext( conn )
LatestSchema.Table.MastoPostable.insert( conn, newId, mastoPostable.finalContent, mastoPostable.instanceUrl, mastoPostable.name, mastoPostable.retried + 1 )
(0 until media.size).foreach: i =>
LatestSchema.Table.MastoPostableMedia.insert( conn, mastoPostable.id, i, media(i) )
false
def notifyAllMastoPosts( conn : Connection, appSetup : AppSetup ) =
val retries = Config.mastodonMaxRetries( conn )
LatestSchema.Table.MastoPostable.foreach( conn ): mastoPostable =>
attemptMastoPost( conn, appSetup, retries, mastoPostable )
def notifyAllMastoPosts( ds : DataSource, appSetup : AppSetup ) : Task[Unit] =
withConnectionTransactional( ds )( conn => notifyAllMastoPosts( conn, appSetup ) )
def subscriptionsForSubscribableName( ds : DataSource, subscribableName : SubscribableName ) : Task[Set[( SubscriptionId, Destination, Boolean, Instant )]] =
withConnectionTransactional( ds ): conn =>
LatestSchema.Table.Subscription.selectForSubscribable( conn, subscribableName )
def removeSubscribable( conn : Connection, subscribableName : SubscribableName, removeSubscriptionsIfNecessary : Boolean ) =
if removeSubscriptionsIfNecessary then
LatestSchema.Table.Subscription.deleteAllForSubscribable( conn, subscribableName )
LatestSchema.Table.Assignment.cleanAwayAssignableAllAssignablesForSubscribable(conn, subscribableName)
LatestSchema.Table.Assignable.deleteAllForSubscribable(conn, subscribableName)
LatestSchema.Table.Subscribable.delete( conn, subscribableName )
def removeSubscribable( ds : DataSource, subscribableName : SubscribableName, removeSubscriptionsIfNecessary : Boolean ) : Task[Unit] =
withConnectionTransactional( ds ): conn =>
removeSubscribable( conn, subscribableName, removeSubscriptionsIfNecessary )
def subscribersExist( conn : Connection, subscribableName : SubscribableName ) : Boolean =
LatestSchema.Table.Subscription.subscribersExist( conn, subscribableName )
def subscribersExist( ds : DataSource, subscribableName : SubscribableName ) : Task[Boolean] =
withConnectionTransactional( ds ): conn =>
subscribersExist( conn, subscribableName )
def cautiousRemoveSubscribable( conn : Connection, subscribableName : SubscribableName ) : Unit =
val theyExist = PgDatabase.subscribersExist( conn, subscribableName )
if theyExist then
throw new WouldDropSubscriptions(s"Removing subscribable '${subscribableName.str}' would delete active subscriptions! Set remove-active-subscriptions flag if you wish to force deletion anyway.")
else
removeSubscribable(conn, subscribableName, false)
def cautiousRemoveSubscribable( ds : DataSource, subscribableName : SubscribableName ) : Task[Unit] =
withConnectionTransactional( ds ): conn =>
cautiousRemoveSubscribable( conn, subscribableName )
def removeFeedAndSubscribables( conn : Connection, feedId : FeedId, removeSubscriptionsIfNecessary : Boolean ) : Unit =
val subscribables = LatestSchema.Table.Subscribable.selectByFeed( conn, feedId )
if removeSubscriptionsIfNecessary then
subscribables.foreach( subscribableName => removeSubscribable( conn, subscribableName, true ) )
else
subscribables.foreach( subscribableName => cautiousRemoveSubscribable(conn, subscribableName) )
LatestSchema.Table.Item.deleteByFeed( conn, feedId )
LatestSchema.Table.Feed.delete( conn, feedId )
def removeFeedAndSubscribables( ds : DataSource, feedId : FeedId, removeSubscriptionsIfNecessary : Boolean ) : Task[Unit] =
withConnectionTransactional( ds ): conn =>
removeFeedAndSubscribables(conn,feedId,removeSubscriptionsIfNecessary)
def updateFeedTimings( conn : Connection, feedId : FeedId, minDelayMinutes : Int, awaitStabilizationMinutes : Int, maxDelayMinutes : Int, assignEveryMinutes : Int ) : Unit =
LatestSchema.Table.Feed.updateFeedTimings(conn, feedId, minDelayMinutes, awaitStabilizationMinutes, maxDelayMinutes, assignEveryMinutes)
def updateFeedTimings( ds : DataSource, feedId : FeedId, minDelayMinutes : Int, awaitStabilizationMinutes : Int, maxDelayMinutes : Int, assignEveryMinutes : Int ) : Task[Unit] =
withConnectionTransactional( ds ): conn =>
updateFeedTimings( conn, feedId, minDelayMinutes, awaitStabilizationMinutes, maxDelayMinutes, assignEveryMinutes )
def mergeFeedTimings( conn : Connection, fts : FeedTimings ) : Unit =
val fi = LatestSchema.Table.Feed.selectById( conn, fts.feedId )
val nextMinDelayMinutes = fts.minDelayMinutes.getOrElse( fi.minDelayMinutes )
val nextAwaitStabilizationMinutes = fts.awaitStabilizationMinutes.getOrElse( fi.awaitStabilizationMinutes )
val nextMaxDelayMinutes = fts.maxDelayMinutes.getOrElse( fi.maxDelayMinutes )
val nextAssignEveryMinutes = fts.assignEveryMinutes.getOrElse( fi.assignEveryMinutes )
updateFeedTimings( conn, fts.feedId, nextMinDelayMinutes, nextAwaitStabilizationMinutes, nextMaxDelayMinutes, nextAssignEveryMinutes )
def mergeFeedTimings( ds : DataSource, fts : FeedTimings ) : Task[Unit] =
withConnectionTransactional( ds ): conn =>
mergeFeedTimings( conn, fts )
def sendNextImmediatelyMailable( conn : Connection, as : AppSetup ) : Boolean =
val mbim = LatestSchema.Table.ImmediatelyMailable.selectNext( conn )
mbim match
case Some( im ) =>
LatestSchema.Table.ImmediatelyMailable.delete(conn, im) // if the transaction fails (as opposed to just the sending) we'll come back
try
val filledContents = im.templateParams.fill( im.contents )
given Smtp.Context = as.smtpContext
Smtp.sendSimpleHtmlOnly( im.contents, subject = im.subject, from = im.from.str, to = im.to.str, replyTo = im.replyTo.map(_.str).toSeq )
INFO.log(s"Expedited mail sent from '${im.from}' to '${im.to}' with subject '${im.subject}'")
catch
case NonFatal(t) =>
WARNING.log("Attempt to mail immediately from '${from}' to '${to}' with subject '${subject}' failed. Queuing to reattempt later.", t)
queueForMailing(conn, im.contents, im.from, im.replyTo, im.to, im.templateParams, im.subject)
true
case None =>
false
def sendNextImmediatelyMailable( ds : DataSource, as : AppSetup ) : Task[Boolean] =
withConnectionTransactional( ds ): conn =>
sendNextImmediatelyMailable( conn, as )
def destinationAlreadySubscribed( conn : Connection, destination : Destination, subscribableName : SubscribableName ) : Boolean =
LatestSchema.Table.Subscription.destinationAlreadySubscribed( conn, destination, subscribableName )
© 2015 - 2025 Weber Informatics LLC | Privacy Policy