/* Part of SWI-Prolog Author: Jan Wielemaker E-mail: J.Wielemaker@vu.nl WWW: http://www.swi-prolog.org Copyright (c) 2006-2015, University of Amsterdam VU University Amsterdam All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ :- module(rdf_persistency, [ rdf_attach_db/2, % +Directory, +Options rdf_detach_db/0, % +Detach current Graph rdf_current_db/1, % -Directory rdf_persistency/2, % +Graph, +Bool rdf_flush_journals/1, % +Options rdf_persistency_property/1, % ?Property rdf_journal_file/2, % ?Graph, ?JournalFile rdf_snapshot_file/2, % ?Graph, ?SnapshotFile rdf_db_to_file/2 % ?Graph, ?FileBase ]). :- use_module(library(semweb/rdf_db)). :- use_module(library(filesex)). :- use_module(library(lists)). :- use_module(library(uri)). :- use_module(library(debug)). :- use_module(library(option)). :- use_module(library(error)). :- use_module(library(thread)). :- use_module(library(apply)). /** RDF persistency plugin This module provides persistency for rdf_db.pl based on the rdf_monitor/2 predicate to track changes to the repository. Where previous versions used autosafe of the whole database using the quick-load format of rdf_db, this version is based on a quick-load file per source (4th argument of rdf/4), and journalling for edit operations. The result is safe, avoids frequent small changes to large files which makes synchronisation and backup expensive and avoids long disruption of the server doing the autosafe. Only loading large files disrupts service for some time. The persistent backup of the database is realised in a directory, using a lock file to avoid corruption due to concurrent access. Each source is represented by two files, the latest snapshot and a journal. The state is restored by loading the snapshot and replaying the journal. The predicate rdf_flush_journals/1 can be used to create fresh snapshots and delete the journals. @tbd If there is a complete `.new' snapshot and no journal, we should move the .new to the plain snapshot name as a means of recovery. @tbd Backup of each graph using one or two files is very costly if there are many graphs. Although the currently used subdirectories avoid hitting OS limits early, this is still not ideal. Probably we should collect (small, older?) files and combine them into a single quick load file. We could call this (similar to GIT) a `pack'. @see rdf_edit.pl */ :- volatile rdf_directory/1, rdf_lock/2, rdf_option/1, source_journal_fd/2, file_base_db/2. :- dynamic rdf_directory/1, % Absolute path rdf_lock/2, % Dir, Lock rdf_option/1, % Defined options source_journal_fd/2, % DB, JournalFD file_base_db/2. % FileBase, DB :- meta_predicate no_agc(0). :- predicate_options(rdf_attach_db/2, 2, [ access(oneof([read_write,read_only])), concurrency(positive_integer), max_open_journals(positive_integer), silent(oneof([true,false,brief])), log_nested_transactions(boolean) ]). %! rdf_attach_db(+Directory, +Options) is det. % % Start persistent operations using Directory as place to store % files. There are several cases: % % * Empty DB, existing directory % Load the DB from the existing directory % % * Full DB, empty directory % Create snapshots for all sources in directory % % Options: % % * access(+AccessMode) % One of =auto= (default), =read_write= or % =read_only=. Read-only access implies that the RDF % store is not locked. It is read at startup and all % modifications to the data are temporary. The default % =auto= mode is =read_write= if the directory is % writeable and the lock can be acquired. Otherwise % it reverts to =read_only=. % % * concurrency(+Jobs) % Number of threads to use for loading the initial % database. If not provided it is the number of CPUs % as optained from the flag =cpu_count=. % % * max_open_journals(+Count) % Maximum number of journals kept open. If not provided, % the default is 10. See limit_fd_pool/0. % % * directory_levels(+Count) % Number of levels of intermediate directories for storing % the graph files. Default is 2. % % * silent(+BoolOrBrief) % If =true= (default =false=), do not print informational % messages. Finally, if =brief= it will show minimal % feedback. % % * log_nested_transactions(+Boolean) % If =true=, nested _log_ transactions are added to the % journal information. By default (=false=), no log-term % is added for nested transactions.\\ % % @error existence_error(source_sink, Directory) % @error permission_error(write, directory, Directory) rdf_attach_db(DirSpec, Options) :- option(access(read_only), Options), !, absolute_file_name(DirSpec, Directory, [ access(read), file_type(directory) ]), rdf_attach_db_ro(Directory, Options). rdf_attach_db(DirSpec, Options) :- option(access(read_write), Options), !, rdf_attach_db_rw(DirSpec, Options). rdf_attach_db(DirSpec, Options) :- absolute_file_name(DirSpec, Directory, [ access(exist), file_type(directory), file_errors(fail) ]), !, ( access_file(Directory, write) -> catch(rdf_attach_db_rw(Directory, Options), E, true), ( var(E) -> true ; E = error(permission_error(lock, rdf_db, _), _) -> print_message(warning, E), print_message(warning, rdf(read_only)), rdf_attach_db(DirSpec, [access(read_only)|Options]) ; throw(E) ) ; print_message(warning, error(permission_error(write, directory, Directory))), print_message(warning, rdf(read_only)), rdf_attach_db_ro(Directory, Options) ). rdf_attach_db(DirSpec, Options) :- catch(rdf_attach_db_rw(DirSpec, Options), E, true), ( var(E) -> true ; print_message(warning, E), print_message(warning, rdf(read_only)), rdf_attach_db(DirSpec, [access(read_only)|Options]) ). rdf_attach_db_rw(DirSpec, Options) :- absolute_file_name(DirSpec, Directory, [ access(write), file_type(directory), file_errors(fail) ]), !, ( rdf_directory(Directory) -> true % update settings? ; rdf_detach_db, mkdir(Directory), lock_db(Directory), assert(rdf_directory(Directory)), assert_options(Options), stop_monitor, % make sure not to register load no_agc(load_db), at_halt(rdf_detach_db), start_monitor ). rdf_attach_db_rw(DirSpec, Options) :- absolute_file_name(DirSpec, Directory, [ solutions(all) ]), ( exists_directory(Directory) -> access_file(Directory, write) ; catch(make_directory(Directory), _, fail) ), !, rdf_attach_db(Directory, Options). rdf_attach_db_rw(DirSpec, _) :- % Generate an existence or absolute_file_name(DirSpec, % permission error Directory, [ access(exist), file_type(directory) ]), permission_error(write, directory, Directory). %! rdf_attach_db_ro(+Directory, +Options) % % Open an RDF database in read-only mode. rdf_attach_db_ro(Directory, Options) :- rdf_detach_db, assert(rdf_directory(Directory)), assert_options(Options), stop_monitor, % make sure not to register load no_agc(load_db). assert_options([]). assert_options([H|T]) :- ( option_type(H, Check) -> Check, assert(rdf_option(H)) ; true % ignore options we do not understand ), assert_options(T). option_type(concurrency(X), must_be(positive_integer, X)). option_type(max_open_journals(X), must_be(positive_integer, X)). option_type(directory_levels(X), must_be(positive_integer, X)). option_type(silent(X), must_be(oneof([true,false,brief]), X)). option_type(log_nested_transactions(X), must_be(boolean, X)). option_type(access(X), must_be(oneof([read_write, read_only]), X)). %! rdf_persistency_property(?Property) is nondet. % % True if Property is a property of the current persistent % database. Currently makes to options passed to rdf_attach_db/2 % available. Notable rdf_persistency_property(access(read_only)) % is true if the database is mounted in read-only mode. Other % properties: % % - directory(Dir) % Directory in which the database resides. rdf_persistency_property(Property) :- var(Property), !, rdf_persistency_property_(Property). rdf_persistency_property(Property) :- rdf_persistency_property_(Property), !. rdf_persistency_property_(Property) :- rdf_option(Property). rdf_persistency_property_(directory(Dir)) :- rdf_directory(Dir). %! no_agc(:Goal) % % Run Goal with atom garbage collection disabled. Loading an RDF % database creates large amounts of atoms we *know* are not % garbage. no_agc(Goal) :- current_prolog_flag(agc_margin, Old), setup_call_cleanup( set_prolog_flag(agc_margin, 0), Goal, set_prolog_flag(agc_margin, Old)). %! rdf_detach_db is det. % % Detach from the current database. Succeeds silently if no % database is attached. Normally called at the end of the program % through at_halt/1. rdf_detach_db :- debug(halt, 'Detaching RDF database', []), stop_monitor, close_journals, ( retract(rdf_directory(Dir)) -> debug(halt, 'DB Directory: ~w', [Dir]), save_prefixes(Dir), retractall(rdf_option(_)), retractall(source_journal_fd(_,_)), unlock_db(Dir) ; true ). %! rdf_current_db(?Dir) % % True if Dir is the current RDF persistent database. rdf_current_db(Directory) :- rdf_directory(Dir), !, Dir = Directory. %! rdf_flush_journals(+Options) % % Flush dirty journals. Options: % % * min_size(+KB) % Only flush if journal is over KB in size. % * graph(+Graph) % Only flush the journal of Graph % % @tbd Provide a default for min_size? rdf_flush_journals(Options) :- option(graph(Graph), Options, _), forall(rdf_graph(Graph), rdf_flush_journal(Graph, Options)). rdf_flush_journal(Graph, Options) :- db_files(Graph, _SnapshotFile, JournalFile), db_file(JournalFile, File), ( \+ exists_file(File) -> true ; memberchk(min_size(KB), Options), size_file(JournalFile, Size), Size / 1024 < KB -> true ; create_db(Graph) ). /******************************* * LOAD * *******************************/ %! load_db is det. % % Reload database from the directory specified by rdf_directory/1. % First we find all names graphs using find_dbs/1 and then we load % them. load_db :- rdf_directory(Dir), concurrency(Jobs), cpu_stat_key(Jobs, StatKey), get_time(Wall0), statistics(StatKey, T0), load_prefixes(Dir), verbosity(Silent), find_dbs(Dir, Graphs, SnapShots, Journals), length(Graphs, GraphCount), maplist(rdf_unload_graph, Graphs), rdf_statistics(triples(Triples0)), load_sources(snapshots, SnapShots, Silent, Jobs), load_sources(journals, Journals, Silent, Jobs), rdf_statistics(triples(Triples1)), statistics(StatKey, T1), get_time(Wall1), T is T1 - T0, Wall is Wall1 - Wall0, Triples = Triples1 - Triples0, message_level(Silent, Level), print_message(Level, rdf(restore(attached(GraphCount, Triples, T/Wall)))). load_sources(_, [], _, _) :- !. load_sources(Type, Sources, Silent, Jobs) :- length(Sources, Count), RunJobs is min(Count, Jobs), print_message(informational, rdf(restoring(Type, Count, RunJobs))), make_goals(Sources, Silent, 1, Count, Goals), concurrent(RunJobs, Goals, []). %! make_goals(+DBs, +Silent, +Index, +Total, -Goals) make_goals([], _, _, _, []). make_goals([DB|T0], Silent, I, Total, [load_source(DB, Silent, I, Total)|T]) :- I2 is I + 1, make_goals(T0, Silent, I2, Total, T). verbosity(Silent) :- rdf_option(silent(Silent)), !. verbosity(Silent) :- current_prolog_flag(verbose, silent), !, Silent = true. verbosity(brief). %! concurrency(-Jobs) % % Number of jobs to run concurrently. concurrency(Jobs) :- rdf_option(concurrency(Jobs)), !. concurrency(Jobs) :- current_prolog_flag(cpu_count, Jobs), Jobs > 0, !. concurrency(1). cpu_stat_key(1, cputime) :- !. cpu_stat_key(_, process_cputime). %! find_dbs(+Dir, -Graphs, -SnapBySize, -JournalBySize) is det. % % Scan the persistent database and return a list of snapshots and % journals, both sorted by file-size. Each term is of the form % % == % db(Size, Ext, DB, DBFile, Depth) % == find_dbs(Dir, Graphs, SnapBySize, JournalBySize) :- directory_files(Dir, Files), phrase(scan_db_files(Files, Dir, '.', 0), Scanned), maplist(db_graph, Scanned, UnsortedGraphs), sort(UnsortedGraphs, Graphs), ( consider_reindex_db(Dir, Graphs, Scanned) -> find_dbs(Dir, Graphs, SnapBySize, JournalBySize) ; partition(db_is_snapshot, Scanned, Snapshots, Journals), sort(Snapshots, SnapBySize), sort(Journals, JournalBySize) ). consider_reindex_db(Dir, Graphs, Scanned) :- length(Graphs, Count), Count > 0, DepthNeeded is floor(log(Count)/log(256)), ( maplist(depth_db(DepthNow), Scanned) -> ( DepthNeeded > DepthNow -> true ; retractall(rdf_option(directory_levels(_))), assertz(rdf_option(directory_levels(DepthNow))), fail ) ; true ), reindex_db(Dir, DepthNeeded). db_is_snapshot(Term) :- arg(2, Term, trp). db_graph(Term, DB) :- arg(3, Term, DB). db_file_name(Term, File) :- arg(4, Term, File). depth_db(Depth, DB) :- arg(5, DB, Depth). %! scan_db_files(+Files, +Dir, +Prefix, +Depth)// is det. % % Produces a list of db(DB, Size, File) for all recognised RDF % database files. File is relative to the database directory Dir. scan_db_files([], _, _, _) --> []. scan_db_files([Nofollow|T], Dir, Prefix, Depth) --> { nofollow(Nofollow) }, !, scan_db_files(T, Dir, Prefix, Depth). scan_db_files([File|T], Dir, Prefix, Depth) --> { file_name_extension(Base, Ext, File), db_extension(Ext), !, rdf_db_to_file(DB, Base), directory_file_path(Prefix, File, DBFile), directory_file_path(Dir, DBFile, AbsFile), size_file(AbsFile, Size) }, [ db(Size, Ext, DB, AbsFile, Depth) ], scan_db_files(T, Dir, Prefix, Depth). scan_db_files([D|T], Dir, Prefix, Depth) --> { directory_file_path(Prefix, D, SubD), directory_file_path(Dir, SubD, AbsD), exists_directory(AbsD), \+ read_link(AbsD, _, _), % Do not follow links !, directory_files(AbsD, SubFiles), SubDepth is Depth + 1 }, scan_db_files(SubFiles, Dir, SubD, SubDepth), scan_db_files(T, Dir, Prefix, Depth). scan_db_files([_|T], Dir, Prefix, Depth) --> scan_db_files(T, Dir, Prefix, Depth). nofollow(.). nofollow(..). db_extension(trp). db_extension(jrn). :- public load_source/4. % called through make_goals/5 load_source(DB, Silent, Nth, Total) :- db_file_name(DB, File), db_graph(DB, Graph), message_level(Silent, Level), graph_triple_count(Graph, Count0), statistics(cputime, T0), ( db_is_snapshot(DB) -> print_message(Level, rdf(restore(Silent, snapshot(Graph, File)))), rdf_load_db(File) ; print_message(Level, rdf(restore(Silent, journal(Graph, File)))), load_journal(File, Graph) ), statistics(cputime, T1), T is T1 - T0, graph_triple_count(Graph, Count1), Count is Count1 - Count0, print_message(Level, rdf(restore(Silent, done(Graph, T, Count, Nth, Total)))). graph_triple_count(Graph, Count) :- rdf_statistics(triples_by_graph(Graph, Count)), !. graph_triple_count(_, 0). %! attach_graph(+Graph, +Options) is det. % % Load triples and reload journal from the indicated snapshot % file. attach_graph(Graph, Options) :- ( option(silent(true), Options) -> Level = silent ; Level = informational ), db_files(Graph, SnapshotFile, JournalFile), rdf_retractall(_,_,_,Graph), statistics(cputime, T0), print_message(Level, rdf(restore(Silent, Graph))), db_file(SnapshotFile, AbsSnapShot), ( exists_file(AbsSnapShot) -> print_message(Level, rdf(restore(Silent, snapshot(SnapshotFile)))), rdf_load_db(AbsSnapShot) ; true ), ( exists_db(JournalFile) -> print_message(Level, rdf(restore(Silent, journal(JournalFile)))), load_journal(JournalFile, Graph) ; true ), statistics(cputime, T1), T is T1 - T0, ( rdf_statistics(triples_by_graph(Graph, Count)) -> true ; Count = 0 ), print_message(Level, rdf(restore(Silent, done(Graph, T, Count)))). message_level(true, silent) :- !. message_level(_, informational). /******************************* * LOAD JOURNAL * *******************************/ %! load_journal(+File:atom, +DB:atom) is det. % % Process transactions from the RDF journal File, adding the given % named graph. load_journal(File, DB) :- rdf_create_graph(DB), setup_call_cleanup( open(File, read, In, [encoding(utf8)]), ( read(In, T0), process_journal(T0, In, DB) ), close(In)). process_journal(end_of_file, _, _) :- !. process_journal(Term, In, DB) :- ( process_journal_term(Term, DB) -> true ; throw(error(type_error(journal_term, Term), _)) ), read(In, T2), process_journal(T2, In, DB). process_journal_term(assert(S,P,O), DB) :- rdf_assert(S,P,O,DB). process_journal_term(assert(S,P,O,Line), DB) :- rdf_assert(S,P,O,DB:Line). process_journal_term(retract(S,P,O), DB) :- rdf_retractall(S,P,O,DB). process_journal_term(retract(S,P,O,Line), DB) :- rdf_retractall(S,P,O,DB:Line). process_journal_term(update(S,P,O,Action), DB) :- ( rdf_update(S,P,O,DB, Action) -> true ; print_message(warning, rdf(update_failed(S,P,O,Action))) ). process_journal_term(start(_), _). % journal open/close process_journal_term(end(_), _). process_journal_term(begin(_), _). % logged transaction (compatibility) process_journal_term(end, _). process_journal_term(begin(_,_,_,_), _). % logged transaction (current) process_journal_term(end(_,_,_), _). /******************************* * CREATE JOURNAL * *******************************/ :- dynamic blocked_db/2, % DB, Reason transaction_message/3, % Nesting, Time, Message transaction_db/3. % Nesting, DB, Id %! rdf_persistency(+DB, Bool) % % Specify whether a database is persistent. Switching to =false= % kills the persistent state. Switching to =true= creates it. rdf_persistency(DB, Bool) :- must_be(atom, DB), must_be(boolean, Bool), fail. rdf_persistency(DB, false) :- !, ( blocked_db(DB, persistency) -> true ; assert(blocked_db(DB, persistency)), delete_db(DB) ). rdf_persistency(DB, true) :- ( retract(blocked_db(DB, persistency)) -> create_db(DB) ; true ). %! rdf_db:property_of_graph(?Property, +Graph) is nondet. % % Extend rdf_graph_property/2 with new properties. :- multifile rdf_db:property_of_graph/2. rdf_db:property_of_graph(persistent(State), Graph) :- ( blocked_db(Graph, persistency) -> State = false ; State = true ). %! start_monitor is det. %! stop_monitor is det. % % Start/stop monitoring the RDF database for changes and update % the journal. start_monitor :- rdf_monitor(monitor, [ -assert(load) ]). stop_monitor :- rdf_monitor(monitor, [ -all ]). %! monitor(+Term) is semidet. % % Handle an rdf_monitor/2 callback to deal with persistency. Note % that the monitor calls that come from rdf_db.pl that deal with % database changes are serialized. They do come from different % threads though. monitor(Msg) :- debug(monitor, 'Monitor: ~p~n', [Msg]), fail. monitor(assert(S,P,O,DB:Line)) :- !, \+ blocked_db(DB, _), journal_fd(DB, Fd), open_transaction(DB, Fd), format(Fd, '~q.~n', [assert(S,P,O,Line)]), sync_journal(DB, Fd). monitor(assert(S,P,O,DB)) :- \+ blocked_db(DB, _), journal_fd(DB, Fd), open_transaction(DB, Fd), format(Fd, '~q.~n', [assert(S,P,O)]), sync_journal(DB, Fd). monitor(retract(S,P,O,DB:Line)) :- !, \+ blocked_db(DB, _), journal_fd(DB, Fd), open_transaction(DB, Fd), format(Fd, '~q.~n', [retract(S,P,O,Line)]), sync_journal(DB, Fd). monitor(retract(S,P,O,DB)) :- \+ blocked_db(DB, _), journal_fd(DB, Fd), open_transaction(DB, Fd), format(Fd, '~q.~n', [retract(S,P,O)]), sync_journal(DB, Fd). monitor(update(S,P,O,DB:Line,Action)) :- !, \+ blocked_db(DB, _), ( Action = graph(NewDB) -> monitor(assert(S,P,O,NewDB)), monitor(retract(S,P,O,DB:Line)) ; journal_fd(DB, Fd), format(Fd, '~q.~n', [update(S,P,O,Action)]), sync_journal(DB, Fd) ). monitor(update(S,P,O,DB,Action)) :- \+ blocked_db(DB, _), ( Action = graph(NewDB) -> monitor(assert(S,P,O,NewDB)), monitor(retract(S,P,O,DB)) ; journal_fd(DB, Fd), open_transaction(DB, Fd), format(Fd, '~q.~n', [update(S,P,O,Action)]), sync_journal(DB, Fd) ). monitor(load(BE, _DumpFileURI)) :- ( BE = end(Graphs) -> sync_loaded_graphs(Graphs) ; true ). monitor(create_graph(Graph)) :- \+ blocked_db(Graph, _), journal_fd(Graph, Fd), open_transaction(Graph, Fd), sync_journal(Graph, Fd). monitor(reset) :- forall(rdf_graph(Graph), delete_db(Graph)). % TBD: Remove empty directories? monitor(transaction(BE, Id)) :- monitor_transaction(Id, BE). monitor_transaction(load_journal(DB), begin(_)) :- !, assert(blocked_db(DB, journal)). monitor_transaction(load_journal(DB), end(_)) :- !, retractall(blocked_db(DB, journal)). monitor_transaction(parse(URI), begin(_)) :- !, ( blocked_db(URI, persistency) -> true ; assert(blocked_db(URI, parse)) ). monitor_transaction(parse(URI), end(_)) :- !, ( retract(blocked_db(URI, parse)) -> create_db(URI) ; true ). monitor_transaction(unload(DB), begin(_)) :- !, ( blocked_db(DB, persistency) -> true ; assert(blocked_db(DB, unload)) ). monitor_transaction(unload(DB), end(_)) :- !, ( retract(blocked_db(DB, unload)) -> delete_db(DB) ; true ). monitor_transaction(log(Msg), begin(N)) :- !, check_nested(N), get_time(Time), asserta(transaction_message(N, Time, Msg)). monitor_transaction(log(_), end(N)) :- check_nested(N), retract(transaction_message(N, _, _)), !, findall(DB:Id, retract(transaction_db(N, DB, Id)), DBs), end_transactions(DBs, N). monitor_transaction(log(Msg, DB), begin(N)) :- !, check_nested(N), get_time(Time), asserta(transaction_message(N, Time, Msg)), journal_fd(DB, Fd), open_transaction(DB, Fd). monitor_transaction(log(Msg, _DB), end(N)) :- monitor_transaction(log(Msg), end(N)). %! check_nested(+Level) is semidet. % % True if we must log this transaction. This is always the case % for toplevel transactions. Nested transactions are only logged % if log_nested_transactions(true) is defined. check_nested(0) :- !. check_nested(_) :- rdf_option(log_nested_transactions(true)). %! open_transaction(+DB, +Fd) is det. % % Add a begin(Id, Level, Time, Message) term if a transaction % involves DB. Id is an incremental integer, where each database % has its own counter. Level is the nesting level, Time a floating % point timestamp and Message te message provided as argument to % the log message. open_transaction(DB, Fd) :- transaction_message(N, Time, Msg), !, ( transaction_db(N, DB, _) -> true ; next_transaction_id(DB, Id), assert(transaction_db(N, DB, Id)), RoundedTime is round(Time*100)/100, format(Fd, '~q.~n', [begin(Id, N, RoundedTime, Msg)]) ). open_transaction(_,_). %! next_transaction_id(+DB, -Id) is det. % % Id is the number to user for the next logged transaction on DB. % Transactions in each named graph are numbered in sequence. % Searching the Id of the last transaction is performed by the 2nd % clause starting 1Kb from the end and doubling this offset each % failure. :- dynamic current_transaction_id/2. next_transaction_id(DB, Id) :- retract(current_transaction_id(DB, Last)), !, Id is Last + 1, assert(current_transaction_id(DB, Id)). next_transaction_id(DB, Id) :- db_files(DB, _, Journal), exists_file(Journal), !, size_file(Journal, Size), open_db(Journal, read, In, []), call_cleanup(iterative_expand(In, Size, Last), close(In)), Id is Last + 1, assert(current_transaction_id(DB, Id)). next_transaction_id(DB, 1) :- assert(current_transaction_id(DB, 1)). iterative_expand(_, 0, 0) :- !. iterative_expand(In, Size, Last) :- % Scan growing sections from the end Max is floor(log(Size)/log(2)), between(10, Max, Step), Offset is -(1< 0, !. iterative_expand(In, _, Last) :- % Scan the whole file seek(In, 0, bof, _), read(In, T0), last_transaction_id(T0, In, 0, Last). last_transaction_id(end_of_file, _, Last, Last) :- !. last_transaction_id(end(Id, _, _), In, _, Last) :- read(In, T1), last_transaction_id(T1, In, Id, Last). last_transaction_id(_, In, Id, Last) :- read(In, T1), last_transaction_id(T1, In, Id, Last). %! end_transactions(+DBs:list(atom:id)) is det. % % End a transaction that affected the given list of databases. We % write the list of other affected databases as an argument to the % end-term to facilitate fast finding of the related transactions. % % In each database, the transaction is ended with a term end(Id, % Nesting, Others), where Id and Nesting are the transaction % identifier and nesting (see open_transaction/2) and Others is a % list of DB:Id, indicating other databases affected by the % transaction. end_transactions(DBs, N) :- end_transactions(DBs, DBs, N). end_transactions([], _, _). end_transactions([DB:Id|T], DBs, N) :- journal_fd(DB, Fd), once(select(DB:Id, DBs, Others)), format(Fd, 'end(~q, ~q, ~q).~n', [Id, N, Others]), sync_journal(DB, Fd), end_transactions(T, DBs, N). %! sync_loaded_graphs(+Graphs) % % Called after a binary triple has been loaded that added triples % to the given graphs. sync_loaded_graphs(Graphs) :- maplist(create_db, Graphs). /******************************* * JOURNAL FILES * *******************************/ %! journal_fd(+DB, -Stream) is det. % % Get an open stream to a journal. If the journal is not open, old % journals are closed to satisfy the =max_open_journals= option. % Then the journal is opened in =append= mode. Journal files are % always encoded as UTF-8 for portability as well as to ensure % full coverage of Unicode. journal_fd(DB, Fd) :- source_journal_fd(DB, Fd), !. journal_fd(DB, Fd) :- with_mutex(rdf_journal_file, journal_fd_(DB, Out)), Fd = Out. journal_fd_(DB, Fd) :- source_journal_fd(DB, Fd), !. journal_fd_(DB, Fd) :- limit_fd_pool, db_files(DB, _Snapshot, Journal), open_db(Journal, append, Fd, [ close_on_abort(false) ]), time_stamp(Now), format(Fd, '~q.~n', [start([time(Now)])]), assert(source_journal_fd(DB, Fd)). % new one at the end %! limit_fd_pool is det. % % Limit the number of open journals to max_open_journals (10). % Note that calls from rdf_monitor/2 are issued in different % threads, but as they are part of write operations they are fully % synchronised. limit_fd_pool :- predicate_property(source_journal_fd(_, _), number_of_clauses(N)), !, ( rdf_option(max_open_journals(Max)) -> true ; Max = 10 ), Close is N - Max, forall(between(1, Close, _), close_oldest_journal). limit_fd_pool. close_oldest_journal :- source_journal_fd(DB, _Fd), !, debug(rdf_persistency, 'Closing old journal for ~q', [DB]), close_journal(DB). close_oldest_journal. %! sync_journal(+DB, +Fd) % % Sync journal represented by database and stream. If the DB is % involved in a transaction there is no point flushing until the % end of the transaction. sync_journal(DB, _) :- transaction_db(_, DB, _), !. sync_journal(_, Fd) :- flush_output(Fd). %! close_journal(+DB) is det. % % Close the journal associated with DB if it is open. close_journal(DB) :- with_mutex(rdf_journal_file, close_journal_(DB)). close_journal_(DB) :- ( retract(source_journal_fd(DB, Fd)) -> time_stamp(Now), format(Fd, '~q.~n', [end([time(Now)])]), close(Fd, [force(true)]) ; true ). %! close_journals % % Close all open journals. close_journals :- forall(source_journal_fd(DB, _), catch(close_journal(DB), E, print_message(error, E))). %! create_db(+Graph) % % Create a saved version of Graph in corresponding file, close and % delete journals. create_db(Graph) :- \+ rdf(_,_,_,Graph), !, debug(rdf_persistency, 'Deleting empty Graph ~w', [Graph]), delete_db(Graph). create_db(Graph) :- debug(rdf_persistency, 'Saving Graph ~w', [Graph]), close_journal(Graph), db_abs_files(Graph, Snapshot, Journal), atom_concat(Snapshot, '.new', NewSnapshot), ( catch(( create_directory_levels(Snapshot), rdf_save_db(NewSnapshot, Graph) ), Error, ( print_message(warning, Error), fail )) -> ( exists_file(Journal) -> delete_file(Journal) ; true ), rename_file(NewSnapshot, Snapshot), debug(rdf_persistency, 'Saved Graph ~w', [Graph]) ; catch(delete_file(NewSnapshot), _, true) ). %! delete_db(+DB) % % Remove snapshot and journal file for DB. delete_db(DB) :- with_mutex(rdf_journal_file, delete_db_(DB)). delete_db_(DB) :- close_journal_(DB), db_abs_files(DB, Snapshot, Journal), !, ( exists_file(Journal) -> delete_file(Journal) ; true ), ( exists_file(Snapshot) -> delete_file(Snapshot) ; true ). delete_db_(_). /******************************* * LOCKING * *******************************/ %! lock_db(+Dir) % % Lock the database directory Dir. lock_db(Dir) :- lockfile(Dir, File), catch(open(File, update, Out, [lock(write), wait(false)]), error(permission_error(Access, _, _), _), locked_error(Access, Dir)), ( current_prolog_flag(pid, PID) -> true ; PID = 0 % TBD: Fix in Prolog ), time_stamp(Now), gethostname(Host), format(Out, '/* RDF Database is in use */~n~n', []), format(Out, '~q.~n', [ locked([ time(Now), pid(PID), host(Host) ]) ]), flush_output(Out), set_end_of_stream(Out), assert(rdf_lock(Dir, lock(Out, File))), at_halt(unlock_db(Dir)). locked_error(lock, Dir) :- lockfile(Dir, File), ( catch(read_file_to_terms(File, Terms, []), _, fail), Terms = [locked(Args)] -> Context = rdf_locked(Args) ; Context = context(_, 'Database is in use') ), throw(error(permission_error(lock, rdf_db, Dir), Context)). locked_error(open, Dir) :- throw(error(permission_error(lock, rdf_db, Dir), context(_, 'Lock file cannot be opened'))). %! unlock_db(+Dir) is det. %! unlock_db(+Stream, +File) is det. unlock_db(Dir) :- retract(rdf_lock(Dir, lock(Out, File))), !, unlock_db(Out, File). unlock_db(_). unlock_db(Out, File) :- close(Out), delete_file(File). /******************************* * FILENAMES * *******************************/ lockfile(Dir, LockFile) :- atomic_list_concat([Dir, /, lock], LockFile). directory_levels(Levels) :- rdf_option(directory_levels(Levels)), !. directory_levels(2). db_file(Base, File) :- rdf_directory(Dir), directory_levels(Levels), db_file(Dir, Base, Levels, File). db_file(Dir, Base, Levels, File) :- dir_levels(Base, Levels, Segments, [Base]), atomic_list_concat([Dir|Segments], /, File). open_db(Base, Mode, Stream, Options) :- db_file(Base, File), create_directory_levels(File), open(File, Mode, Stream, [encoding(utf8)|Options]). create_directory_levels(_File) :- rdf_option(directory_levels(0)), !. create_directory_levels(File) :- file_directory_name(File, Dir), make_directory_path(Dir). exists_db(Base) :- db_file(Base, File), exists_file(File). %! dir_levels(+File, +Levels, ?Segments, ?Tail) is det. % % Create a list of intermediate directory names for File. Each % directory consists of two hexadecimal digits. dir_levels(_, 0, Segments, Segments) :- !. dir_levels(File, Levels, Segments, Tail) :- rdf_atom_md5(File, 1, Hash), create_dir_levels(Levels, 0, Hash, Segments, Tail). create_dir_levels(0, _, _, Segments, Segments) :- !. create_dir_levels(N, S, Hash, [S1|Segments0], Tail) :- sub_atom(Hash, S, 2, _, S1), S2 is S+2, N2 is N-1, create_dir_levels(N2, S2, Hash, Segments0, Tail). %! db_files(+DB, -Snapshot, -Journal). %! db_files(-DB, +Snapshot, -Journal). %! db_files(-DB, -Snapshot, +Journal). % % True if named graph DB is represented by the files Snapshot and % Journal. The filenames are local to the directory representing % the store. db_files(DB, Snapshot, Journal) :- nonvar(DB), !, rdf_db_to_file(DB, Base), atom_concat(Base, '.trp', Snapshot), atom_concat(Base, '.jrn', Journal). db_files(DB, Snapshot, Journal) :- nonvar(Snapshot), !, atom_concat(Base, '.trp', Snapshot), atom_concat(Base, '.jrn', Journal), rdf_db_to_file(DB, Base). db_files(DB, Snapshot, Journal) :- nonvar(Journal), !, atom_concat(Base, '.jrn', Journal), atom_concat(Base, '.trp', Snapshot), rdf_db_to_file(DB, Base). db_abs_files(DB, Snapshot, Journal) :- db_files(DB, Snapshot0, Journal0), db_file(Snapshot0, Snapshot), db_file(Journal0, Journal). %! rdf_journal_file(+Graph, -File) is semidet. %! rdf_journal_file(-Graph, -File) is nondet. % % True if File the name of the existing journal file for Graph. rdf_journal_file(Graph, Journal) :- ( var(Graph) -> rdf_graph(Graph) ; true ), db_abs_files(Graph, _Snapshot, Journal), exists_file(Journal). %! rdf_snapshot_file(+Graph, -File) is semidet. %! rdf_snapshot_file(-Graph, -File) is nondet. % % True if File the name of the existing snapshot file for Graph. rdf_snapshot_file(Graph, Snapshot) :- ( var(Graph) -> rdf_graph(Graph) % also pick the empty graphs ; true ), db_abs_files(Graph, Snapshot, _Journal), exists_file(Snapshot). %! rdf_db_to_file(+DB, -File) is det. %! rdf_db_to_file(-DB, +File) is det. % % Translate between database encoding (often an file or URL) and % the name we store in the directory. We keep a cache for two % reasons. Speed, but much more important is that the mapping of % raw --> encoded provided by www_form_encode/2 is not guaranteed % to be unique by the W3C standards. rdf_db_to_file(DB, File) :- file_base_db(File, DB), !. rdf_db_to_file(DB, File) :- url_to_filename(DB, File), assert(file_base_db(File, DB)). %! url_to_filename(+URL, -FileName) is det. %! url_to_filename(-URL, +FileName) is det. % % Turn a valid URL into a filename. Earlier versions used % www_form_encode/2, but this can produce characters that are not % valid in filenames. We will use the same encoding as % www_form_encode/2, but using our own rules for allowed % characters. The only requirement is that we avoid any filename % special character in use. The current encoding use US-ASCII % alnum characters, _ and % url_to_filename(URL, FileName) :- atomic(URL), !, atom_codes(URL, Codes), phrase(url_encode(EncCodes), Codes), atom_codes(FileName, EncCodes). url_to_filename(URL, FileName) :- uri_encoded(path, URL, FileName). url_encode([0'+|T]) --> " ", !, url_encode(T). url_encode([C|T]) --> alphanum(C), !, url_encode(T). url_encode([C|T]) --> no_enc_extra(C), !, url_encode(T). url_encode(Enc) --> ( "\r\n" ; "\n" ), !, { string_codes("%0D%0A", Codes), append(Codes, T, Enc) }, url_encode(T). url_encode([]) --> eos, !. url_encode([0'%,D1,D2|T]) --> [C], { Dv1 is (C>>4 /\ 0xf), Dv2 is (C /\ 0xf), code_type(D1, xdigit(Dv1)), code_type(D2, xdigit(Dv2)) }, url_encode(T). eos([], []). alphanum(C) --> [C], { C < 128, % US-ASCII code_type(C, alnum) }. no_enc_extra(0'_) --> "_". /******************************* * REINDEX * *******************************/ %! reindex_db(+Dir, +Levels) % % Reindex the database by creating intermediate directories. reindex_db(Dir, Levels) :- directory_files(Dir, Files), reindex_files(Files, Dir, '.', 0, Levels), remove_empty_directories(Files, Dir). reindex_files([], _, _, _, _). reindex_files([Nofollow|Files], Dir, Prefix, CLevel, Levels) :- nofollow(Nofollow), !, reindex_files(Files, Dir, Prefix, CLevel, Levels). reindex_files([File|Files], Dir, Prefix, CLevel, Levels) :- CLevel \== Levels, file_name_extension(_Base, Ext, File), db_extension(Ext), !, directory_file_path(Prefix, File, DBFile), directory_file_path(Dir, DBFile, OldPath), db_file(Dir, File, Levels, NewPath), debug(rdf_persistency, 'Rename ~q --> ~q', [OldPath, NewPath]), file_directory_name(NewPath, NewDir), make_directory_path(NewDir), rename_file(OldPath, NewPath), reindex_files(Files, Dir, Prefix, CLevel, Levels). reindex_files([D|Files], Dir, Prefix, CLevel, Levels) :- directory_file_path(Prefix, D, SubD), directory_file_path(Dir, SubD, AbsD), exists_directory(AbsD), \+ read_link(AbsD, _, _), % Do not follow links !, directory_files(AbsD, SubFiles), CLevel2 is CLevel + 1, reindex_files(SubFiles, Dir, SubD, CLevel2, Levels), reindex_files(Files, Dir, Prefix, CLevel, Levels). reindex_files([_|Files], Dir, Prefix, CLevel, Levels) :- reindex_files(Files, Dir, Prefix, CLevel, Levels). remove_empty_directories([], _). remove_empty_directories([File|Files], Dir) :- \+ nofollow(File), directory_file_path(Dir, File, Path), exists_directory(Path), \+ read_link(Path, _, _), !, directory_files(Path, Content), exclude(nofollow, Content, RealContent), ( RealContent == [] -> debug(rdf_persistency, 'Remove empty dir ~q', [Path]), delete_directory(Path) ; remove_empty_directories(RealContent, Path) ), remove_empty_directories(Files, Dir). remove_empty_directories([_|Files], Dir) :- remove_empty_directories(Files, Dir). /******************************* * PREFIXES * *******************************/ save_prefixes(Dir) :- atomic_list_concat([Dir, /, 'prefixes.db'], PrefixFile), setup_call_cleanup(open(PrefixFile, write, Out, [encoding(utf8)]), write_prefixes(Out), close(Out)). write_prefixes(Out) :- format(Out, '% Snapshot of defined RDF prefixes~n~n', []), forall(rdf_current_ns(Alias, URI), format(Out, 'prefix(~q, ~q).~n', [Alias, URI])). %! load_prefixes(+RDFDBDir) is det. % % If the file RDFDBDir/prefixes.db exists, load the prefixes. The % prefixes are registered using rdf_register_ns/3. Possible errors % because the prefix definitions have changed are printed as % warnings, retaining the old definition. Note that changing % prefixes generally requires reloading all RDF from the source. load_prefixes(Dir) :- atomic_list_concat([Dir, /, 'prefixes.db'], PrefixFile), ( exists_file(PrefixFile) -> setup_call_cleanup(open(PrefixFile, read, In, [encoding(utf8)]), read_prefixes(In), close(In)) ; true ). read_prefixes(Stream) :- read_term(Stream, T0, []), read_prefixes(T0, Stream). read_prefixes(end_of_file, _) :- !. read_prefixes(prefix(Alias, URI), Stream) :- !, must_be(atom, Alias), must_be(atom, URI), catch(rdf_register_ns(Alias, URI, []), E, print_message(warning, E)), read_term(Stream, T, []), read_prefixes(T, Stream). read_prefixes(Term, _) :- domain_error(prefix_term, Term). /******************************* * UTIL * *******************************/ %! mkdir(+Directory) % % Create a directory if it does not already exist. mkdir(Directory) :- exists_directory(Directory), !. mkdir(Directory) :- make_directory(Directory). %! time_stamp(-Integer) % % Return time-stamp rounded to integer. time_stamp(Int) :- get_time(Now), Int is round(Now). /******************************* * MESSAGES * *******************************/ :- multifile prolog:message/3, prolog:message_context/3. prolog:message(rdf(Term)) --> message(Term). message(restoring(Type, Count, Jobs)) --> [ 'Restoring ~D ~w using ~D concurrent workers'-[Count, Type, Jobs] ]. message(restore(attached(Graphs, Triples, Time/Wall))) --> { catch(Percent is round(100*Time/Wall), _, Percent = 0) }, [ 'Loaded ~D graphs (~D triples) in ~2f sec. (~d% CPU = ~2f sec.)'- [Graphs, Triples, Wall, Percent, Time] ]. % attach_graph/2 message(restore(true, Action)) --> !, silent_message(Action). message(restore(brief, Action)) --> !, brief_message(Action). message(restore(_, Graph)) --> [ 'Restoring ~p ... '-[Graph], flush ]. message(restore(_, snapshot(_))) --> [ at_same_line, '(snapshot) '-[], flush ]. message(restore(_, journal(_))) --> [ at_same_line, '(journal) '-[], flush ]. message(restore(_, done(_, Time, Count))) --> [ at_same_line, '~D triples in ~2f sec.'-[Count, Time] ]. % load_source/4 message(restore(_, snapshot(G, _))) --> [ 'Restoring ~p\t(snapshot)'-[G], flush ]. message(restore(_, journal(G, _))) --> [ 'Restoring ~p\t(journal)'-[G], flush ]. message(restore(_, done(_, Time, Count))) --> [ at_same_line, '~D triples in ~2f sec.'-[Count, Time] ]. % journal handling message(update_failed(S,P,O,Action)) --> [ 'Failed to update <~p ~p ~p> with ~p'-[S,P,O,Action] ]. % directory reindexing message(reindex(Count, Depth)) --> [ 'Restructuring database with ~d levels (~D graphs)'-[Depth, Count] ]. message(reindex(Depth)) --> [ 'Fixing database directory structure (~d levels)'-[Depth] ]. message(read_only) --> [ 'Cannot write persistent store; continuing in read-only mode.', nl, 'All changes to the RDF store will be lost if this process terminates.' ]. silent_message(_Action) --> []. brief_message(done(Graph, _Time, _Count, Nth, Total)) --> { file_base_name(Graph, Base) }, [ at_same_line, '\r~p~`.t ~D of ~D graphs~72|'-[Base, Nth, Total], flush ]. brief_message(_) --> []. prolog:message_context(rdf_locked(Args)) --> { memberchk(time(Time), Args), memberchk(pid(Pid), Args), format_time(string(S), '%+', Time) }, [ nl, 'locked at ~s by process id ~w'-[S,Pid] ].