Services
The service layer holds Marginalia's logic: everything the UI does to data goes through a service, and services are the main API extensions use. This page describes how services and repositories are built, how transactions and the current user work, and what each service is for. The generation engine has its own page, Generation pipeline.
Layers
flowchart LR
UI["UI / extensions<br/>(@Configurable, @Autowired)"] --> S["Service interface<br/>domain/service"]
S -.-> P["Spring CGLIB proxy<br/>transactions"]
P --> I["Service implementation<br/>domain/service/impl"]
I --> R["Repository<br/>domain/repository/impl/Jpa*"]
R --> EM["EntityManager<br/>(shared, transaction-bound)"]
I --> O["other services"]-
Interfaces live in
domain/service/(anddomain/security/service/), implementations in.../impl/. Code always depends on the interface. -
Beans are declared in
META-INF/spring/services-config.xml. Each entity service gets its repository injected through therepositoryproperty; everything else is@Autowiredinside the implementation. -
Repositories (
domain/repository/impl/Jpa*Repository) contain the JPQL and native SQL. They are called only by their service.
The generic service hierarchy
Entity services extend a generic base that matches the entity's base class:
The same three levels exist for repositories: JpaBaseRepository → JpaOwnedRepository →
JpaExtendableRepository. A repository only has to name its entity class (getEntityClass()) to get the generic
queries; it can override hydrate(entity) to post-process loaded entities.
Things to know about the generic methods:
-
find(id)doesn't check owner ordeleted. Use it for ids that come from the user's own data (a book'sai, a part'sparent). For anything that comes from outside - a URL, an uploaded file, a backup - usefindForUser(uuid), which returnsnullfor other users' and deleted entities. -
find(entity)reloads. Entities held by the UI are detached and may be stale; services usually start withManuscript m = find(manuscript)before changing it. -
findAll*skip deleted rows,find(id)doesn't. -
savereturns the managed copy (merge). Keep using the returned object. -
savevs.saveWithoutEvent-saveserializes the extended attributes intoextendedContent;saveWithoutEventstoresextendedContentas it is (used when it was copied from a backup). Both keep the_fulltextcolumn in step with the@Fulltextablefields, see Full-text column. -
delete(entity, false)soft deletes,delete(entity, true)removes the row. See Soft delete.
Transactions
Transactions are declared with three meta-annotations from domain/traits/ and applied by Spring's
tx:annotation-driven with class-based (CGLIB) proxies around each service bean (proxy-target-class="true"):
The rules that follow from proxies:
-
Only calls through a bean reference are transactional. A service calling its own method (
this.save(...)) bypasses the proxy, so that method's annotation has no effect. When a service needs its own transactional method from a callback, it injects itself -SummaryServiceImplhas@Autowired private SummaryService selfand callsself.save(summary)from the streaming callback. -
Transactions are short. SQLite allows one writer at a time (see SQLite limitations); a long write transaction blocks every other user's writes. Never call a model, wait for the UI or sleep inside a transaction. Streaming code saves through the service for each change, so each save is its own transaction.
-
A transaction ends with the outermost annotated call. Entities returned to the UI are detached. Lazy associations (
ChatMessage.parent,ChatMessage.parentScript,LorebookEntry.lorebook) can't be navigated after that - reload them through the service (chatMessageService.getParent(node),find(...)). -
Methods without an annotation run in the caller's transaction, or without one.
The current user
Services are singletons, but the user differs per browser session. The session-scoped bean user
(domain.security.model.User behind a scoped proxy, defined in container-config.xml) is injected into services as
@Autowired
protected User currentUser;
Each call on currentUser is resolved against the HTTP session of the current thread. It holds a copy of the
logged-in user's id, login and fullName - load the entity (userService.find(currentUser.getId())) for
anything else, such as isAdmin().
Services that depend on the current user:
-
OwnedServiceImpl-findAllForUser,findForUser, and the owner of new entities, -
SettingService.getOrCreate(UserSetting.class)- the user's settings, -
ManuscriptService- searches,findViewable,markOpened, and the prompt defaults (getMasterTemplate... fall back to the current user'sUserSetting), -
administrator checks:
AdminGuard.requireAdmin()(seeAdministration ).
On background threads there is no request, so resolving currentUser fails with No thread-bound request
found. Capture the request attributes on the UI thread and enter them on the worker:
ThreadCopyRequestAttributes attributes = ThreadCopyRequestAttributes.create(); // on the UI thread
executor.submit(() -> {
try (InRequestScope _ = new InRequestScope(attributes)) { // on the worker
lorebookService.findAllForUser(); // current user resolves
}
});
The generation engine, SummaryServiceImpl and ThreadAccessDialog do this. Scheduled jobs (database backups) run
without any user and only use installation-wide data (getOrCreateApp).
Services by area
Books and the story
Images in backups. A stored backup is JSON and never contains image files, only the imageAttachments of the
parts (resource uuids) in their extendedContent. ManuscriptBackup.imageCount is a header field (read by
parseMetadataOnly without loading the backup) so the UI knows whether to ask. BackupImages reads and rewrites the
attachments inside the backup JSON (collect, remap where returning null drops the image).
exportBackup(backup, withImages, onImage) writes to a temp file and returns a BackupExport; with images it is a ZIP:
backup.json (the stored file, streamed), resources.json ({"version":1,"resources":[{uuid, entry, name, mimeType, hash, size}]}) and resources/<n>.<ext>, one image at a time with Files.copy, so nothing but the JSON tree is in memory.
Import, restore and analyzeLorebooks detect a ZIP by its first bytes (openSource) and read entries only by the names
the manifest lists under resources/ (no extraction to disk). Each image is read bounded by MAX_IMAGE_BYTES and goes
through ResourceService.uploadImage, so it is validated again and the hash dedupe reuses an existing file.
Resources are not shared by books, so: importBackup keeps a uuid the user already has with the same hash and otherwise
adds the image (adopting), restoreAsNewManuscript and cloneBackup(..., withImages) make a new Resource for every
use (from the archive, or ResourceService.copy of the user's own resource, which points to the same file); images that
can't be found are dropped from the parts. After every restore linkImages notes the new parts in the resources whose
link is empty or stale. The ZIP is only a transfer format, a stored backup stays a JSON file.
The automatic book backups (BackupStrategy.AFTER_N_MESSAGES / AFTER_N_MINUTES) are triggered by the story editor
after a part is generated (ManuscriptStoryPart), not by a service - code that generates parts without the editor
doesn't take them.
Story export
ExportDialog (opened from the settings menu of ManuscriptStoryPart) collects the active branch in one
ProgressBarDialog (indeterminate), cuts the chosen range and exports it in a second one whose total is the number of
messages, then shows the download link. The exporter gets ExportOptions (title, author, title page, language), the
messages and the progress dialog, on which it calls updateProgress() for every message.
ExporterBase does the common work: it renders the title page from Defaults.DEFAULT_EXPORT_HEADER_TEMPLATE
(Handlebars, data ExportHeaderTemplateData), skips parts without text, reports progress and feeds the Markdown of
each part to an ExportWriter that the exporter creates per export - exporters are shared between users, so any state
must live in the writer. A part whose text has a Markdown heading is a chapter (chapterTitle, the same rule as the
Chapter Marker plugin: the first # line); before the first part the writer gets the list of all chapters in
contents(...) and every chapter start in message(markdown, chapter), so it can put an anchor (Chapter.anchor())
on the chapter and link to it from the table of contents (HTML, EPUB navigation, PDF, DOCX; TXT and Markdown ignore it).
The PDF exporter keeps the export and renders it again until the pages shown in the contents match the pages the
chapters landed on; DOCX uses PAGEREF fields and sets updateFields, so Word fills them in after asking.
Images attached to a part (ChatMessage.getImages()) are loaded once per part (ResourceService.findImage +
getResourceData, so only the exporting user's images; a missing one is skipped with a warning) and handed to the writer
in images(List<ExportImage>) right after message(...). A part with images but no text is exported with an empty text.
HTML embeds them as data URIs, EPUB as files in OEBPS/images/ with manifest items, DOCX with POI pictures (scaled to
fit the page), PDF with OpenPDF images, TXT as [Image: caption], Markdown as  plus an italic caption line. figure(src, caption) renders the XHTML figure.
MarkdownExporter is the only one that does not parse: the Markdown of each part is written as it is (raw HTML included, since the output is
source and not a rendered format), as blocks separated by an empty line.
It also provides toHtml (CommonMark, raw HTML escaped, XHTML safe) and walk, which turns
Markdown into paragraphs, headings, code and rules for formats built from styled text (BlockSink; used by TXT, DOCX
and PDF). Libraries: CommonMark (parsing), Apache POI (DOCX), OpenPDF with the Liberation fonts (PDF); EPUB is written
with java.util.zip.
Lorebooks and tags
Models
Users and settings
Login throttling
Failed logins are limited twice. Per user (User.failedLogins, lockedUntil, in the database) and per client address
(UserServiceImpl.addressFailures, in memory). Both use the same back-off, 1 s, 2 s, 4 s ... up to 15 minutes, after
10 (user) or 30 (address) consecutive failures; the address limit is higher because several users can share an address.
A failure counts against the address whatever the user name was, so a password spray over many accounts is stopped
even though no single account fails often.
The address comes from UIUtils.clientAddress(Configuration) (LoginCheckRoute.clientAddress() in the login routes),
which asks ClientAddressResolver:
-
The address of the connection is used by default.
X-Forwarded-Foris ignored, because any client can send it. -
When
trustedProxies(configuration.properties, or-DtrustedProxies=...) lists the connecting peer - addresses or CIDR ranges - the header is read from the right and the first address that is not a trusted proxy is the client. Entries left of it were sent by the client and are never used. The private network is not trusted by itself. -
Host names are never resolved and a bad
trustedProxiesentry stops the start.
Without a proxy nothing needs to be configured. Behind one, list the proxy, otherwise all users share its address and 30 failures from anyone block everybody for a while.
Administration
The services behind the Administration screen check on the server that the caller is an administrator, so they stay
safe if something other than that screen (an API, an extension) ever reaches them. They call
AdminGuard.requireAdmin() (bean in services-config.xml), which loads the current user from the database - the
session copy of admin is not trusted, a user demoted or deleted mid-session loses access at once - and throws
SecurityException for nobody, a deleted user or a non-administrator. Guarded: every DatabaseBackupService method
except createScheduledBackup and getNextScheduledBackup, CleanupService.getReferenceModel/analyze/purge,
OsgiService.installPackage/uninstallPackage, and UserService.deleteUser/clearPassword/unlock. Code that runs
without a request (the backup schedule, loading extensions on start, registerContributor) is not guarded, and
inside these services it calls private unguarded variants (listBackups, removeBackup). A new administrator-only
service method calls adminGuard.requireAdmin() first.
Asynchronous operations
Long operations - generating a part, a summary, streaming from a model - return immediately and report through a callback:
CancellationToken.cancel() stops the operation (the inference callback's isDead() reports it to the stream).
Callbacks run on worker threads: UI code in them must use ui.access(...) (see
Threads and push), and service calls need the request scope shown above.
Writing a new service
- Interface in
domain/service/; for an entity extendExtendableService<Entity, EntityRepository>. - Implementation in
domain/service/impl/extendingExtendableServiceImpl<...>; repository extendingJpaExtendableRepository<Entity>withgetEntityClass(). -
Beans in
services-config.xml:<bean id="bookmarkRepository" class="com.github.enerccio.marginalia.domain.repository.impl.JpaBookmarkRepository"> <property name="entityManager" ref="em"/> </bean> <bean class="com.github.enerccio.marginalia.domain.service.impl.BookmarkServiceImpl"> <property name="repository" ref="bookmarkRepository" /> </bean> - Annotate every public method:
@CommonTx,@CommonTxReadOnlyor@NoTx. - Use
currentUserfor ownership,findForUser(uuid)for anything coming from outside, and callAdminGuard.requireAdmin()for administrator operations - hiding a button in the UI is not a permission check. - Keep the class non-final with public methods (CGLIB proxies subclass it).
- Test it on top of
MarginaliaTestBase(see Testing).
Extensions can't add beans to the application context. They keep their logic in their own classes annotated
@Configurable (plugins are compiled with the AspectJ compiler too), which get the application's services
@Autowired - for example the Reviewer's ReviewerService. Transactions then come from the application services they
call. See
Plugin development.